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>
177 lines
6.0 KiB
JavaScript
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>
|
|
);
|
|
}
|