sl-webui 1cd8ebeb32 feat(ui): add social-bot dashboard (issue #107)
React + Vite + TailwindCSS dashboard served on port 8080.
Connects to ROS2 via rosbridge_server WebSocket (default ws://localhost:9090).

Panels:
- StatusPanel: pipeline state (idle/listening/thinking/speaking/throttled)
  with animated pulse indicator, GPU memory bar, per-stage latency stats
- FaceGallery: enrolled persons grid with enroll/delete via
  /social/enrollment/* services; live detection indicator
- ConversationLog: real-time transcript with human/bot bubbles,
  streaming partial support, auto-scroll
- PersonalityTuner: sass/humor/verbosity sliders (0–10) writing to
  personality_node via rcl_interfaces/srv/SetParameters; live
  PersonalityState display
- NavModeSelector: shadow/lead/side/orbit/loose/tight mode buttons
  publishing to /social/nav/mode; voice command reference table

Usage:
  cd ui/social-bot && npm install && npm run dev   # dev server port 8080
  npm run build && npm run preview                  # production preview

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 08:36:51 -05:00

117 lines
3.3 KiB
JavaScript

/**
* 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 };
}