Spaces:
Running
Running
| import { useEffect, useRef, useState } from 'react' | |
| import { useNavigate, useLocation } from 'react-router-dom' | |
| import { WEBHOOK_URL } from '../lib/webrtc' | |
| const TYPE_META = { | |
| university: { label: 'University Call Agent', accent: '#60d0f0', icon: '⬡' }, | |
| realestate: { label: 'Real Estate Call Agent', accent: '#f0b060', icon: '⬢' }, | |
| coaching: { label: 'Coaching Institute Agent', accent: '#b088f0', icon: '◆' }, | |
| medical: { label: 'Medical OPD Agent', accent: '#5fd0a0', icon: '✚' }, | |
| hotel: { label: 'Hotel Concierge Agent', accent: '#f0a0c0', icon: '❖' }, | |
| } | |
| export default function CallRoom({ type, sessionId }) { | |
| const navigate = useNavigate() | |
| const { state } = useLocation() | |
| const chatEndRef = useRef(null) | |
| const [messages, setMessages] = useState([]) | |
| const [connected, setConnected] = useState(false) | |
| const [copied, setCopied] = useState(false) | |
| const [elapsed, setElapsed] = useState(0) | |
| const meta = TYPE_META[type] || TYPE_META.university | |
| const phone = state?.phone || '' | |
| const agentName = state?.agentName || 'Agent' | |
| const watchUrl = `${window.location.origin}${window.location.pathname}#/call/${type}/${sessionId}` | |
| useEffect(() => { | |
| const t = setInterval(() => setElapsed(s => s + 1), 1000) | |
| return () => clearInterval(t) | |
| }, []) | |
| const fmtTime = s => { | |
| const m = Math.floor(s / 60).toString().padStart(2, '0') | |
| const ss = (s % 60).toString().padStart(2, '0') | |
| return `${m}:${ss}` | |
| } | |
| useEffect(() => { | |
| chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }) | |
| }, [messages]) | |
| useEffect(() => { | |
| const es = new EventSource(`${WEBHOOK_URL}/events?session=${sessionId}`) | |
| es.onopen = () => setConnected(true) | |
| es.onmessage = e => { | |
| try { | |
| const data = JSON.parse(e.data) | |
| console.log('[sse] raw event:', JSON.stringify(data).slice(0, 400)) | |
| const responses = data.responses || [] | |
| const msgs = [] | |
| // User transcript | |
| const userEntry = responses.find(r => !r.streaming && r.out?.user_input) | |
| if (userEntry) { | |
| const text = userEntry.out.user_input | |
| .replace(/<turn-visual-context>[\s\S]*?<\/turn-visual-context>/g, '') | |
| .trim() | |
| if (text) msgs.push({ role: 'user', text }) | |
| } | |
| // AI text — main LLM only (prev_llm is audio filler, not transcript) | |
| const aiRaw = responses | |
| .filter(r => r.streaming && r.chunk && r.node === 'llm') | |
| .map(r => r.chunk) | |
| .join('') | |
| const aiText = aiRaw | |
| .split(/<\|speak\|>/) | |
| .map(seg => seg.replace(/<\|hangup\|>\S*/g, '').trim()) | |
| .filter(Boolean) | |
| .join(' ') | |
| .trim() | |
| if (aiText) msgs.push({ role: 'ai', text: aiText }) | |
| if (msgs.length) setMessages(prev => [...prev, ...msgs]) | |
| } catch (err) { | |
| console.error('[sse] parse/render error:', err, 'raw:', e.data?.slice(0, 200)) | |
| } | |
| } | |
| es.onerror = () => setConnected(false) | |
| return () => es.close() | |
| }, [sessionId]) | |
| function copyLink() { | |
| navigator.clipboard.writeText(watchUrl).then(() => { | |
| setCopied(true) | |
| setTimeout(() => setCopied(false), 2000) | |
| }) | |
| } | |
| return ( | |
| <div style={s.root}> | |
| {/* topbar */} | |
| <header style={s.topbar}> | |
| <div style={s.topLeft}> | |
| <span style={{ ...s.typeChip, color: meta.accent, borderColor: `${meta.accent}40` }}> | |
| {meta.icon} {meta.label} | |
| </span> | |
| {phone && ( | |
| <span style={s.phone}>{phone}</span> | |
| )} | |
| </div> | |
| <div style={s.topRight}> | |
| <span style={s.timer}>{fmtTime(elapsed)}</span> | |
| <div style={{ ...s.liveChip, ...(connected ? s.liveOn : s.liveOff) }}> | |
| <div style={{ ...s.liveDot, background: connected ? 'var(--accent)' : 'var(--muted)', animation: connected ? 'blink .8s step-end infinite' : 'none' }} /> | |
| {connected ? 'Live' : 'Waiting'} | |
| </div> | |
| </div> | |
| </header> | |
| {/* body */} | |
| <div style={s.body}> | |
| {/* main chat */} | |
| <main style={s.chatArea}> | |
| <div style={s.chatScroll}> | |
| {messages.length === 0 ? ( | |
| <div style={s.emptyState}> | |
| <span style={{ ...s.emptyIcon, color: meta.accent }}>{meta.icon}</span> | |
| <p style={s.emptyTitle}> | |
| {connected ? 'Call connected — waiting for conversation…' : 'Connecting to call…'} | |
| </p> | |
| <p style={s.emptyHint}> | |
| Messages will appear here as the conversation unfolds. | |
| </p> | |
| </div> | |
| ) : ( | |
| messages.map((msg, i) => ( | |
| <div key={i} style={{ ...s.msgRow, ...(msg.role === 'user' ? s.msgRowUser : s.msgRowAi) }}> | |
| <span style={{ ...s.role, color: msg.role === 'user' ? 'var(--user-ring)' : meta.accent }}> | |
| {msg.role === 'user' ? 'Caller' : agentName} | |
| </span> | |
| <div style={{ ...s.bubble, ...(msg.role === 'user' ? s.bubbleUser : { ...s.bubbleAi, borderColor: `${meta.accent}30` }) }}> | |
| {msg.text} | |
| </div> | |
| </div> | |
| )) | |
| )} | |
| <div ref={chatEndRef} /> | |
| </div> | |
| </main> | |
| {/* sidebar */} | |
| <aside style={s.sidebar}> | |
| <div style={s.sideSection}> | |
| <p style={s.sideLabel}>Session</p> | |
| <p style={s.sideValue}>{sessionId.slice(0, 8)}…</p> | |
| </div> | |
| <div style={s.sideDivider} /> | |
| <div style={s.sideSection}> | |
| <p style={s.sideLabel}>Share view</p> | |
| <p style={s.shareUrl}>{watchUrl}</p> | |
| <button | |
| style={{ ...s.copyBtn, borderColor: copied ? meta.accent : 'var(--border2)', color: copied ? meta.accent : 'var(--text)' }} | |
| onClick={copyLink} | |
| > | |
| {copied ? 'Copied!' : 'Copy link'} | |
| </button> | |
| </div> | |
| <div style={s.sideDivider} /> | |
| <button style={s.homeBtn} onClick={() => navigate('/')}> | |
| ← New session | |
| </button> | |
| </aside> | |
| </div> | |
| </div> | |
| ) | |
| } | |
| const s = { | |
| root: { height: '100vh', display: 'flex', flexDirection: 'column', overflow: 'hidden' }, | |
| topbar: { | |
| display: 'flex', alignItems: 'center', justifyContent: 'space-between', | |
| padding: '0 28px', height: 56, borderBottom: '1px solid var(--border)', flexShrink: 0, | |
| }, | |
| topLeft: { display: 'flex', alignItems: 'center', gap: 12 }, | |
| topRight: { display: 'flex', alignItems: 'center', gap: 12 }, | |
| typeChip: { | |
| fontFamily: 'var(--mono)', fontSize: 10, letterSpacing: '.12em', | |
| textTransform: 'uppercase', padding: '3px 9px', borderRadius: 20, border: '1px solid', | |
| }, | |
| phone: { fontFamily: 'var(--mono)', fontSize: 11, color: 'var(--muted)' }, | |
| timer: { fontFamily: 'var(--mono)', fontSize: 12, color: 'var(--muted)', letterSpacing: '.1em' }, | |
| liveChip: { | |
| display: 'flex', alignItems: 'center', gap: 6, | |
| fontFamily: 'var(--mono)', fontSize: 10, letterSpacing: '.14em', textTransform: 'uppercase', | |
| padding: '4px 10px', borderRadius: 20, border: '1px solid var(--border2)', | |
| }, | |
| liveOn: { color: 'var(--accent)', borderColor: 'rgba(200,240,96,.3)' }, | |
| liveOff: { color: 'var(--muted)' }, | |
| liveDot: { width: 5, height: 5, borderRadius: '50%', flexShrink: 0 }, | |
| body: { flex: 1, minHeight: 0, display: 'flex', flexDirection: 'row', overflow: 'hidden' }, | |
| chatArea: { flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }, | |
| chatScroll: { | |
| flex: 1, overflowY: 'auto', padding: '24px 28px', | |
| display: 'flex', flexDirection: 'column', gap: 16, | |
| }, | |
| emptyState: { | |
| display: 'flex', flexDirection: 'column', alignItems: 'center', | |
| justifyContent: 'center', gap: 12, flex: 1, paddingTop: 80, | |
| }, | |
| emptyIcon: { fontSize: 32, opacity: .4 }, | |
| emptyTitle: { fontFamily: 'var(--sans)', fontSize: 15, fontWeight: 600, color: 'var(--text)', opacity: .6 }, | |
| emptyHint: { | |
| fontFamily: 'var(--mono)', fontSize: 11, color: 'var(--muted)', | |
| textAlign: 'center', maxWidth: 360, lineHeight: 1.6, letterSpacing: '.06em', | |
| }, | |
| msgRow: { display: 'flex', flexDirection: 'column', gap: 5, maxWidth: 640 }, | |
| msgRowUser: { alignSelf: 'flex-end', alignItems: 'flex-end' }, | |
| msgRowAi: { alignSelf: 'flex-start', alignItems: 'flex-start' }, | |
| role: { fontFamily: 'var(--mono)', fontSize: 8, letterSpacing: '.2em', textTransform: 'uppercase' }, | |
| bubble: { fontSize: 13, lineHeight: 1.6, padding: '10px 14px', borderRadius: 12, wordBreak: 'break-word' }, | |
| bubbleUser: { | |
| background: 'rgba(200,240,96,.08)', border: '1px solid rgba(200,240,96,.18)', | |
| color: 'var(--text)', borderBottomRightRadius: 3, | |
| }, | |
| bubbleAi: { | |
| background: 'var(--surface)', border: '1px solid', | |
| color: 'var(--text)', borderBottomLeftRadius: 3, | |
| }, | |
| sidebar: { | |
| width: 220, flexShrink: 0, borderLeft: '1px solid var(--border)', | |
| background: 'var(--surface)', display: 'flex', flexDirection: 'column', | |
| padding: '24px 18px', gap: 0, | |
| }, | |
| sideSection: { display: 'flex', flexDirection: 'column', gap: 6 }, | |
| sideLabel: { | |
| fontFamily: 'var(--mono)', fontSize: 8, letterSpacing: '.22em', | |
| textTransform: 'uppercase', color: 'var(--muted)', | |
| }, | |
| sideValue: { fontFamily: 'var(--mono)', fontSize: 11, color: 'var(--text)' }, | |
| sideDivider: { height: 1, background: 'var(--border)', margin: '20px 0' }, | |
| shareUrl: { | |
| fontFamily: 'var(--mono)', fontSize: 9, color: 'var(--muted)', | |
| wordBreak: 'break-all', lineHeight: 1.5, marginBottom: 8, | |
| }, | |
| copyBtn: { | |
| fontFamily: 'var(--mono)', fontSize: 9, letterSpacing: '.12em', | |
| padding: '7px 0', borderRadius: 6, border: '1px solid', | |
| background: 'transparent', cursor: 'pointer', width: '100%', | |
| textAlign: 'center', transition: 'border-color .15s, color .15s', | |
| }, | |
| homeBtn: { | |
| fontFamily: 'var(--mono)', fontSize: 10, color: 'var(--muted)', | |
| background: 'none', border: 'none', cursor: 'pointer', | |
| letterSpacing: '.08em', padding: 0, textAlign: 'left', marginTop: 'auto', | |
| }, | |
| } | |