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

229 lines
7.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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