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>
This commit is contained in:
parent
00c97bd902
commit
1cd8ebeb32
2
ui/social-bot/.gitignore
vendored
Normal file
2
ui/social-bot/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
dist/
|
||||
12
ui/social-bot/index.html
Normal file
12
ui/social-bot/index.html
Normal 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
2920
ui/social-bot/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
ui/social-bot/package.json
Normal file
24
ui/social-bot/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
ui/social-bot/postcss.config.js
Normal file
6
ui/social-bot/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
165
ui/social-bot/src/App.jsx
Normal file
165
ui/social-bot/src/App.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
221
ui/social-bot/src/components/ConversationLog.jsx
Normal file
221
ui/social-bot/src/components/ConversationLog.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
237
ui/social-bot/src/components/FaceGallery.jsx
Normal file
237
ui/social-bot/src/components/FaceGallery.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
176
ui/social-bot/src/components/NavModeSelector.jsx
Normal file
176
ui/social-bot/src/components/NavModeSelector.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
228
ui/social-bot/src/components/PersonalityTuner.jsx
Normal file
228
ui/social-bot/src/components/PersonalityTuner.jsx
Normal 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, 0–10)
|
||||
* humor_level (int, 0–10)
|
||||
* verbosity (int, 0–10) — 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">
|
||||
"{personaInfo.greeting}"
|
||||
</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>
|
||||
);
|
||||
}
|
||||
154
ui/social-bot/src/components/StatusPanel.jsx
Normal file
154
ui/social-bot/src/components/StatusPanel.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
116
ui/social-bot/src/hooks/useRosbridge.js
Normal file
116
ui/social-bot/src/hooks/useRosbridge.js
Normal 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 };
|
||||
}
|
||||
73
ui/social-bot/src/index.css
Normal file
73
ui/social-bot/src/index.css
Normal 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);
|
||||
}
|
||||
10
ui/social-bot/src/main.jsx
Normal file
10
ui/social-bot/src/main.jsx
Normal 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>
|
||||
);
|
||||
12
ui/social-bot/tailwind.config.js
Normal file
12
ui/social-bot/tailwind.config.js
Normal 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: [],
|
||||
};
|
||||
14
ui/social-bot/vite.config.js
Normal file
14
ui/social-bot/vite.config.js
Normal 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,
|
||||
},
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user