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

238 lines
8.1 KiB
JavaScript

/**
* FaceGallery.jsx — Enrolled person management.
*
* Services:
* /social/enrollment/list_persons (ListPersons.srv)
* /social/enrollment/enroll_person (EnrollPerson.srv)
* /social/enrollment/delete_person (DeletePerson.srv)
*
* Topics (live detections):
* /social/face_detections (FaceDetectionArray) — optional, shows live bboxes
*/
import { useEffect, useState, useCallback } from 'react';
function PersonCard({ person, onDelete, liveDetection }) {
const [deleting, setDeleting] = useState(false);
const enrolledAt = person.enrolled_at
? new Date(person.enrolled_at.sec * 1000).toLocaleDateString()
: '—';
const handleDelete = async () => {
if (!confirm(`Delete ${person.person_name}?`)) return;
setDeleting(true);
try {
await onDelete(person.person_id);
} finally {
setDeleting(false);
}
};
const isActive = liveDetection?.face_id === person.person_id;
return (
<div className={`bg-gray-900 rounded-lg border p-3 flex flex-col gap-2 transition-colors ${
isActive ? 'border-green-600 bg-green-950' : 'border-gray-800'
}`}>
{/* Avatar placeholder */}
<div className="w-full aspect-square rounded bg-gray-800 border border-gray-700 flex items-center justify-center relative overflow-hidden">
<svg className="w-12 h-12 text-gray-600" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v2.4h19.2v-2.4c0-3.2-6.4-4.8-9.6-4.8z"/>
</svg>
{isActive && (
<div className="absolute inset-0 border-2 border-green-500 rounded pointer-events-none" />
)}
<div className="absolute top-1 right-1">
<div className={`w-2 h-2 rounded-full ${isActive ? 'bg-green-400' : 'bg-gray-700'}`} />
</div>
</div>
{/* Info */}
<div>
<div className="text-cyan-300 text-sm font-bold truncate">{person.person_name}</div>
<div className="text-gray-600 text-xs">ID: {person.person_id}</div>
<div className="text-gray-600 text-xs">Samples: {person.sample_count}</div>
<div className="text-gray-600 text-xs">Enrolled: {enrolledAt}</div>
</div>
{/* Delete */}
<button
onClick={handleDelete}
disabled={deleting}
className="w-full py-1 text-xs rounded border border-red-900 text-red-400 hover:bg-red-950 hover:border-red-700 disabled:opacity-40 transition-colors"
>
{deleting ? 'Deleting…' : 'Delete'}
</button>
</div>
);
}
export function FaceGallery({ subscribe, callService }) {
const [persons, setPersons] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [enrolling, setEnrolling] = useState(false);
const [newName, setNewName] = useState('');
const [newSamples, setNewSamples] = useState(5);
const [liveDetections, setLiveDetections] = useState([]);
const loadPersons = useCallback(async () => {
setLoading(true);
setError(null);
try {
const result = await callService(
'/social/enrollment/list_persons',
'saltybot_social_msgs/srv/ListPersons',
{}
);
setPersons(result?.persons ?? []);
} catch (e) {
setError('Service unavailable: ' + (e?.message ?? e));
} finally {
setLoading(false);
}
}, [callService]);
useEffect(() => {
loadPersons();
}, [loadPersons]);
// Live face detections
useEffect(() => {
const unsub = subscribe(
'/social/face_detections',
'saltybot_social_msgs/FaceDetectionArray',
(msg) => setLiveDetections(msg.faces ?? [])
);
return unsub;
}, [subscribe]);
const handleEnroll = async () => {
if (!newName.trim()) return;
setEnrolling(true);
setError(null);
try {
const result = await callService(
'/social/enrollment/enroll_person',
'saltybot_social_msgs/srv/EnrollPerson',
{ name: newName.trim(), mode: 'capture', n_samples: newSamples }
);
if (result?.success) {
setNewName('');
await loadPersons();
} else {
setError(result?.message ?? 'Enrollment failed');
}
} catch (e) {
setError('Enroll error: ' + (e?.message ?? e));
} finally {
setEnrolling(false);
}
};
const handleDelete = async (personId) => {
try {
await callService(
'/social/enrollment/delete_person',
'saltybot_social_msgs/srv/DeletePerson',
{ person_id: personId }
);
await loadPersons();
} catch (e) {
setError('Delete error: ' + (e?.message ?? e));
}
};
return (
<div className="space-y-4">
{/* Enroll new person */}
<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">ENROLL NEW PERSON</div>
<div className="flex flex-col sm:flex-row gap-2">
<input
type="text"
placeholder="Person name…"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleEnroll()}
className="flex-1 bg-gray-900 border border-gray-700 rounded px-3 py-1.5 text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-cyan-700"
/>
<div className="flex items-center gap-2">
<span className="text-gray-500 text-xs shrink-0">Samples:</span>
<input
type="number"
min={1}
max={20}
value={newSamples}
onChange={(e) => setNewSamples(Number(e.target.value))}
className="w-16 bg-gray-900 border border-gray-700 rounded px-2 py-1.5 text-sm text-gray-200 focus:outline-none focus:border-cyan-700"
/>
</div>
<button
onClick={handleEnroll}
disabled={enrolling || !newName.trim()}
className="px-4 py-1.5 rounded bg-cyan-950 hover:bg-cyan-900 border border-cyan-700 text-cyan-300 text-sm disabled:opacity-40 transition-colors"
>
{enrolling ? 'Capturing…' : 'Enroll'}
</button>
</div>
{enrolling && (
<div className="mt-2 text-amber-400 text-xs animate-pulse">
Capturing face samples look at the camera
</div>
)}
</div>
{/* Error */}
{error && (
<div className="bg-red-950 border border-red-800 rounded px-3 py-2 text-red-300 text-xs">
{error}
</div>
)}
{/* Gallery header */}
<div className="flex items-center justify-between">
<div className="text-cyan-700 text-xs font-bold tracking-widest">
ENROLLED PERSONS ({persons.length})
</div>
<div className="flex items-center gap-2">
{liveDetections.length > 0 && (
<div className="flex items-center gap-1 text-green-400 text-xs">
<div className="w-1.5 h-1.5 rounded-full bg-green-400 animate-pulse" />
{liveDetections.length} detected
</div>
)}
<button
onClick={loadPersons}
disabled={loading}
className="px-2 py-1 rounded border border-gray-700 text-gray-400 hover:text-gray-200 text-xs disabled:opacity-40 transition-colors"
>
{loading ? '…' : 'Refresh'}
</button>
</div>
</div>
{/* Grid */}
{loading && persons.length === 0 ? (
<div className="text-gray-600 text-sm text-center py-8">Loading</div>
) : persons.length === 0 ? (
<div className="text-gray-600 text-sm text-center py-8 border border-dashed border-gray-800 rounded-lg">
No enrolled persons
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
{persons.map((p) => (
<PersonCard
key={p.person_id}
person={p}
onDelete={handleDelete}
liveDetection={liveDetections.find(d => d.face_id === p.person_id)}
/>
))}
</div>
)}
</div>
);
}