Spaces:
Sleeping
Sleeping
| import { useState, useCallback, useRef } from 'react'; | |
| import JarvisOrb from './components/JarvisOrb'; | |
| import ConversationOverlay from './components/ConversationOverlay'; | |
| import ProjectCardsDrawer from './components/ProjectCardsDrawer'; | |
| import useOperatorSSE from './hooks/useOperatorSSE'; | |
| import useWakeWord from './hooks/useWakeWord'; | |
| import useSpeechRecognition from './hooks/useSpeechRecognition'; | |
| import useTTS from './hooks/useTTS'; | |
| import useGoogleAuth from './hooks/useGoogleAuth'; | |
| const PROJECTS = [ | |
| { id: 'school', name: 'Master IA \u2014 Sorbonne', contact: 'prof.martinez@sorbonne.fr', color: '#4285f4' }, | |
| { id: 'company', name: 'Alternance \u2014 BNP Paribas', contact: 'sophie.renard@bnpparibas.com', color: '#ea4335' }, | |
| { id: 'startup', name: 'Side Project \u2014 NoctaAI', contact: 'yassine@noctaai.com', color: '#fbbc04' }, | |
| ]; | |
| export default function App() { | |
| const { sendChat } = useOperatorSSE(); | |
| const { speak } = useTTS(); | |
| const { authenticated, oauthAvailable, login, logout } = useGoogleAuth(); | |
| // Start directly in IDLE — no scan needed | |
| const [phase, setPhase] = useState('IDLE'); | |
| const [messages, setMessages] = useState([]); | |
| const phaseRef = useRef(phase); | |
| phaseRef.current = phase; | |
| const [inputText, setInputText] = useState(''); | |
| const [showTranscript, setShowTranscript] = useState(false); | |
| const handleWakeWord = useCallback(() => { | |
| if (phaseRef.current === 'IDLE') setPhase('LISTENING'); | |
| }, []); | |
| const { micActive, micError, requestMic } = useWakeWord({ | |
| enabled: phase === 'IDLE', | |
| onDetected: handleWakeWord, | |
| }); | |
| const handleOrbClick = useCallback(() => { | |
| if (phase === 'IDLE') { | |
| if (micError || !micActive) { | |
| // First click: authorize mic — wake word listener starts | |
| requestMic(); | |
| } else { | |
| // Mic already active — go to listening | |
| setPhase('LISTENING'); | |
| } | |
| } | |
| }, [phase, micError, micActive, requestMic]); | |
| const handleUserSaid = useCallback(async (text) => { | |
| if (!text.trim()) return; | |
| const bye = text.toLowerCase(); | |
| if (bye.includes('merci') || bye.includes("c'est bon") || bye.includes('a plus') || bye.includes('au revoir')) { | |
| setMessages(prev => [...prev, { role: 'user', text }]); | |
| const farewell = "Ok, je reste la si tu as besoin. Dis 'Oppy' quand tu veux."; | |
| setMessages(prev => [...prev, { role: 'assistant', text: farewell }]); | |
| setPhase('SPEAKING'); | |
| await speak(farewell); | |
| setPhase('IDLE'); | |
| return; | |
| } | |
| setMessages(prev => [...prev, { role: 'user', text }]); | |
| setPhase('SPEAKING'); | |
| const acks = [ | |
| "D'accord, donne-moi un petit instant.", | |
| "Je regarde ca tout de suite.", | |
| "Laisse-moi verifier.", | |
| "Une seconde, je cherche.", | |
| ]; | |
| const ack = acks[Math.floor(Math.random() * acks.length)]; | |
| const ackDone = speak(ack); | |
| const replyPromise = sendChat(text); | |
| await ackDone; | |
| setPhase('THINKING'); | |
| try { | |
| const reply = await replyPromise; | |
| if (reply) { | |
| setMessages(prev => [...prev, { role: 'assistant', text: reply }]); | |
| setPhase('SPEAKING'); | |
| await speak(reply); | |
| if (phaseRef.current === 'SPEAKING') setPhase('LISTENING'); | |
| } else { | |
| setPhase('LISTENING'); | |
| } | |
| } catch (err) { | |
| console.error('Chat error:', err); | |
| setPhase('LISTENING'); | |
| } | |
| }, [sendChat, speak]); | |
| const handleTimeout = useCallback(() => { | |
| if (phaseRef.current === 'LISTENING') setPhase('IDLE'); | |
| }, []); | |
| const { transcript } = useSpeechRecognition({ | |
| enabled: phase === 'LISTENING', | |
| onResult: handleUserSaid, | |
| onTimeout: handleTimeout, | |
| timeoutMs: 20000, | |
| }); | |
| const handleSubmit = (e) => { | |
| e.preventDefault(); | |
| const text = inputText.trim(); | |
| if (!text || phase === 'THINKING' || phase === 'SPEAKING') return; | |
| setInputText(''); | |
| handleUserSaid(text); | |
| }; | |
| let label = ''; | |
| if (phase === 'IDLE') { | |
| if (micError) label = "Clique sur l'orbe pour autoriser le micro"; | |
| else if (!micActive) label = "Clique sur l'orbe pour activer le micro"; | |
| else label = "Micro actif — dis 'Oppy' pour commencer"; | |
| } | |
| else if (phase === 'LISTENING') label = transcript || "Je t'ecoute..."; | |
| else if (phase === 'THINKING') label = 'Oppy reflechit...'; | |
| else if (phase === 'SPEAKING') label = ''; | |
| const micDotColor = phase === 'IDLE' && micActive ? '#34a853' : phase === 'IDLE' && micError ? '#ea4335' : null; | |
| return ( | |
| <div style={{ | |
| minHeight: '100vh', | |
| display: 'flex', | |
| flexDirection: 'column', | |
| alignItems: 'center', | |
| justifyContent: 'center', | |
| padding: '40px 20px 100px', | |
| position: 'relative', | |
| }}> | |
| {/* Logo */} | |
| <div style={{ | |
| position: 'fixed', | |
| top: 0, | |
| left: 0, | |
| padding: '20px 24px', | |
| zIndex: 10, | |
| display: 'flex', | |
| alignItems: 'center', | |
| gap: '10px', | |
| }}> | |
| <span style={{ | |
| fontSize: '20px', | |
| background: 'linear-gradient(135deg, #4285f4, #a142f4, #f439a0)', | |
| WebkitBackgroundClip: 'text', | |
| WebkitTextFillColor: 'transparent', | |
| }}> | |
| ✦ | |
| </span> | |
| <h1 style={{ | |
| fontSize: '16px', | |
| fontWeight: 500, | |
| color: '#1f1f1f', | |
| margin: 0, | |
| letterSpacing: '0.5px', | |
| }}> | |
| Oppy | |
| </h1> | |
| </div> | |
| {/* Google Auth status */} | |
| {oauthAvailable && ( | |
| <div style={{ | |
| position: 'fixed', | |
| top: 0, | |
| right: 0, | |
| padding: '20px 24px', | |
| zIndex: 10, | |
| display: 'flex', | |
| alignItems: 'center', | |
| gap: '10px', | |
| }}> | |
| <span style={{ | |
| fontSize: '11px', | |
| color: authenticated ? '#34a853' : '#9aa0a6', | |
| display: 'flex', | |
| alignItems: 'center', | |
| gap: '6px', | |
| }}> | |
| <span style={{ | |
| width: '8px', | |
| height: '8px', | |
| borderRadius: '50%', | |
| background: authenticated ? '#34a853' : '#9aa0a6', | |
| display: 'inline-block', | |
| }} /> | |
| {authenticated ? 'Google connecte' : 'Mode demo'} | |
| </span> | |
| <button | |
| onClick={authenticated ? logout : login} | |
| style={{ | |
| background: 'transparent', | |
| color: authenticated ? '#9aa0a6' : '#4285f4', | |
| border: `1px solid ${authenticated ? '#e0e0e0' : '#4285f4'}`, | |
| padding: '6px 12px', | |
| borderRadius: '16px', | |
| fontFamily: 'inherit', | |
| fontSize: '11px', | |
| fontWeight: 500, | |
| cursor: 'pointer', | |
| transition: 'all 0.2s', | |
| }} | |
| > | |
| {authenticated ? 'Deconnecter' : 'Connecter Google'} | |
| </button> | |
| </div> | |
| )} | |
| {/* Orb */} | |
| <JarvisOrb phase={phase} onClick={handleOrbClick} isLoading={false} /> | |
| {/* Mic status dot */} | |
| {micDotColor && ( | |
| <div style={{ | |
| marginTop: '12px', | |
| display: 'flex', | |
| alignItems: 'center', | |
| gap: '6px', | |
| }}> | |
| <div style={{ | |
| width: '8px', height: '8px', borderRadius: '50%', | |
| background: micDotColor, | |
| animation: micActive ? 'pulse 2s infinite' : 'none', | |
| }} /> | |
| <span style={{ fontSize: '11px', color: micDotColor, letterSpacing: '0.5px' }}> | |
| {micActive ? 'Micro actif' : 'Micro bloque'} | |
| </span> | |
| </div> | |
| )} | |
| {/* Phase label */} | |
| <div style={{ | |
| marginTop: micDotColor ? '8px' : '24px', | |
| fontSize: '14px', | |
| color: phase === 'LISTENING' && transcript ? '#4285f4' : '#5f6368', | |
| textAlign: 'center', | |
| minHeight: '20px', | |
| animation: phase === 'THINKING' ? 'pulse 1.5s infinite' : 'none', | |
| fontWeight: phase === 'LISTENING' && transcript ? 500 : 400, | |
| }}> | |
| {label} | |
| </div> | |
| {/* Transcript toggle */} | |
| {messages.length > 0 && ( | |
| <div style={{ marginTop: '16px', textAlign: 'center' }}> | |
| <button | |
| onClick={() => setShowTranscript(!showTranscript)} | |
| style={{ | |
| background: showTranscript ? '#f1f3f4' : 'transparent', | |
| border: '1px solid #e0e0e0', | |
| color: '#5f6368', | |
| padding: '6px 16px', | |
| borderRadius: '16px', | |
| fontFamily: 'inherit', | |
| fontSize: '12px', | |
| cursor: 'pointer', | |
| transition: 'all 0.2s', | |
| }} | |
| > | |
| {showTranscript ? 'Masquer la transcription' : 'Afficher la transcription'} | |
| </button> | |
| </div> | |
| )} | |
| {showTranscript && ( | |
| <div style={{ marginTop: '12px', width: '100%', maxWidth: '600px' }}> | |
| <ConversationOverlay messages={messages} isThinking={phase === 'THINKING'} /> | |
| </div> | |
| )} | |
| {/* Text input */} | |
| <form | |
| onSubmit={handleSubmit} | |
| style={{ | |
| position: 'fixed', | |
| bottom: '50px', | |
| left: '50%', | |
| transform: 'translateX(-50%)', | |
| width: '100%', | |
| maxWidth: '500px', | |
| display: 'flex', | |
| gap: '8px', | |
| padding: '0 20px', | |
| zIndex: 15, | |
| }} | |
| > | |
| <input | |
| type="text" | |
| value={inputText} | |
| onChange={(e) => setInputText(e.target.value)} | |
| placeholder="Ecris ta question ici..." | |
| style={{ | |
| flex: 1, | |
| background: '#f8f9fa', | |
| border: '1px solid #e0e0e0', | |
| borderRadius: '24px', | |
| padding: '12px 20px', | |
| color: '#1f1f1f', | |
| fontFamily: 'inherit', | |
| fontSize: '14px', | |
| outline: 'none', | |
| }} | |
| /> | |
| <button | |
| type="submit" | |
| disabled={!inputText.trim() || phase === 'THINKING'} | |
| style={{ | |
| background: inputText.trim() ? '#4285f4' : '#f1f3f4', | |
| color: inputText.trim() ? '#fff' : '#9aa0a6', | |
| border: 'none', | |
| borderRadius: '24px', | |
| padding: '12px 20px', | |
| fontFamily: 'inherit', | |
| fontSize: '13px', | |
| fontWeight: 500, | |
| cursor: !inputText.trim() ? 'not-allowed' : 'pointer', | |
| transition: 'all 0.2s', | |
| }} | |
| > | |
| Envoyer | |
| </button> | |
| </form> | |
| {/* Project cards drawer */} | |
| <ProjectCardsDrawer projects={PROJECTS} statuses={{}} /> | |
| </div> | |
| ); | |
| } | |