React + Vite + TailwindCSS dashboard served on port 8080. Connects to ROS2 via rosbridge_server WebSocket (default ws://localhost:9090). Panels: - StatusPanel: pipeline state (idle/listening/thinking/speaking/throttled) with animated pulse indicator, GPU memory bar, per-stage latency stats - FaceGallery: enrolled persons grid with enroll/delete via /social/enrollment/* services; live detection indicator - ConversationLog: real-time transcript with human/bot bubbles, streaming partial support, auto-scroll - PersonalityTuner: sass/humor/verbosity sliders (0–10) writing to personality_node via rcl_interfaces/srv/SetParameters; live PersonalityState display - NavModeSelector: shadow/lead/side/orbit/loose/tight mode buttons publishing to /social/nav/mode; voice command reference table Usage: cd ui/social-bot && npm install && npm run dev # dev server port 8080 npm run build && npm run preview # production preview Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
222 lines
7.3 KiB
JavaScript
222 lines
7.3 KiB
JavaScript
/**
|
|
* 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 (
|
|
<div className="flex flex-col items-end gap-0.5">
|
|
<div className="flex items-center gap-2 text-xs text-gray-500">
|
|
<span className="text-xs">{formatTime(entry.ts)}</span>
|
|
<span className="text-blue-400 font-bold">{entry.speaker || 'unknown'}</span>
|
|
{entry.confidence != null && (
|
|
<span className="text-gray-600">({Math.round(entry.confidence * 100)}%)</span>
|
|
)}
|
|
{entry.partial && <span className="text-amber-600 text-xs italic">partial</span>}
|
|
</div>
|
|
<div className={`max-w-xs sm:max-w-md bubble-human rounded-lg px-3 py-2 text-sm text-blue-100 ${entry.partial ? 'bubble-partial' : ''}`}>
|
|
{entry.text}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function BotBubble({ entry }) {
|
|
return (
|
|
<div className="flex flex-col items-start gap-0.5">
|
|
<div className="flex items-center gap-2 text-xs text-gray-500">
|
|
<span className="text-teal-400 font-bold">Salty</span>
|
|
{entry.speaker && <span className="text-gray-600">→ {entry.speaker}</span>}
|
|
<span className="text-xs">{formatTime(entry.ts)}</span>
|
|
{entry.partial && <span className="text-amber-600 text-xs italic">streaming…</span>}
|
|
</div>
|
|
<div className={`max-w-xs sm:max-w-md bubble-bot rounded-lg px-3 py-2 text-sm text-teal-100 ${entry.partial ? 'bubble-partial' : ''}`}>
|
|
{entry.text}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className="flex flex-col h-full" style={{ minHeight: '400px', maxHeight: '70vh' }}>
|
|
{/* Toolbar */}
|
|
<div className="flex items-center justify-between mb-2 shrink-0">
|
|
<div className="text-cyan-700 text-xs font-bold tracking-widest">
|
|
CONVERSATION LOG ({entries.length})
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<label className="flex items-center gap-1 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={autoScroll}
|
|
onChange={(e) => setAutoScroll(e.target.checked)}
|
|
className="accent-cyan-500 w-3 h-3"
|
|
/>
|
|
<span className="text-gray-500 text-xs">Auto-scroll</span>
|
|
</label>
|
|
<button
|
|
onClick={handleClear}
|
|
className="px-2 py-0.5 rounded border border-gray-700 text-gray-500 hover:text-gray-300 text-xs"
|
|
>
|
|
Clear
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Scroll container */}
|
|
<div
|
|
ref={scrollRef}
|
|
onScroll={handleScroll}
|
|
className="flex-1 overflow-y-auto space-y-3 pr-1"
|
|
style={{ minHeight: '300px' }}
|
|
>
|
|
{entries.length === 0 ? (
|
|
<div className="text-gray-600 text-sm text-center py-12 border border-dashed border-gray-800 rounded-lg">
|
|
Waiting for conversation…
|
|
</div>
|
|
) : (
|
|
entries.map((entry) =>
|
|
entry.type === 'human' ? (
|
|
<HumanBubble key={entry.id} entry={entry} />
|
|
) : (
|
|
<BotBubble key={entry.id} entry={entry} />
|
|
)
|
|
)
|
|
)}
|
|
<div ref={bottomRef} />
|
|
</div>
|
|
|
|
{/* Legend */}
|
|
<div className="flex gap-4 mt-2 pt-2 border-t border-gray-900 shrink-0">
|
|
<div className="flex items-center gap-1.5">
|
|
<div className="w-3 h-3 rounded-sm bg-blue-950 border border-blue-700" />
|
|
<span className="text-gray-600 text-xs">Human</span>
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
<div className="w-3 h-3 rounded-sm bg-teal-950 border border-teal-700" />
|
|
<span className="text-gray-600 text-xs">Salty</span>
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
<div className="w-2 h-2 rounded-full bg-amber-600 opacity-60" />
|
|
<span className="text-gray-600 text-xs">Streaming</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|