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