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

177 lines
6.0 KiB
JavaScript

/**
* NavModeSelector.jsx — Follow mode switcher.
*
* Publishes: /social/nav/mode (std_msgs/String)
* Subscribes: /social/nav/mode (std_msgs/String) — echoed back by social_nav_node
* /social/nav/status (std_msgs/String) — freeform status string
*/
import { useEffect, useState } from 'react';
const MODES = [
{
id: 'shadow',
label: 'SHADOW',
icon: '👤',
description: 'Follow directly behind at distance',
color: 'border-blue-700 text-blue-300 bg-blue-950',
activeColor: 'border-blue-400 text-blue-100 bg-blue-900 mode-active',
},
{
id: 'lead',
label: 'LEAD',
icon: '➡',
description: 'Robot moves ahead, person follows',
color: 'border-green-700 text-green-300 bg-green-950',
activeColor: 'border-green-400 text-green-100 bg-green-900 mode-active',
},
{
id: 'side',
label: 'SIDE',
icon: '↔',
description: 'Walk side-by-side',
color: 'border-purple-700 text-purple-300 bg-purple-950',
activeColor: 'border-purple-400 text-purple-100 bg-purple-900 mode-active',
},
{
id: 'orbit',
label: 'ORBIT',
icon: '⟳',
description: 'Circle around the tracked person',
color: 'border-amber-700 text-amber-300 bg-amber-950',
activeColor: 'border-amber-400 text-amber-100 bg-amber-900 mode-active',
},
{
id: 'loose',
label: 'LOOSE',
icon: '⬡',
description: 'Follow with generous spacing',
color: 'border-teal-700 text-teal-300 bg-teal-950',
activeColor: 'border-teal-400 text-teal-100 bg-teal-900 mode-active',
},
{
id: 'tight',
label: 'TIGHT',
icon: '⬟',
description: 'Follow closely, minimal gap',
color: 'border-red-700 text-red-300 bg-red-950',
activeColor: 'border-red-400 text-red-100 bg-red-900 mode-active',
},
];
const VOICE_COMMANDS = [
{ mode: 'shadow', cmd: '"shadow" / "follow me"' },
{ mode: 'lead', cmd: '"lead me" / "go ahead"' },
{ mode: 'side', cmd: '"stay beside"' },
{ mode: 'orbit', cmd: '"orbit"' },
{ mode: 'loose', cmd: '"give me space"' },
{ mode: 'tight', cmd: '"stay close"' },
];
export function NavModeSelector({ subscribe, publish }) {
const [activeMode, setActiveMode] = useState(null);
const [navStatus, setNavStatus] = useState('');
const [sending, setSending] = useState(null);
// Subscribe to echoed mode topic
useEffect(() => {
const unsub = subscribe(
'/social/nav/mode',
'std_msgs/String',
(msg) => setActiveMode(msg.data)
);
return unsub;
}, [subscribe]);
// Subscribe to nav status
useEffect(() => {
const unsub = subscribe(
'/social/nav/status',
'std_msgs/String',
(msg) => setNavStatus(msg.data)
);
return unsub;
}, [subscribe]);
const handleMode = async (modeId) => {
setSending(modeId);
publish('/social/nav/mode', 'std_msgs/String', { data: modeId });
// Optimistic update; will be confirmed when echoed back
setActiveMode(modeId);
setTimeout(() => setSending(null), 800);
};
return (
<div className="space-y-4">
{/* Status */}
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-4">
<div className="text-cyan-700 text-xs font-bold tracking-widest mb-2">NAV STATUS</div>
<div className="flex items-center gap-3">
{activeMode ? (
<>
<div className="w-2.5 h-2.5 rounded-full bg-green-400 animate-pulse" />
<span className="text-gray-300 text-sm">
Mode: <span className="text-cyan-300 font-bold uppercase">{activeMode}</span>
</span>
</>
) : (
<>
<div className="w-2.5 h-2.5 rounded-full bg-gray-600" />
<span className="text-gray-600 text-sm">No mode received</span>
</>
)}
{navStatus && (
<span className="ml-auto text-gray-500 text-xs">{navStatus}</span>
)}
</div>
</div>
{/* Mode buttons */}
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-4">
<div className="text-cyan-700 text-xs font-bold tracking-widest mb-3">FOLLOW MODE</div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{MODES.map((mode) => {
const isActive = activeMode === mode.id;
const isSending = sending === mode.id;
return (
<button
key={mode.id}
onClick={() => handleMode(mode.id)}
disabled={isSending}
title={mode.description}
className={`flex flex-col items-center gap-1 py-3 px-2 rounded-lg border font-bold text-sm transition-all duration-200 ${
isActive ? mode.activeColor : mode.color
} hover:opacity-90 active:scale-95 disabled:cursor-wait`}
>
<span className="text-xl">{mode.icon}</span>
<span className="tracking-widest text-xs">{mode.label}</span>
{isSending && <span className="text-xs opacity-60">sending</span>}
</button>
);
})}
</div>
<p className="text-gray-600 text-xs mt-3">
Tap to publish to <code>/social/nav/mode</code>
</p>
</div>
{/* Voice commands reference */}
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-4">
<div className="text-cyan-700 text-xs font-bold tracking-widest mb-3">VOICE COMMANDS</div>
<div className="space-y-1.5">
{VOICE_COMMANDS.map(({ mode, cmd }) => (
<div key={mode} className="flex items-center gap-2 text-xs">
<span className="text-gray-500 uppercase w-14 shrink-0">{mode}</span>
<span className="text-gray-400 italic">{cmd}</span>
</div>
))}
</div>
<p className="text-gray-600 text-xs mt-3">
Voice commands are parsed by <code>social_nav_node</code> from{' '}
<code>/social/speech/command</code>.
</p>
</div>
</div>
);
}