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>
229 lines
7.5 KiB
JavaScript
229 lines
7.5 KiB
JavaScript
/**
|
||
* 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>
|
||
);
|
||
}
|