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>
238 lines
8.1 KiB
JavaScript
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>
|
|
);
|
|
}
|