377 lines
10 KiB
TypeScript

import { useState, useRef, useEffect, useCallback } from 'react';
import { useSocket, type RobotState } from './hooks/useSocket';
import { useMicrophone } from './hooks/useMicrophone';
const STATE_COLORS: Record<RobotState, string> = {
disconnected: '#666',
idle: '#4caf50',
listening: '#2196f3',
thinking: '#ff9800',
speaking: '#ab47bc',
};
const STATE_LABELS: Record<RobotState, string> = {
disconnected: 'Déconnecté',
idle: 'Prêt',
listening: 'Écoute...',
thinking: 'Réflexion...',
speaking: 'Parle...',
};
const LS_KEY_TOKEN = 'tipote_device_token';
const LS_KEY_URL = 'tipote_server_url';
function App() {
const [serverUrl, setServerUrl] = useState(() => localStorage.getItem(LS_KEY_URL) || 'http://localhost:3000');
const [deviceToken, setDeviceToken] = useState(() => localStorage.getItem(LS_KEY_TOKEN) || '');
const [conversationActive, setConversationActive] = useState(false);
const [showLogs, setShowLogs] = useState(false);
const { state, connected, logs, connect, disconnect, emit, clearLogs } = useSocket();
const logsEndRef = useRef<HTMLDivElement>(null);
const prevStateRef = useRef<RobotState>('disconnected');
const onAudioChunk = useCallback(
(chunk: ArrayBuffer, sampleRate: number) => {
emit('audio_chunk', { data: chunk, sampleRate });
},
[emit],
);
const onSpeechEnd = useCallback(() => {
emit('speech_end');
}, [emit]);
const { recording, start: startMic, stop: stopMic, silentStop } = useMicrophone({ onAudioChunk, onSpeechEnd });
// Stop mic when Ti-Pote starts thinking/speaking
useEffect(() => {
if (recording && (state === 'thinking' || state === 'speaking')) {
silentStop();
}
}, [state, recording, silentStop]);
// Auto-restart listening when Ti-Pote finishes speaking
useEffect(() => {
const prevState = prevStateRef.current;
prevStateRef.current = state;
if (conversationActive && state === 'idle' && (prevState === 'speaking' || prevState === 'thinking')) {
const timer = setTimeout(() => {
emit('wake_word_detected');
startMic();
}, 500);
return () => clearTimeout(timer);
}
}, [state, conversationActive, emit, startMic]);
useEffect(() => {
logsEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [logs]);
// Persist settings to localStorage
const handleServerUrlChange = (value: string) => {
setServerUrl(value);
localStorage.setItem(LS_KEY_URL, value);
};
const handleTokenChange = (value: string) => {
setDeviceToken(value);
localStorage.setItem(LS_KEY_TOKEN, value);
};
const handleConnect = () => {
if (connected) {
disconnect();
setConversationActive(false);
} else {
if (!deviceToken.trim()) return alert('Device token requis');
connect(serverUrl, deviceToken.trim());
}
};
const handleConversation = () => {
if (conversationActive) {
setConversationActive(false);
emit('user_interrupt');
if (recording) stopMic();
} else {
setConversationActive(true);
emit('wake_word_detected');
startMic();
}
};
return (
<div style={styles.container}>
{/* Header */}
<div style={styles.header}>
<h1 style={styles.title}>Ti-Pote Simulator</h1>
<div
style={{
...styles.statusBadge,
background: STATE_COLORS[state],
boxShadow: state !== 'disconnected' ? `0 0 10px ${STATE_COLORS[state]}60` : 'none',
}}
>
{STATE_LABELS[state]}
</div>
</div>
{/* Connection */}
{!connected ? (
<div style={styles.panel}>
<h2 style={styles.panelTitle}>Connexion</h2>
<div style={styles.field}>
<label style={styles.label}>Server URL</label>
<input
style={styles.input}
value={serverUrl}
onChange={(e) => handleServerUrlChange(e.target.value)}
placeholder="http://localhost:3000"
/>
</div>
<div style={styles.field}>
<label style={styles.label}>Device Token</label>
<input
style={styles.input}
value={deviceToken}
onChange={(e) => handleTokenChange(e.target.value)}
placeholder="Coller le JWT du device ici"
/>
</div>
<button
style={{ ...styles.btn, background: '#4caf50', width: '100%', padding: '12px 20px', fontSize: 16 }}
onClick={handleConnect}
disabled={!deviceToken.trim()}
>
Connecter
</button>
</div>
) : (
<>
{/* Main action */}
<div style={styles.panel}>
<button
style={{
...styles.btn,
width: '100%',
padding: '20px',
fontSize: 18,
background: conversationActive ? '#f44336' : '#2196f3',
borderRadius: 12,
transition: 'all 0.2s',
}}
onClick={handleConversation}
>
{conversationActive ? 'Arrêter la conversation' : 'Parler à Ti-Pote'}
</button>
{conversationActive && recording && (
<div style={styles.listeningIndicator}>
<span style={styles.pulse} />
Micro actif parlez, Ti-Pote écoute
</div>
)}
{conversationActive && state === 'thinking' && (
<div style={styles.thinkingIndicator}>Ti-Pote réfléchit...</div>
)}
{conversationActive && state === 'speaking' && (
<div style={styles.speakingIndicator}>Ti-Pote parle...</div>
)}
</div>
{/* Disconnect */}
<button
style={{ ...styles.btn, background: 'transparent', color: '#888', width: '100%', marginBottom: 16 }}
onClick={handleConnect}
>
Déconnecter
</button>
</>
)}
{/* Logs (collapsible) */}
<div style={styles.panel}>
<div style={styles.logHeader}>
<button
style={{ ...styles.btn, background: 'transparent', color: '#888', padding: '4px 0', fontSize: 12 }}
onClick={() => setShowLogs(!showLogs)}
>
{showLogs ? '▼' : '▶'} Logs ({logs.length})
</button>
{showLogs && (
<button
style={{ ...styles.btn, background: '#333', padding: '4px 12px', fontSize: 11 }}
onClick={clearLogs}
>
Clear
</button>
)}
</div>
{showLogs && (
<div style={styles.logContainer}>
{logs.map((log, i) => (
<div key={i} style={styles.logEntry}>
<span style={styles.logTime}>{log.timestamp.toLocaleTimeString()}</span>
<span
style={{
...styles.logDirection,
color: log.direction === 'in' ? '#4caf50' : log.direction === 'out' ? '#2196f3' : '#ff9800',
}}
>
{log.direction === 'in' ? '◀' : log.direction === 'out' ? '▶' : '●'}
</span>
<span style={styles.logEvent}>{log.event}</span>
{log.data && <span style={styles.logData}>{log.data}</span>}
</div>
))}
<div ref={logsEndRef} />
</div>
)}
</div>
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
container: {
maxWidth: 480,
margin: '0 auto',
padding: 20,
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
color: '#e0e0e0',
background: '#1a1a2e',
minHeight: '100vh',
},
header: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 20,
},
title: {
fontSize: 20,
fontWeight: 700,
color: '#fff',
margin: 0,
},
statusBadge: {
padding: '6px 14px',
borderRadius: 20,
fontSize: 12,
fontWeight: 600,
color: '#fff',
transition: 'all 0.3s',
},
panel: {
background: '#16213e',
borderRadius: 12,
padding: 16,
marginBottom: 12,
},
panelTitle: {
fontSize: 13,
fontWeight: 600,
textTransform: 'uppercase' as const,
letterSpacing: 1,
color: '#666',
margin: '0 0 12px 0',
},
field: {
marginBottom: 12,
},
label: {
display: 'block',
fontSize: 12,
color: '#aaa',
marginBottom: 4,
},
input: {
width: '100%',
padding: '10px 12px',
border: '1px solid #333',
borderRadius: 8,
background: '#0f3460',
color: '#fff',
fontSize: 14,
boxSizing: 'border-box' as const,
outline: 'none',
},
btn: {
padding: '8px 20px',
border: 'none',
borderRadius: 8,
color: '#fff',
fontSize: 14,
fontWeight: 600,
cursor: 'pointer',
},
listeningIndicator: {
display: 'flex',
alignItems: 'center',
gap: 10,
marginTop: 12,
padding: '10px 14px',
background: '#1a237e',
borderRadius: 8,
fontSize: 13,
color: '#90caf9',
},
pulse: {
display: 'inline-block',
width: 10,
height: 10,
borderRadius: '50%',
background: '#2196f3',
animation: 'pulse 1.5s infinite',
flexShrink: 0,
},
thinkingIndicator: {
marginTop: 12,
padding: '10px 14px',
background: '#4a2800',
borderRadius: 8,
fontSize: 13,
color: '#ffb74d',
textAlign: 'center' as const,
},
speakingIndicator: {
marginTop: 12,
padding: '10px 14px',
background: '#2a1040',
borderRadius: 8,
fontSize: 13,
color: '#ce93d8',
textAlign: 'center' as const,
},
logHeader: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
},
logContainer: {
maxHeight: 200,
overflowY: 'auto' as const,
fontFamily: 'monospace',
fontSize: 11,
background: '#0a0a1a',
borderRadius: 6,
padding: 8,
marginTop: 8,
},
logEntry: {
display: 'flex',
gap: 6,
padding: '2px 0',
borderBottom: '1px solid #1a1a2e',
},
logTime: { color: '#555', flexShrink: 0 },
logDirection: { flexShrink: 0 },
logEvent: { color: '#ccc', fontWeight: 600, flexShrink: 0 },
logData: { color: '#888', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' as const },
};
export default App;