feat(webui): conversation history panel (Issue #240)
Implements a chat-style conversation viewer that subscribes to /social/conversation_text and displays user speech (STT) and robot responses (TTS) with timestamps and speaker labels. Includes auto-scroll to latest message, manual scroll detection, and message history limiting. - New component: ConversationHistory.jsx (chat-style message bubbles) - User messages in blue, robot responses in green - Auto-scrolling with manual scroll detection toggle - Timestamp formatting (HH:MM:SS) - Message history limiting (max 100 messages) - Clear history button - Integrated into SOCIAL tab group as "History" tab - Subscribes to /social/conversation_text topic (saltybot_social_msgs/ConversationMessage) Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
3901e27683
commit
04652c73f8
@ -24,6 +24,7 @@ import { useRosbridge } from './hooks/useRosbridge.js';
|
|||||||
import { StatusPanel } from './components/StatusPanel.jsx';
|
import { StatusPanel } from './components/StatusPanel.jsx';
|
||||||
import { FaceGallery } from './components/FaceGallery.jsx';
|
import { FaceGallery } from './components/FaceGallery.jsx';
|
||||||
import { ConversationLog } from './components/ConversationLog.jsx';
|
import { ConversationLog } from './components/ConversationLog.jsx';
|
||||||
|
import { ConversationHistory } from './components/ConversationHistory.jsx';
|
||||||
import { PersonalityTuner } from './components/PersonalityTuner.jsx';
|
import { PersonalityTuner } from './components/PersonalityTuner.jsx';
|
||||||
import { NavModeSelector } from './components/NavModeSelector.jsx';
|
import { NavModeSelector } from './components/NavModeSelector.jsx';
|
||||||
import { AudioMeter } from './components/AudioMeter.jsx';
|
import { AudioMeter } from './components/AudioMeter.jsx';
|
||||||
@ -65,6 +66,7 @@ const TAB_GROUPS = [
|
|||||||
{ id: 'status', label: 'Status', },
|
{ id: 'status', label: 'Status', },
|
||||||
{ id: 'faces', label: 'Faces', },
|
{ id: 'faces', label: 'Faces', },
|
||||||
{ id: 'conversation', label: 'Convo', },
|
{ id: 'conversation', label: 'Convo', },
|
||||||
|
{ id: 'history', label: 'History', },
|
||||||
{ id: 'personality', label: 'Personality', },
|
{ id: 'personality', label: 'Personality', },
|
||||||
{ id: 'navigation', label: 'Nav Mode', },
|
{ id: 'navigation', label: 'Nav Mode', },
|
||||||
{ id: 'audio', label: 'Audio', },
|
{ id: 'audio', label: 'Audio', },
|
||||||
@ -223,6 +225,7 @@ export default function App() {
|
|||||||
{activeTab === 'status' && <StatusPanel subscribe={subscribe} />}
|
{activeTab === 'status' && <StatusPanel subscribe={subscribe} />}
|
||||||
{activeTab === 'faces' && <FaceGallery subscribe={subscribe} callService={callService} />}
|
{activeTab === 'faces' && <FaceGallery subscribe={subscribe} callService={callService} />}
|
||||||
{activeTab === 'conversation' && <ConversationLog subscribe={subscribe} />}
|
{activeTab === 'conversation' && <ConversationLog subscribe={subscribe} />}
|
||||||
|
{activeTab === 'history' && <ConversationHistory subscribe={subscribe} />}
|
||||||
{activeTab === 'personality' && <PersonalityTuner subscribe={subscribe} setParam={setParam} />}
|
{activeTab === 'personality' && <PersonalityTuner subscribe={subscribe} setParam={setParam} />}
|
||||||
{activeTab === 'navigation' && <NavModeSelector subscribe={subscribe} publish={publishFn} />}
|
{activeTab === 'navigation' && <NavModeSelector subscribe={subscribe} publish={publishFn} />}
|
||||||
{activeTab === 'audio' && <AudioMeter subscribe={subscribe} />}
|
{activeTab === 'audio' && <AudioMeter subscribe={subscribe} />}
|
||||||
|
|||||||
209
ui/social-bot/src/components/ConversationHistory.jsx
Normal file
209
ui/social-bot/src/components/ConversationHistory.jsx
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
/**
|
||||||
|
* ConversationHistory.jsx — Chat-style conversation display
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Real-time conversation messages from /social/conversation_text
|
||||||
|
* - User speech input (STT) and robot responses (TTS)
|
||||||
|
* - Timestamped messages with speaker labels
|
||||||
|
* - Auto-scroll to latest message
|
||||||
|
* - Message grouping by speaker
|
||||||
|
* - Scrollback history with max limit
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
const MAX_MESSAGES = 100; // Keep last 100 messages
|
||||||
|
const AUTO_SCROLL_THRESHOLD = 100; // pixels from bottom
|
||||||
|
|
||||||
|
function formatTimestamp(timestamp) {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
return date.toLocaleTimeString('en-US', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function MessageBubble({ message, isUser }) {
|
||||||
|
const bgClass = isUser
|
||||||
|
? 'bg-blue-950 border-blue-800'
|
||||||
|
: 'bg-green-950 border-green-800';
|
||||||
|
|
||||||
|
const textColorClass = isUser ? 'text-blue-300' : 'text-green-300';
|
||||||
|
const labelColorClass = isUser ? 'text-blue-500' : 'text-green-500';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'} mb-3`}>
|
||||||
|
<div className={`max-w-xs lg:max-w-md rounded-lg border ${bgClass} p-3 space-y-1`}>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className={`text-xs font-bold tracking-widest ${labelColorClass}`}>
|
||||||
|
{isUser ? 'USER' : 'ROBOT'}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-600">
|
||||||
|
{formatTimestamp(message.timestamp)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className={`text-sm ${textColorClass} break-words leading-relaxed`}>
|
||||||
|
{message.text}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConversationHistory({ subscribe }) {
|
||||||
|
const [messages, setMessages] = useState([]);
|
||||||
|
const [autoScroll, setAutoScroll] = useState(true);
|
||||||
|
const messagesEndRef = useRef(null);
|
||||||
|
const scrollContainerRef = useRef(null);
|
||||||
|
const messageIdRef = useRef(0);
|
||||||
|
|
||||||
|
// Auto-scroll to latest message
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoScroll && messagesEndRef.current) {
|
||||||
|
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
}, [messages, autoScroll]);
|
||||||
|
|
||||||
|
// Handle scroll detection for auto-scroll toggle
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (!scrollContainerRef.current) return;
|
||||||
|
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current;
|
||||||
|
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
|
||||||
|
|
||||||
|
// Auto-scroll if within threshold
|
||||||
|
setAutoScroll(distanceFromBottom < AUTO_SCROLL_THRESHOLD);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Subscribe to conversation text topic
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubConversation = subscribe(
|
||||||
|
'/social/conversation_text',
|
||||||
|
'saltybot_social_msgs/ConversationMessage',
|
||||||
|
(msg) => {
|
||||||
|
try {
|
||||||
|
// Parse message data
|
||||||
|
let speaker = 'unknown';
|
||||||
|
let text = '';
|
||||||
|
|
||||||
|
if (msg.speaker !== undefined) {
|
||||||
|
// speaker: 0 = user, 1 = robot
|
||||||
|
speaker = msg.speaker === 0 ? 'user' : 'robot';
|
||||||
|
} else if (msg.is_user !== undefined) {
|
||||||
|
speaker = msg.is_user ? 'user' : 'robot';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.text) {
|
||||||
|
text = msg.text;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!text) return; // Skip empty messages
|
||||||
|
|
||||||
|
const newMessage = {
|
||||||
|
id: ++messageIdRef.current,
|
||||||
|
text,
|
||||||
|
speaker,
|
||||||
|
isUser: speaker === 'user',
|
||||||
|
timestamp: msg.timestamp || Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
setMessages((prev) => [...prev, newMessage].slice(-MAX_MESSAGES));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing conversation message:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return unsubConversation;
|
||||||
|
}, [subscribe]);
|
||||||
|
|
||||||
|
const clearHistory = () => {
|
||||||
|
setMessages([]);
|
||||||
|
messageIdRef.current = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const userMessageCount = messages.filter((m) => m.isUser).length;
|
||||||
|
const robotMessageCount = messages.filter((m) => !m.isUser).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full space-y-3">
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-3 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="text-cyan-700 text-xs font-bold tracking-widest">
|
||||||
|
CONVERSATION HISTORY
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-600 text-xs">
|
||||||
|
{messages.length} messages ({userMessageCount} user, {robotMessageCount} robot)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={clearHistory}
|
||||||
|
className="px-3 py-1.5 text-xs font-bold tracking-widest rounded border border-gray-700 text-gray-400 hover:text-red-400 hover:border-red-700 transition-colors"
|
||||||
|
>
|
||||||
|
CLEAR
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Messages Container */}
|
||||||
|
<div
|
||||||
|
ref={scrollContainerRef}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
className="flex-1 overflow-y-auto bg-gray-950 rounded-lg border border-cyan-950 p-4 space-y-1"
|
||||||
|
>
|
||||||
|
{messages.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-full text-gray-600">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-sm mb-2">No conversation yet</div>
|
||||||
|
<div className="text-xs text-gray-700">
|
||||||
|
Messages from /social/conversation_text will appear here
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{messages.map((message) => (
|
||||||
|
<MessageBubble
|
||||||
|
key={message.id}
|
||||||
|
message={message}
|
||||||
|
isUser={message.isUser}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Auto-scroll indicator */}
|
||||||
|
<div className="bg-gray-950 rounded border border-gray-800 p-2 flex items-center justify-between text-xs text-gray-600">
|
||||||
|
<span>Auto-scroll to latest: {autoScroll ? '✓ ON' : '○ OFF'}</span>
|
||||||
|
{!autoScroll && (
|
||||||
|
<button
|
||||||
|
onClick={() => setAutoScroll(true)}
|
||||||
|
className="text-cyan-500 hover:text-cyan-400 transition-colors"
|
||||||
|
>
|
||||||
|
Jump to latest
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Topic Info */}
|
||||||
|
<div className="bg-gray-950 rounded border border-gray-800 p-2 text-xs text-gray-600 space-y-1">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Topic:</span>
|
||||||
|
<span className="text-gray-500">/social/conversation_text</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Message Type:</span>
|
||||||
|
<span className="text-gray-500">saltybot_social_msgs/ConversationMessage</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Max History:</span>
|
||||||
|
<span className="text-gray-500">{MAX_MESSAGES} messages</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user