Merge pull request 'feat(ui): social-bot web dashboard (issue #107)' (#112) from sl-webui/issue-107-dashboard into main

This commit is contained in:
sl-jetson 2026-03-02 08:41:18 -05:00
commit b23e8432a2
16 changed files with 4370 additions and 0 deletions

2
ui/social-bot/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
node_modules/
dist/

12
ui/social-bot/index.html Normal file
View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Saltybot Social Dashboard</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

2920
ui/social-bot/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,24 @@
{
"name": "saltybot-social-dashboard",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "Social-bot monitoring and configuration dashboard",
"scripts": {
"dev": "vite --port 8080 --host",
"build": "vite build",
"preview": "vite preview --port 8080 --host"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"roslib": "^1.4.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.14",
"vite": "^5.4.10"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

165
ui/social-bot/src/App.jsx Normal file
View File

@ -0,0 +1,165 @@
/**
* 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>
);
}

View File

@ -0,0 +1,221 @@
/**
* ConversationLog.jsx Real-time transcript with speaker labels.
*
* Subscribes:
* /social/speech/transcript (SpeechTranscript) human utterances
* /social/conversation/response (ConversationResponse) bot replies
*/
import { useEffect, useRef, useState } from 'react';
const MAX_ENTRIES = 200;
function formatTime(ts) {
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
function HumanBubble({ entry }) {
return (
<div className="flex flex-col items-end gap-0.5">
<div className="flex items-center gap-2 text-xs text-gray-500">
<span className="text-xs">{formatTime(entry.ts)}</span>
<span className="text-blue-400 font-bold">{entry.speaker || 'unknown'}</span>
{entry.confidence != null && (
<span className="text-gray-600">({Math.round(entry.confidence * 100)}%)</span>
)}
{entry.partial && <span className="text-amber-600 text-xs italic">partial</span>}
</div>
<div className={`max-w-xs sm:max-w-md bubble-human rounded-lg px-3 py-2 text-sm text-blue-100 ${entry.partial ? 'bubble-partial' : ''}`}>
{entry.text}
</div>
</div>
);
}
function BotBubble({ entry }) {
return (
<div className="flex flex-col items-start gap-0.5">
<div className="flex items-center gap-2 text-xs text-gray-500">
<span className="text-teal-400 font-bold">Salty</span>
{entry.speaker && <span className="text-gray-600"> {entry.speaker}</span>}
<span className="text-xs">{formatTime(entry.ts)}</span>
{entry.partial && <span className="text-amber-600 text-xs italic">streaming</span>}
</div>
<div className={`max-w-xs sm:max-w-md bubble-bot rounded-lg px-3 py-2 text-sm text-teal-100 ${entry.partial ? 'bubble-partial' : ''}`}>
{entry.text}
</div>
</div>
);
}
export function ConversationLog({ subscribe }) {
const [entries, setEntries] = useState([]);
const [autoScroll, setAutoScroll] = useState(true);
const bottomRef = useRef(null);
const scrollRef = useRef(null);
// Auto-scroll to bottom when new entries arrive
useEffect(() => {
if (autoScroll && bottomRef.current) {
bottomRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [entries, autoScroll]);
// Human transcript
useEffect(() => {
const unsub = subscribe(
'/social/speech/transcript',
'saltybot_social_msgs/SpeechTranscript',
(msg) => {
setEntries((prev) => {
const entry = {
id: `h-${msg.header?.stamp?.sec ?? Date.now()}-${msg.turn_id ?? Math.random()}`,
type: 'human',
text: msg.text,
speaker: msg.speaker_id,
confidence: msg.confidence,
partial: msg.is_partial,
ts: Date.now(),
};
// Replace partial entry with same turn if exists
if (msg.is_partial) {
const idx = prev.findLastIndex(
(e) => e.type === 'human' && e.partial && e.speaker === msg.speaker_id
);
if (idx !== -1) {
const updated = [...prev];
updated[idx] = entry;
return updated;
}
} else {
// Replace any trailing partial for same speaker
const idx = prev.findLastIndex(
(e) => e.type === 'human' && e.partial && e.speaker === msg.speaker_id
);
if (idx !== -1) {
const updated = [...prev];
updated[idx] = { ...entry, id: prev[idx].id };
return updated.slice(-MAX_ENTRIES);
}
}
return [...prev, entry].slice(-MAX_ENTRIES);
});
}
);
return unsub;
}, [subscribe]);
// Bot response
useEffect(() => {
const unsub = subscribe(
'/social/conversation/response',
'saltybot_social_msgs/ConversationResponse',
(msg) => {
setEntries((prev) => {
const entry = {
id: `b-${msg.turn_id ?? Math.random()}`,
type: 'bot',
text: msg.text,
speaker: msg.speaker_id,
partial: msg.is_partial,
turnId: msg.turn_id,
ts: Date.now(),
};
// Replace streaming partial with same turn_id
if (msg.turn_id != null) {
const idx = prev.findLastIndex(
(e) => e.type === 'bot' && e.turnId === msg.turn_id
);
if (idx !== -1) {
const updated = [...prev];
updated[idx] = entry;
return updated;
}
}
return [...prev, entry].slice(-MAX_ENTRIES);
});
}
);
return unsub;
}, [subscribe]);
const handleScroll = () => {
const el = scrollRef.current;
if (!el) return;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 60;
setAutoScroll(atBottom);
};
const handleClear = () => setEntries([]);
return (
<div className="flex flex-col h-full" style={{ minHeight: '400px', maxHeight: '70vh' }}>
{/* Toolbar */}
<div className="flex items-center justify-between mb-2 shrink-0">
<div className="text-cyan-700 text-xs font-bold tracking-widest">
CONVERSATION LOG ({entries.length})
</div>
<div className="flex items-center gap-2">
<label className="flex items-center gap-1 cursor-pointer">
<input
type="checkbox"
checked={autoScroll}
onChange={(e) => setAutoScroll(e.target.checked)}
className="accent-cyan-500 w-3 h-3"
/>
<span className="text-gray-500 text-xs">Auto-scroll</span>
</label>
<button
onClick={handleClear}
className="px-2 py-0.5 rounded border border-gray-700 text-gray-500 hover:text-gray-300 text-xs"
>
Clear
</button>
</div>
</div>
{/* Scroll container */}
<div
ref={scrollRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto space-y-3 pr-1"
style={{ minHeight: '300px' }}
>
{entries.length === 0 ? (
<div className="text-gray-600 text-sm text-center py-12 border border-dashed border-gray-800 rounded-lg">
Waiting for conversation
</div>
) : (
entries.map((entry) =>
entry.type === 'human' ? (
<HumanBubble key={entry.id} entry={entry} />
) : (
<BotBubble key={entry.id} entry={entry} />
)
)
)}
<div ref={bottomRef} />
</div>
{/* Legend */}
<div className="flex gap-4 mt-2 pt-2 border-t border-gray-900 shrink-0">
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded-sm bg-blue-950 border border-blue-700" />
<span className="text-gray-600 text-xs">Human</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded-sm bg-teal-950 border border-teal-700" />
<span className="text-gray-600 text-xs">Salty</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-2 h-2 rounded-full bg-amber-600 opacity-60" />
<span className="text-gray-600 text-xs">Streaming</span>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,237 @@
/**
* 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>
);
}

View File

@ -0,0 +1,176 @@
/**
* NavModeSelector.jsx Follow mode switcher.
*
* Publishes: /social/nav/mode (std_msgs/String)
* Subscribes: /social/nav/mode (std_msgs/String) echoed back by social_nav_node
* /social/nav/status (std_msgs/String) freeform status string
*/
import { useEffect, useState } from 'react';
const MODES = [
{
id: 'shadow',
label: 'SHADOW',
icon: '👤',
description: 'Follow directly behind at distance',
color: 'border-blue-700 text-blue-300 bg-blue-950',
activeColor: 'border-blue-400 text-blue-100 bg-blue-900 mode-active',
},
{
id: 'lead',
label: 'LEAD',
icon: '➡',
description: 'Robot moves ahead, person follows',
color: 'border-green-700 text-green-300 bg-green-950',
activeColor: 'border-green-400 text-green-100 bg-green-900 mode-active',
},
{
id: 'side',
label: 'SIDE',
icon: '↔',
description: 'Walk side-by-side',
color: 'border-purple-700 text-purple-300 bg-purple-950',
activeColor: 'border-purple-400 text-purple-100 bg-purple-900 mode-active',
},
{
id: 'orbit',
label: 'ORBIT',
icon: '⟳',
description: 'Circle around the tracked person',
color: 'border-amber-700 text-amber-300 bg-amber-950',
activeColor: 'border-amber-400 text-amber-100 bg-amber-900 mode-active',
},
{
id: 'loose',
label: 'LOOSE',
icon: '⬡',
description: 'Follow with generous spacing',
color: 'border-teal-700 text-teal-300 bg-teal-950',
activeColor: 'border-teal-400 text-teal-100 bg-teal-900 mode-active',
},
{
id: 'tight',
label: 'TIGHT',
icon: '⬟',
description: 'Follow closely, minimal gap',
color: 'border-red-700 text-red-300 bg-red-950',
activeColor: 'border-red-400 text-red-100 bg-red-900 mode-active',
},
];
const VOICE_COMMANDS = [
{ mode: 'shadow', cmd: '"shadow" / "follow me"' },
{ mode: 'lead', cmd: '"lead me" / "go ahead"' },
{ mode: 'side', cmd: '"stay beside"' },
{ mode: 'orbit', cmd: '"orbit"' },
{ mode: 'loose', cmd: '"give me space"' },
{ mode: 'tight', cmd: '"stay close"' },
];
export function NavModeSelector({ subscribe, publish }) {
const [activeMode, setActiveMode] = useState(null);
const [navStatus, setNavStatus] = useState('');
const [sending, setSending] = useState(null);
// Subscribe to echoed mode topic
useEffect(() => {
const unsub = subscribe(
'/social/nav/mode',
'std_msgs/String',
(msg) => setActiveMode(msg.data)
);
return unsub;
}, [subscribe]);
// Subscribe to nav status
useEffect(() => {
const unsub = subscribe(
'/social/nav/status',
'std_msgs/String',
(msg) => setNavStatus(msg.data)
);
return unsub;
}, [subscribe]);
const handleMode = async (modeId) => {
setSending(modeId);
publish('/social/nav/mode', 'std_msgs/String', { data: modeId });
// Optimistic update; will be confirmed when echoed back
setActiveMode(modeId);
setTimeout(() => setSending(null), 800);
};
return (
<div className="space-y-4">
{/* Status */}
<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-2">NAV STATUS</div>
<div className="flex items-center gap-3">
{activeMode ? (
<>
<div className="w-2.5 h-2.5 rounded-full bg-green-400 animate-pulse" />
<span className="text-gray-300 text-sm">
Mode: <span className="text-cyan-300 font-bold uppercase">{activeMode}</span>
</span>
</>
) : (
<>
<div className="w-2.5 h-2.5 rounded-full bg-gray-600" />
<span className="text-gray-600 text-sm">No mode received</span>
</>
)}
{navStatus && (
<span className="ml-auto text-gray-500 text-xs">{navStatus}</span>
)}
</div>
</div>
{/* Mode buttons */}
<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">FOLLOW MODE</div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{MODES.map((mode) => {
const isActive = activeMode === mode.id;
const isSending = sending === mode.id;
return (
<button
key={mode.id}
onClick={() => handleMode(mode.id)}
disabled={isSending}
title={mode.description}
className={`flex flex-col items-center gap-1 py-3 px-2 rounded-lg border font-bold text-sm transition-all duration-200 ${
isActive ? mode.activeColor : mode.color
} hover:opacity-90 active:scale-95 disabled:cursor-wait`}
>
<span className="text-xl">{mode.icon}</span>
<span className="tracking-widest text-xs">{mode.label}</span>
{isSending && <span className="text-xs opacity-60">sending</span>}
</button>
);
})}
</div>
<p className="text-gray-600 text-xs mt-3">
Tap to publish to <code>/social/nav/mode</code>
</p>
</div>
{/* Voice commands reference */}
<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">VOICE COMMANDS</div>
<div className="space-y-1.5">
{VOICE_COMMANDS.map(({ mode, cmd }) => (
<div key={mode} className="flex items-center gap-2 text-xs">
<span className="text-gray-500 uppercase w-14 shrink-0">{mode}</span>
<span className="text-gray-400 italic">{cmd}</span>
</div>
))}
</div>
<p className="text-gray-600 text-xs mt-3">
Voice commands are parsed by <code>social_nav_node</code> from{' '}
<code>/social/speech/command</code>.
</p>
</div>
</div>
);
}

View File

@ -0,0 +1,228 @@
/**
* PersonalityTuner.jsx Personality dial controls.
*
* Reads current personality from /social/personality/state (PersonalityState).
* Writes via /personality_node/set_parameters (rcl_interfaces/srv/SetParameters).
*
* SOUL.md params controlled:
* sass_level (int, 010)
* humor_level (int, 010)
* verbosity (int, 010) new param, add to SOUL.md + personality_node
*/
import { useEffect, useState } from 'react';
const PERSONALITY_DIALS = [
{
key: 'sass_level',
label: 'SASS',
param: { name: 'sass_level', type: 'integer' },
min: 0, max: 10,
leftLabel: 'Polite',
rightLabel: 'Maximum Sass',
color: 'accent-orange',
barColor: '#f97316',
description: '0 = pure politeness, 10 = maximum sass',
},
{
key: 'humor_level',
label: 'HUMOR',
param: { name: 'humor_level', type: 'integer' },
min: 0, max: 10,
leftLabel: 'Deadpan',
rightLabel: 'Comedian',
color: 'accent-cyan',
barColor: '#06b6d4',
description: '0 = deadpan/serious, 10 = comedian',
},
{
key: 'verbosity',
label: 'VERBOSITY',
param: { name: 'verbosity', type: 'integer' },
min: 0, max: 10,
leftLabel: 'Terse',
rightLabel: 'Verbose',
color: 'accent-purple',
barColor: '#a855f7',
description: '0 = brief responses, 10 = elaborate explanations',
},
];
function DialSlider({ dial, value, onChange }) {
const pct = ((value - dial.min) / (dial.max - dial.min)) * 100;
return (
<div className="space-y-1">
<div className="flex justify-between items-center">
<span className="text-xs font-bold text-gray-400 tracking-widest">{dial.label}</span>
<span
className="text-sm font-bold w-6 text-right"
style={{ color: dial.barColor }}
>
{value}
</span>
</div>
<div className="relative">
<input
type="range"
min={dial.min}
max={dial.max}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className={`w-full h-1.5 rounded appearance-none cursor-pointer ${dial.color}`}
style={{
background: `linear-gradient(to right, ${dial.barColor} ${pct}%, #1f2937 ${pct}%)`,
}}
/>
</div>
<div className="flex justify-between text-xs text-gray-600">
<span>{dial.leftLabel}</span>
<span>{dial.rightLabel}</span>
</div>
</div>
);
}
export function PersonalityTuner({ subscribe, setParam }) {
const [values, setValues] = useState({ sass_level: 4, humor_level: 7, verbosity: 5 });
const [applied, setApplied] = useState(null); // last-applied snapshot
const [saving, setSaving] = useState(false);
const [saveResult, setSaveResult] = useState(null);
const [personaInfo, setPersonaInfo] = useState(null);
// Sync with live personality state
useEffect(() => {
const unsub = subscribe(
'/social/personality/state',
'saltybot_social_msgs/PersonalityState',
(msg) => {
setPersonaInfo({
name: msg.persona_name,
mood: msg.mood,
person: msg.person_id,
tier: msg.relationship_tier,
greeting: msg.greeting_text,
});
}
);
return unsub;
}, [subscribe]);
const isDirty = JSON.stringify(values) !== JSON.stringify(applied);
const handleApply = async () => {
setSaving(true);
setSaveResult(null);
try {
const params = PERSONALITY_DIALS.map((d) => ({
name: d.param.name,
type: d.param.type,
value: values[d.key],
}));
await setParam('personality_node', params);
setApplied({ ...values });
setSaveResult({ ok: true, msg: 'Parameters applied to personality_node' });
} catch (e) {
setSaveResult({ ok: false, msg: e?.message ?? 'Service call failed' });
} finally {
setSaving(false);
setTimeout(() => setSaveResult(null), 4000);
}
};
const handleReset = () => {
setValues({ sass_level: 4, humor_level: 7, verbosity: 5 });
};
return (
<div className="space-y-4">
{/* Current persona info */}
{personaInfo && (
<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-2">ACTIVE PERSONA</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div>
<span className="text-gray-600">Name: </span>
<span className="text-cyan-300 font-bold">{personaInfo.name || '—'}</span>
</div>
<div>
<span className="text-gray-600">Mood: </span>
<span className={`font-bold ${
personaInfo.mood === 'happy' ? 'text-green-400' :
personaInfo.mood === 'curious' ? 'text-blue-400' :
personaInfo.mood === 'annoyed' ? 'text-red-400' :
personaInfo.mood === 'playful' ? 'text-purple-400' :
'text-gray-400'
}`}>{personaInfo.mood || '—'}</span>
</div>
{personaInfo.person && (
<>
<div>
<span className="text-gray-600">Talking to: </span>
<span className="text-amber-400">{personaInfo.person}</span>
</div>
<div>
<span className="text-gray-600">Tier: </span>
<span className="text-gray-300">{personaInfo.tier || '—'}</span>
</div>
</>
)}
{personaInfo.greeting && (
<div className="col-span-2 mt-1 text-gray-400 italic border-t border-gray-800 pt-1">
&quot;{personaInfo.greeting}&quot;
</div>
)}
</div>
</div>
)}
{/* Personality sliders */}
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-4 space-y-5">
<div className="text-cyan-700 text-xs font-bold tracking-widest">PERSONALITY DIALS</div>
{PERSONALITY_DIALS.map((dial) => (
<DialSlider
key={dial.key}
dial={dial}
value={values[dial.key]}
onChange={(v) => setValues((prev) => ({ ...prev, [dial.key]: v }))}
/>
))}
{/* Info */}
<div className="text-gray-700 text-xs border-t border-gray-900 pt-3">
Changes call <code className="text-gray-500">/personality_node/set_parameters</code>.
Hot-reload to SOUL.md happens within reload_interval (default 5s).
</div>
</div>
{/* Action buttons */}
<div className="flex items-center gap-2">
<button
onClick={handleApply}
disabled={saving || !isDirty}
className="flex-1 py-2 rounded font-bold text-sm border border-cyan-700 bg-cyan-950 hover:bg-cyan-900 text-cyan-300 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
{saving ? 'Applying…' : isDirty ? 'Apply Changes' : 'Up to Date'}
</button>
<button
onClick={handleReset}
className="px-4 py-2 rounded text-sm border border-gray-700 text-gray-400 hover:text-gray-200 hover:border-gray-600 transition-colors"
>
Defaults
</button>
</div>
{/* Save result */}
{saveResult && (
<div className={`rounded px-3 py-2 text-xs ${
saveResult.ok
? 'bg-green-950 border border-green-800 text-green-300'
: 'bg-red-950 border border-red-800 text-red-300'
}`}>
{saveResult.msg}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,154 @@
/**
* 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>
);
}

View File

@ -0,0 +1,116 @@
/**
* useRosbridge.js React hook for ROS2 rosbridge_server WebSocket connection.
*
* rosbridge_server default: ws://<robot-ip>:9090
* Provides subscribe/publish/callService helpers bound to the active connection.
*/
import { useState, useEffect, useRef, useCallback } from 'react';
import ROSLIB from 'roslib';
export function useRosbridge(url) {
const [connected, setConnected] = useState(false);
const [error, setError] = useState(null);
const rosRef = useRef(null);
const subscribersRef = useRef(new Map()); // topic -> ROSLIB.Topic
useEffect(() => {
if (!url) return;
const ros = new ROSLIB.Ros({ url });
rosRef.current = ros;
ros.on('connection', () => {
setConnected(true);
setError(null);
});
ros.on('error', (err) => {
setError(err?.toString() || 'Connection error');
});
ros.on('close', () => {
setConnected(false);
});
return () => {
subscribersRef.current.forEach((topic) => topic.unsubscribe());
subscribersRef.current.clear();
ros.close();
rosRef.current = null;
};
}, [url]);
/** Subscribe to a ROS2 topic. Returns an unsubscribe function. */
const subscribe = useCallback((name, messageType, callback) => {
if (!rosRef.current) return () => {};
const key = `${name}::${messageType}`;
if (subscribersRef.current.has(key)) {
subscribersRef.current.get(key).unsubscribe();
}
const topic = new ROSLIB.Topic({
ros: rosRef.current,
name,
messageType,
});
topic.subscribe(callback);
subscribersRef.current.set(key, topic);
return () => {
topic.unsubscribe();
subscribersRef.current.delete(key);
};
}, []);
/** Publish a single message to a ROS2 topic. */
const publish = useCallback((name, messageType, data) => {
if (!rosRef.current) return;
const topic = new ROSLIB.Topic({
ros: rosRef.current,
name,
messageType,
});
topic.publish(new ROSLIB.Message(data));
}, []);
/** Call a ROS2 service. Returns a Promise resolving to the response. */
const callService = useCallback((name, serviceType, request = {}) => {
return new Promise((resolve, reject) => {
if (!rosRef.current) {
reject(new Error('Not connected'));
return;
}
const svc = new ROSLIB.Service({
ros: rosRef.current,
name,
serviceType,
});
svc.callService(new ROSLIB.ServiceRequest(request), resolve, reject);
});
}, []);
/** Set ROS2 node parameters via rcl_interfaces/srv/SetParameters. */
const setParam = useCallback((nodeName, params) => {
// params: { name: string, type: 'bool'|'int'|'double'|'string', value: any }[]
const TYPE_MAP = { bool: 1, integer: 2, double: 3, string: 4, int: 2 };
const parameters = params.map(({ name, type, value }) => {
const typeInt = TYPE_MAP[type] ?? 4;
const valueKey = {
1: 'bool_value',
2: 'integer_value',
3: 'double_value',
4: 'string_value',
}[typeInt];
return { name, value: { type: typeInt, [valueKey]: value } };
});
return callService(
`/${nodeName}/set_parameters`,
'rcl_interfaces/srv/SetParameters',
{ parameters }
);
}, [callService]);
return { connected, error, subscribe, publish, callService, setParam };
}

View File

@ -0,0 +1,73 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
* {
box-sizing: border-box;
}
body {
font-family: 'Courier New', Courier, monospace;
background: #050510;
color: #d1d5db;
margin: 0;
padding: 0;
min-height: 100vh;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 4px;
height: 4px;
}
::-webkit-scrollbar-track {
background: #020208;
}
::-webkit-scrollbar-thumb {
background: #1a3a4a;
border-radius: 2px;
}
::-webkit-scrollbar-thumb:hover {
background: #00b8d9;
}
/* Pulse animations for pipeline state */
@keyframes pulse-blue {
0%, 100% { box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.6); }
50% { box-shadow: 0 0 0 8px rgba(59, 130, 246, 0); }
}
@keyframes pulse-amber {
0%, 100% { box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.6); }
50% { box-shadow: 0 0 0 8px rgba(245, 158, 11, 0); }
}
@keyframes pulse-green {
0%, 100% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.6); }
50% { box-shadow: 0 0 0 8px rgba(34, 197, 94, 0); }
}
.pulse-blue { animation: pulse-blue 1.2s infinite; }
.pulse-amber { animation: pulse-amber 0.8s infinite; }
.pulse-green { animation: pulse-green 0.6s infinite; }
/* Conversation bubbles */
.bubble-human {
background: #1e3a5f;
border: 1px solid #2d6a9f;
}
.bubble-bot {
background: #0d3030;
border: 1px solid #0d9488;
}
.bubble-partial {
opacity: 0.7;
}
/* Slider accent colors */
input[type='range'].accent-cyan { accent-color: #00b8d9; }
input[type='range'].accent-orange { accent-color: #f97316; }
input[type='range'].accent-purple { accent-color: #a855f7; }
/* Mode button active glow */
.mode-active {
box-shadow: 0 0 10px rgba(0, 184, 217, 0.4);
}

View File

@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.jsx';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@ -0,0 +1,12 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,jsx}'],
theme: {
extend: {
fontFamily: {
mono: ['"Courier New"', 'Courier', 'monospace'],
},
},
},
plugins: [],
};

View File

@ -0,0 +1,14 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 8080,
host: true,
},
preview: {
port: 8080,
host: true,
},
});