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>
155 lines
5.9 KiB
JavaScript
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>
|
|
);
|
|
}
|