/** * ConversationLog.jsx — Real-time transcript with speaker labels. * * Subscribes: * /social/speech/transcript (SpeechTranscript) — human utterances * /social/conversation/response (ConversationResponse) — bot replies */ import { useEffect, useRef, useState } from 'react'; const MAX_ENTRIES = 200; function formatTime(ts) { return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); } function HumanBubble({ entry }) { return (
{formatTime(entry.ts)} {entry.speaker || 'unknown'} {entry.confidence != null && ( ({Math.round(entry.confidence * 100)}%) )} {entry.partial && partial}
{entry.text}
); } function BotBubble({ entry }) { return (
Salty {entry.speaker && → {entry.speaker}} {formatTime(entry.ts)} {entry.partial && streaming…}
{entry.text}
); } export function ConversationLog({ subscribe }) { const [entries, setEntries] = useState([]); const [autoScroll, setAutoScroll] = useState(true); const bottomRef = useRef(null); const scrollRef = useRef(null); // Auto-scroll to bottom when new entries arrive useEffect(() => { if (autoScroll && bottomRef.current) { bottomRef.current.scrollIntoView({ behavior: 'smooth' }); } }, [entries, autoScroll]); // Human transcript useEffect(() => { const unsub = subscribe( '/social/speech/transcript', 'saltybot_social_msgs/SpeechTranscript', (msg) => { setEntries((prev) => { const entry = { id: `h-${msg.header?.stamp?.sec ?? Date.now()}-${msg.turn_id ?? Math.random()}`, type: 'human', text: msg.text, speaker: msg.speaker_id, confidence: msg.confidence, partial: msg.is_partial, ts: Date.now(), }; // Replace partial entry with same turn if exists if (msg.is_partial) { const idx = prev.findLastIndex( (e) => e.type === 'human' && e.partial && e.speaker === msg.speaker_id ); if (idx !== -1) { const updated = [...prev]; updated[idx] = entry; return updated; } } else { // Replace any trailing partial for same speaker const idx = prev.findLastIndex( (e) => e.type === 'human' && e.partial && e.speaker === msg.speaker_id ); if (idx !== -1) { const updated = [...prev]; updated[idx] = { ...entry, id: prev[idx].id }; return updated.slice(-MAX_ENTRIES); } } return [...prev, entry].slice(-MAX_ENTRIES); }); } ); return unsub; }, [subscribe]); // Bot response useEffect(() => { const unsub = subscribe( '/social/conversation/response', 'saltybot_social_msgs/ConversationResponse', (msg) => { setEntries((prev) => { const entry = { id: `b-${msg.turn_id ?? Math.random()}`, type: 'bot', text: msg.text, speaker: msg.speaker_id, partial: msg.is_partial, turnId: msg.turn_id, ts: Date.now(), }; // Replace streaming partial with same turn_id if (msg.turn_id != null) { const idx = prev.findLastIndex( (e) => e.type === 'bot' && e.turnId === msg.turn_id ); if (idx !== -1) { const updated = [...prev]; updated[idx] = entry; return updated; } } return [...prev, entry].slice(-MAX_ENTRIES); }); } ); return unsub; }, [subscribe]); const handleScroll = () => { const el = scrollRef.current; if (!el) return; const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 60; setAutoScroll(atBottom); }; const handleClear = () => setEntries([]); return (
{/* Toolbar */}
CONVERSATION LOG ({entries.length})
{/* Scroll container */}
{entries.length === 0 ? (
Waiting for conversation…
) : ( entries.map((entry) => entry.type === 'human' ? ( ) : ( ) ) )}
{/* Legend */}
Human
Salty
Streaming
); }