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

166 lines
5.8 KiB
JavaScript

/**
* App.jsx — Saltybot Social Dashboard root component.
*
* Layout:
* [TopBar: connection config + pipeline state badge]
* [TabNav: Status | Faces | Conversation | Personality | Navigation]
* [TabContent]
*/
import { useState, useCallback } from 'react';
import { useRosbridge } from './hooks/useRosbridge.js';
import { StatusPanel } from './components/StatusPanel.jsx';
import { FaceGallery } from './components/FaceGallery.jsx';
import { ConversationLog } from './components/ConversationLog.jsx';
import { PersonalityTuner } from './components/PersonalityTuner.jsx';
import { NavModeSelector } from './components/NavModeSelector.jsx';
const TABS = [
{ id: 'status', label: 'Status', icon: '⬤' },
{ id: 'faces', label: 'Faces', icon: '◉' },
{ id: 'conversation', label: 'Conversation', icon: '◌' },
{ id: 'personality', label: 'Personality', icon: '◈' },
{ id: 'navigation', label: 'Navigation', icon: '◫' },
];
const DEFAULT_WS_URL = 'ws://localhost:9090';
function ConnectionBar({ url, setUrl, connected, error }) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(url);
const handleApply = () => {
setUrl(draft);
setEditing(false);
};
return (
<div className="flex items-center gap-2 text-xs">
{/* Connection dot */}
<div className={`w-2 h-2 rounded-full shrink-0 ${
connected ? 'bg-green-400' : error ? 'bg-red-500' : 'bg-gray-600'
}`} />
{editing ? (
<div className="flex items-center gap-1">
<input
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleApply(); if (e.key === 'Escape') setEditing(false); }}
autoFocus
className="bg-gray-900 border border-cyan-800 rounded px-2 py-0.5 text-cyan-300 w-52 focus:outline-none"
/>
<button onClick={handleApply} className="px-2 py-0.5 rounded bg-cyan-950 border border-cyan-700 text-cyan-400 hover:bg-cyan-900">Connect</button>
<button onClick={() => setEditing(false)} className="text-gray-600 hover:text-gray-400 px-1"></button>
</div>
) : (
<button
onClick={() => { setDraft(url); setEditing(true); }}
className="text-gray-500 hover:text-cyan-400 transition-colors truncate max-w-40"
title={url}
>
{connected ? (
<span className="text-green-400">rosbridge: {url}</span>
) : error ? (
<span className="text-red-400" title={error}> {url}</span>
) : (
<span className="text-gray-500">{url} (connecting)</span>
)}
</button>
)}
</div>
);
}
export default function App() {
const [wsUrl, setWsUrl] = useState(DEFAULT_WS_URL);
const [activeTab, setActiveTab] = useState('status');
const { connected, error, subscribe, publish, callService, setParam } = useRosbridge(wsUrl);
// Memoized publish for NavModeSelector (avoids recreating on every render)
const publishFn = useCallback(
(name, type, data) => publish(name, type, data),
[publish]
);
return (
<div className="min-h-screen flex flex-col bg-[#050510] text-gray-300 font-mono">
{/* ── Top Bar ── */}
<header className="flex items-center justify-between px-4 py-2 bg-[#070712] border-b border-cyan-950 shrink-0 gap-2 flex-wrap">
<div className="flex items-center gap-3">
<span className="text-orange-500 font-bold tracking-widest text-sm"> SALTYBOT</span>
<span className="text-cyan-800 text-xs hidden sm:inline">SOCIAL DASHBOARD</span>
</div>
<ConnectionBar
url={wsUrl}
setUrl={setWsUrl}
connected={connected}
error={error}
/>
</header>
{/* ── Tab Nav ── */}
<nav className="bg-[#070712] border-b border-cyan-950 shrink-0">
<div className="flex overflow-x-auto">
{TABS.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-1.5 px-4 py-2.5 text-xs font-bold tracking-widest whitespace-nowrap border-b-2 transition-colors ${
activeTab === tab.id
? 'border-cyan-500 text-cyan-300 bg-cyan-950 bg-opacity-30'
: 'border-transparent text-gray-500 hover:text-gray-300 hover:border-gray-700'
}`}
>
<span className="hidden sm:inline text-base leading-none">{tab.icon}</span>
{tab.label.toUpperCase()}
</button>
))}
</div>
</nav>
{/* ── Content ── */}
<main className="flex-1 overflow-y-auto p-4">
{activeTab === 'status' && (
<StatusPanel subscribe={subscribe} />
)}
{activeTab === 'faces' && (
<FaceGallery
subscribe={subscribe}
callService={callService}
/>
)}
{activeTab === 'conversation' && (
<ConversationLog subscribe={subscribe} />
)}
{activeTab === 'personality' && (
<PersonalityTuner
subscribe={subscribe}
setParam={setParam}
/>
)}
{activeTab === 'navigation' && (
<NavModeSelector
subscribe={subscribe}
publish={publishFn}
/>
)}
</main>
{/* ── Footer ── */}
<footer className="bg-[#070712] border-t border-cyan-950 px-4 py-1.5 flex items-center justify-between text-xs text-gray-700 shrink-0">
<span>rosbridge: <code className="text-gray-600">{wsUrl}</code></span>
<span className={connected ? 'text-green-700' : 'text-red-900'}>
{connected ? 'CONNECTED' : 'DISCONNECTED'}
</span>
</footer>
</div>
);
}