Spaces:
Running
Running
| import { useEffect, useRef, useState } from 'react' | |
| import { useNavigate } from 'react-router-dom' | |
| import { WEBHOOK_URL } from '../lib/webrtc' | |
| export default function WatchRoom({ sessionId }) { | |
| const navigate = useNavigate() | |
| const chatEndRef = useRef(null) | |
| const [messages, setMessages] = useState([]) | |
| const [connected, setConnected] = useState(false) | |
| const [copied, setCopied] = useState(false) | |
| const watchUrl = `${window.location.origin}${window.location.pathname}#/watch/${sessionId}` | |
| 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) | |
| const responses = data.responses || [] | |
| const msgs = [] | |
| // User transcript — non-streaming entry with out.user_input | |
| 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, '') | |
| .replace(/<non-verbal-elements>[\s\S]*?<\/non-verbal-elements>/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 {} | |
| } | |
| 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}> | |
| {/* sidebar */} | |
| <aside style={s.sidebar}> | |
| <div style={s.sideTop}> | |
| <button style={s.back} onClick={() => navigate('/')}>← home</button> | |
| <p style={s.sideLabel}>watching session</p> | |
| <p style={s.sessionId}>{sessionId}</p> | |
| <div style={{ ...s.dot, background: connected ? 'var(--accent)' : 'var(--muted)', animation: connected ? 'blink .8s step-end infinite' : 'none' }} /> | |
| <p style={s.connLabel}>{connected ? 'live' : 'waiting…'}</p> | |
| </div> | |
| <div style={s.sideDivider} /> | |
| <div style={s.sideBottom}> | |
| <p style={s.shareSectionLabel}>Share this view</p> | |
| <p style={s.shareUrl}>{watchUrl}</p> | |
| <button | |
| style={{ ...s.copyBtn, ...(copied ? s.copyBtnDone : {}) }} | |
| onClick={copyLink} | |
| > | |
| {copied ? 'Copied!' : 'Copy link'} | |
| </button> | |
| </div> | |
| </aside> | |
| {/* main chat area */} | |
| <main style={s.main}> | |
| <header style={s.header}> | |
| <span style={s.headerLabel}>Live Transcript</span> | |
| {messages.length > 0 && ( | |
| <span style={s.msgCount}>{messages.length} messages</span> | |
| )} | |
| </header> | |
| <div style={s.chatScroll}> | |
| {messages.length === 0 ? ( | |
| <div style={s.emptyState}> | |
| <span style={s.emptyIcon}>◈</span> | |
| <p style={s.emptyTitle}>Waiting for the interview to begin</p> | |
| <p style={s.emptyHint}>Messages will appear here in real time 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)' : 'var(--ai-ring)' }}> | |
| {msg.role === 'user' ? 'Candidate' : 'Interviewer'} | |
| </span> | |
| <div style={{ ...s.bubble, ...(msg.role === 'user' ? s.bubbleUser : s.bubbleAi) }}> | |
| {msg.text} | |
| </div> | |
| </div> | |
| )) | |
| )} | |
| <div ref={chatEndRef} /> | |
| </div> | |
| </main> | |
| </div> | |
| ) | |
| } | |
| const s = { | |
| root: { | |
| height: '100vh', | |
| display: 'flex', | |
| flexDirection: 'row', | |
| overflow: 'hidden', | |
| }, | |
| sidebar: { | |
| width: 220, | |
| flexShrink: 0, | |
| borderRight: '1px solid var(--border)', | |
| background: 'var(--surface)', | |
| display: 'flex', | |
| flexDirection: 'column', | |
| padding: '24px 18px', | |
| gap: 0, | |
| }, | |
| sideTop: { | |
| display: 'flex', | |
| flexDirection: 'column', | |
| gap: 6, | |
| }, | |
| back: { | |
| alignSelf: 'flex-start', | |
| fontFamily: 'var(--mono)', | |
| fontSize: 10, | |
| color: 'var(--muted)', | |
| background: 'none', | |
| border: 'none', | |
| cursor: 'pointer', | |
| letterSpacing: '.08em', | |
| padding: 0, | |
| marginBottom: 20, | |
| }, | |
| sideLabel: { | |
| fontFamily: 'var(--mono)', | |
| fontSize: 8, | |
| letterSpacing: '.22em', | |
| textTransform: 'uppercase', | |
| color: 'var(--muted)', | |
| }, | |
| sessionId: { | |
| fontFamily: 'var(--mono)', | |
| fontSize: 10, | |
| color: 'var(--text)', | |
| wordBreak: 'break-all', | |
| lineHeight: 1.5, | |
| marginBottom: 8, | |
| }, | |
| dot: { | |
| width: 6, | |
| height: 6, | |
| borderRadius: '50%', | |
| flexShrink: 0, | |
| }, | |
| connLabel: { | |
| fontFamily: 'var(--mono)', | |
| fontSize: 9, | |
| letterSpacing: '.18em', | |
| textTransform: 'uppercase', | |
| color: 'var(--muted)', | |
| marginTop: -4, | |
| }, | |
| sideDivider: { | |
| height: 1, | |
| background: 'var(--border)', | |
| margin: '20px 0', | |
| }, | |
| sideBottom: { | |
| display: 'flex', | |
| flexDirection: 'column', | |
| gap: 8, | |
| }, | |
| shareSectionLabel: { | |
| fontFamily: 'var(--mono)', | |
| fontSize: 8, | |
| letterSpacing: '.22em', | |
| textTransform: 'uppercase', | |
| color: 'var(--muted)', | |
| marginBottom: 2, | |
| }, | |
| shareUrl: { | |
| fontFamily: 'var(--mono)', | |
| fontSize: 9, | |
| color: 'var(--muted)', | |
| wordBreak: 'break-all', | |
| lineHeight: 1.5, | |
| }, | |
| copyBtn: { | |
| fontFamily: 'var(--mono)', | |
| fontSize: 9, | |
| letterSpacing: '.12em', | |
| padding: '7px 0', | |
| borderRadius: 6, | |
| border: '1px solid var(--border2)', | |
| background: 'transparent', | |
| color: 'var(--text)', | |
| cursor: 'pointer', | |
| width: '100%', | |
| textAlign: 'center', | |
| transition: 'border-color .15s, color .15s', | |
| marginTop: 4, | |
| }, | |
| copyBtnDone: { | |
| color: 'var(--accent)', | |
| borderColor: 'var(--accent)', | |
| }, | |
| main: { | |
| flex: 1, | |
| display: 'flex', | |
| flexDirection: 'column', | |
| overflow: 'hidden', | |
| }, | |
| header: { | |
| display: 'flex', | |
| alignItems: 'center', | |
| justifyContent: 'space-between', | |
| padding: '0 28px', | |
| height: 56, | |
| borderBottom: '1px solid var(--border)', | |
| flexShrink: 0, | |
| }, | |
| headerLabel: { | |
| fontFamily: 'var(--mono)', | |
| fontSize: 11, | |
| letterSpacing: '.18em', | |
| textTransform: 'uppercase', | |
| color: 'var(--muted)', | |
| }, | |
| msgCount: { | |
| fontFamily: 'var(--mono)', | |
| fontSize: 10, | |
| color: 'var(--muted)', | |
| background: 'var(--border2)', | |
| padding: '2px 8px', | |
| borderRadius: 10, | |
| }, | |
| 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, | |
| color: 'var(--muted)', | |
| 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 var(--border2)', | |
| color: 'var(--text)', | |
| borderBottomLeftRadius: 3, | |
| }, | |
| } | |