sl-webui 1cd8ebeb32 feat(ui): add social-bot dashboard (issue #107)
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>
2026-03-02 08:36:51 -05:00

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>
);
}