import { useState, useRef, useEffect, useCallback } from 'react'; import { useSocket, type RobotState } from './hooks/useSocket'; import { useMicrophone } from './hooks/useMicrophone'; const STATE_COLORS: Record = { disconnected: '#666', idle: '#4caf50', listening: '#2196f3', thinking: '#ff9800', speaking: '#ab47bc', }; const STATE_LABELS: Record = { 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(null); const prevStateRef = useRef('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 (
{/* Header */}

Ti-Pote Simulator

{STATE_LABELS[state]}
{/* Connection */} {!connected ? (

Connexion

handleServerUrlChange(e.target.value)} placeholder="http://localhost:3000" />
handleTokenChange(e.target.value)} placeholder="Coller le JWT du device ici" />
) : ( <> {/* Main action */}
{conversationActive && recording && (
Micro actif — parlez, Ti-Pote écoute
)} {conversationActive && state === 'thinking' && (
Ti-Pote réfléchit...
)} {conversationActive && state === 'speaking' && (
Ti-Pote parle...
)}
{/* Disconnect */} )} {/* Logs (collapsible) */}
{showLogs && ( )}
{showLogs && (
{logs.map((log, i) => (
{log.timestamp.toLocaleTimeString()} {log.direction === 'in' ? '◀' : log.direction === 'out' ? '▶' : '●'} {log.event} {log.data && {log.data}}
))}
)}
); } const styles: Record = { 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;