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

155 lines
5.9 KiB
JavaScript

/**
* StatusPanel.jsx — Live pipeline status display.
*
* Subscribes to /social/orchestrator/state (std_msgs/String, JSON payload):
* {
* state: "idle"|"listening"|"thinking"|"speaking"|"throttled",
* gpu_free_mb: number,
* gpu_total_mb: number,
* latency: {
* wakeword_to_transcript: { mean_ms, p95_ms, n },
* transcript_to_llm: { mean_ms, p95_ms, n },
* llm_to_tts: { mean_ms, p95_ms, n },
* end_to_end: { mean_ms, p95_ms, n }
* },
* persona_name: string,
* active_person: string
* }
*/
import { useEffect, useState } from 'react';
const STATE_CONFIG = {
idle: { label: 'IDLE', color: 'text-gray-400', bg: 'bg-gray-800', border: 'border-gray-600', pulse: '' },
listening: { label: 'LISTENING', color: 'text-blue-300', bg: 'bg-blue-950', border: 'border-blue-600', pulse: 'pulse-blue' },
thinking: { label: 'THINKING', color: 'text-amber-300', bg: 'bg-amber-950', border: 'border-amber-600', pulse: 'pulse-amber' },
speaking: { label: 'SPEAKING', color: 'text-green-300', bg: 'bg-green-950', border: 'border-green-600', pulse: 'pulse-green' },
throttled: { label: 'THROTTLED', color: 'text-red-300', bg: 'bg-red-950', border: 'border-red-600', pulse: 'pulse-amber' },
};
function LatencyRow({ label, stat }) {
if (!stat || stat.n === 0) return null;
const warn = stat.mean_ms > 500;
const crit = stat.mean_ms > 1500;
const cls = crit ? 'text-red-400' : warn ? 'text-amber-400' : 'text-cyan-400';
return (
<div className="flex justify-between items-center py-0.5">
<span className="text-gray-500 text-xs">{label}</span>
<div className="text-right">
<span className={`text-xs font-bold ${cls}`}>{Math.round(stat.mean_ms)}ms</span>
<span className="text-gray-600 text-xs ml-1">p95:{Math.round(stat.p95_ms)}ms</span>
</div>
</div>
);
}
export function StatusPanel({ subscribe }) {
const [status, setStatus] = useState(null);
const [lastUpdate, setLastUpdate] = useState(null);
useEffect(() => {
const unsub = subscribe(
'/social/orchestrator/state',
'std_msgs/String',
(msg) => {
try {
const data = JSON.parse(msg.data);
setStatus(data);
setLastUpdate(Date.now());
} catch {
// ignore parse errors
}
}
);
return unsub;
}, [subscribe]);
const state = status?.state ?? 'idle';
const cfg = STATE_CONFIG[state] ?? STATE_CONFIG.idle;
const gpuFree = status?.gpu_free_mb ?? 0;
const gpuTotal = status?.gpu_total_mb ?? 1;
const gpuUsed = gpuTotal - gpuFree;
const gpuPct = Math.round((gpuUsed / gpuTotal) * 100);
const gpuWarn = gpuPct > 80;
const lat = status?.latency ?? {};
const stale = lastUpdate && Date.now() - lastUpdate > 5000;
return (
<div className="space-y-4">
{/* Pipeline State */}
<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">PIPELINE STATE</div>
<div className="flex items-center gap-4">
<div
className={`w-5 h-5 rounded-full shrink-0 ${cfg.bg} border-2 ${cfg.border} ${cfg.pulse}`}
/>
<div>
<div className={`text-2xl font-bold tracking-widest ${cfg.color}`}>{cfg.label}</div>
{status?.persona_name && (
<div className="text-gray-500 text-xs mt-0.5">
Persona: <span className="text-cyan-500">{status.persona_name}</span>
</div>
)}
{status?.active_person && (
<div className="text-gray-500 text-xs">
Talking to: <span className="text-amber-400">{status.active_person}</span>
</div>
)}
</div>
{stale && (
<div className="ml-auto text-red-500 text-xs">STALE</div>
)}
{!status && (
<div className="ml-auto text-gray-600 text-xs">No signal</div>
)}
</div>
</div>
{/* GPU Memory */}
<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">GPU MEMORY</div>
{gpuTotal > 1 ? (
<>
<div className="flex justify-between text-xs mb-1.5">
<span className={gpuWarn ? 'text-red-400' : 'text-gray-400'}>
{Math.round(gpuUsed)} MB used
</span>
<span className="text-gray-500">{Math.round(gpuTotal)} MB total</span>
</div>
<div className="w-full h-3 bg-gray-900 rounded overflow-hidden border border-gray-800">
<div
className="h-full transition-all duration-500 rounded"
style={{
width: `${gpuPct}%`,
background: gpuPct > 90 ? '#ef4444' : gpuPct > 75 ? '#f59e0b' : '#06b6d4',
}}
/>
</div>
<div className={`text-xs mt-1 text-right ${gpuWarn ? 'text-amber-400' : 'text-gray-600'}`}>
{gpuPct}%
</div>
</>
) : (
<div className="text-gray-600 text-xs">No GPU data</div>
)}
</div>
{/* Latency */}
<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">LATENCY</div>
{Object.keys(lat).length > 0 ? (
<div className="divide-y divide-gray-900">
<LatencyRow label="Wake → Transcript" stat={lat.wakeword_to_transcript} />
<LatencyRow label="Transcript → LLM" stat={lat.transcript_to_llm} />
<LatencyRow label="LLM → TTS" stat={lat.llm_to_tts} />
<LatencyRow label="End-to-End" stat={lat.end_to_end} />
</div>
) : (
<div className="text-gray-600 text-xs">No latency data yet</div>
)}
</div>
</div>
);
}