diff --git a/@applications/web/src/app/CompanionApp.tsx b/@applications/web/src/app/CompanionApp.tsx index e47493f..4d01a78 100644 --- a/@applications/web/src/app/CompanionApp.tsx +++ b/@applications/web/src/app/CompanionApp.tsx @@ -1,22 +1,11 @@ -import { useReducer, useCallback, useRef, useEffect, useState } from 'react'; import type { ReactElement } from 'react'; -import styled, { createGlobalStyle } from '@lilith/ui-styled-components'; -import type { SegmentEvent, TtsStartEvent, TtsEndEvent } from '@lilith/companion-client/types'; -import { ChatView } from '../features/chat/ChatView'; -import { MicButton } from '../features/chat/MicButton'; -import type { MicState } from '../features/chat/MicButton'; -import { TextInput } from '../features/chat/TextInput'; -import { MicCapture } from '../features/voice/MicCapture'; -import { PcmPlayer } from '../features/voice/PcmPlayer'; -import { VoiceSession } from '../features/voice/VoiceSession'; -import { chatReducer, initialChatState } from '../features/chat/types'; -import type { HistoryMessage } from '../features/chat/types'; -import { ToastProvider, useToast, Tooltip } from '@lilith/ui-feedback'; +import { createGlobalStyle } from '@lilith/ui-styled-components'; +import { ToastProvider } from '@lilith/ui-feedback'; import { ThemeProvider } from '@lilith/ui-theme'; -import { createSession } from '../features/errors/sessionRecovery'; -import { SettingsPanel } from '../features/settings/SettingsPanel'; -import { useSettings } from '../features/settings/useSettings'; -import { SessionHistoryPanel } from '../features/history/SessionHistoryPanel'; +import { BrowserRouter } from 'react-router-dom'; +import { AdminShell } from '@lilith/admin-shell'; +import type { NavSection } from '@lilith/admin-shell'; +import { AppRoutes } from './routes'; const GlobalStyle = createGlobalStyle` *, *::before, *::after { @@ -40,560 +29,49 @@ const GlobalStyle = createGlobalStyle` } `; -const AppContainer = styled.div` - display: flex; - flex-direction: column; - height: 100dvh; - width: 100%; - max-width: 640px; - margin: 0 auto; - background: #0d0d1a; - position: relative; -`; - -const Header = styled.header` - display: flex; - align-items: center; - padding: 12px 16px; - padding-top: max(12px, env(safe-area-inset-top)); - border-bottom: 1px solid #1a1a2e; - flex-shrink: 0; -`; - -const HeaderTitle = styled.h1` - font-size: 17px; - font-weight: 600; - color: #e2e8f0; - flex: 1; -`; - -type ConnectionState = 'connected' | 'disconnected' | 'error'; - -const CONNECTION_COLORS: Record = { - connected: '#68d391', - disconnected: '#4a5568', - error: '#f56565', -}; - -const ConnectionIndicator = styled.div` - display: flex; - align-items: center; - gap: 6px; - flex-shrink: 0; -`; - -const ConnectionDot = styled.span<{ $state: ConnectionState }>` - width: 8px; - height: 8px; - border-radius: 50%; - background: ${({ $state }) => CONNECTION_COLORS[$state]}; - flex-shrink: 0; -`; - -const ConnectionLabel = styled.span` - font-size: 11px; - color: #4a5568; -`; - -const InstallButton = styled.button` - background: none; - border: 1px solid #2d3748; - border-radius: 6px; - color: #a0aec0; - font-size: 11px; - padding: 3px 8px; - cursor: pointer; - margin-right: 8px; - flex-shrink: 0; - - &:active { - background: #1a1a2e; - } -`; - -const HeaderIconButton = styled.button` - background: none; - border: none; - color: #4a5568; - cursor: pointer; - padding: 4px; - margin-right: 8px; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - border-radius: 6px; - - &:active { - background: #1a1a2e; - color: #a0aec0; - } -`; - -const BottomBar = styled.div` - display: flex; - align-items: flex-end; - gap: 10px; - padding: 10px 16px; - padding-bottom: max(10px, env(safe-area-inset-bottom)); - border-top: 1px solid #1a1a2e; - flex-shrink: 0; -`; - -interface BeforeInstallPromptEvent extends Event { - prompt(): void; -} - -const API_BASE_URL: string = import.meta.env.VITE_API_URL ?? '/api'; -// Socket.IO base URL: in dev the Vite proxy handles /socket.io/* → API server, -// so '' (origin) is correct. In prod set VITE_SOCKET_URL to the API server origin. -const SOCKET_BASE_URL: string = import.meta.env.VITE_SOCKET_URL ?? ''; - -function CompanionAppInner(): ReactElement { - const sessionId = useRef(sessionStorage.getItem('companion_session_id') ?? ''); - const [chatState, dispatch] = useReducer(chatReducer, initialChatState); - const [micState, setMicState] = useState('idle'); - const [connectionState, setConnectionState] = useState('disconnected'); - const [installable, setInstallable] = useState(false); - const [settingsOpen, setSettingsOpen] = useState(false); - const [historyOpen, setHistoryOpen] = useState(false); - const [personaId, setPersonaId] = useState('miku'); - const { showToast } = useToast(); - const { setTtsEnabled, setTtsVolume, setSttEnabled, ...settings } = useSettings(); - - const audioContextRef = useRef(null); - const micCaptureRef = useRef(null); - const pcmPlayerRef = useRef(null); - const voiceSessionRef = useRef(null); - const activeAssistantIdRef = useRef(null); - const audioInitializedRef = useRef(false); - - // SW → client audio message (notification tap with audioUrl) - useEffect(() => { - if (!('serviceWorker' in navigator)) return; - const handler = (event: MessageEvent) => { - if ( - event.data !== null && - typeof event.data === 'object' && - 'type' in event.data && - (event.data as { type: unknown }).type === 'play-tts' && - 'url' in event.data && - typeof (event.data as { url: unknown }).url === 'string' - ) { - const audioUrl = (event.data as { url: string }).url; - const audio = new Audio(audioUrl); - audio.play().catch(() => {/* autoplay may be blocked — user will still see the notification */}); - } - }; - navigator.serviceWorker.addEventListener('message', handler); - return () => navigator.serviceWorker.removeEventListener('message', handler); - }, []); - - // PWA install prompt - const installPromptRef = useRef(null); - useEffect(() => { - const handler = (e: Event) => { - e.preventDefault(); - installPromptRef.current = e; - setInstallable(true); - }; - window.addEventListener('beforeinstallprompt', handler); - return () => window.removeEventListener('beforeinstallprompt', handler); - }, []); - - const handleInstall = useCallback(() => { - if (!installPromptRef.current) return; - (installPromptRef.current as BeforeInstallPromptEvent).prompt(); - installPromptRef.current = null; - setInstallable(false); - }, []); - - const connectVoiceSession = useCallback((sid: string) => { - const session = new VoiceSession(API_BASE_URL, SOCKET_BASE_URL, sid, { - onTranscript: (text) => { - const id = crypto.randomUUID(); - dispatch({ type: 'ADD_USER_MESSAGE', id, text }); - }, - - onSegment: (event: SegmentEvent) => { - if (!activeAssistantIdRef.current) { - const id = crypto.randomUUID(); - activeAssistantIdRef.current = id; - dispatch({ type: 'INIT_ASSISTANT_MESSAGE', id }); - } - dispatch({ - type: 'APPEND_SEGMENT', - id: activeAssistantIdRef.current, - partIndex: event.part_index, - text: event.text, - emotion: event.emotion, - }); - }, - - onTtsStart: (event: TtsStartEvent) => { - if (activeAssistantIdRef.current) { - dispatch({ - type: 'SET_SPEAKING', - id: activeAssistantIdRef.current, - partIndex: event.part_index, - }); - } - }, - - onTtsEnd: (event: TtsEndEvent) => { - if (activeAssistantIdRef.current) { - dispatch({ type: 'CLEAR_SPEAKING', id: activeAssistantIdRef.current }); - } - dispatch({ type: 'CLEAR_ACTIVE_ASSISTANT' }); - setMicState('idle'); - }, - - onListening: () => { - setMicState('listening'); - activeAssistantIdRef.current = null; - }, - - onStateChange: (state) => { - setConnectionState(state === 'connecting' ? 'disconnected' : state); - if (state === 'error') { - setMicState('idle'); - } - }, - - onError: (message) => { - showToast(message, 'error'); - }, - }); - - voiceSessionRef.current = session; - session.connect(); - }, [showToast]); - - // Initialize session on mount, then connect VoiceSession - useEffect(() => { - let cancelled = false; - - async function init(): Promise { - try { - let sid = sessionStorage.getItem('companion_session_id'); - - if (sid) { - // Validate the session still exists — 404 means it was cleared (server restart, etc.) - const histRes = await fetch(`${API_BASE_URL}/session/${sid}/history`); - if (histRes.ok) { - const messages = (await histRes.json()) as HistoryMessage[]; - if (!cancelled && messages.length > 0) { - dispatch({ type: 'LOAD_HISTORY', messages }); - } - } else if (histRes.status === 404) { - // Session expired — discard and create fresh - sessionStorage.removeItem('companion_session_id'); - sid = null; - } - // Other non-404 errors (network issues, 5xx): keep the session ID and proceed - } - - if (sid) { - // Fetch session details to get current persona - try { - const detailRes = await fetch(`${API_BASE_URL}/session/${sid}`); - if (detailRes.ok) { - const details = (await detailRes.json()) as { persona_id: string }; - if (!cancelled) setPersonaId(details.persona_id); - } - } catch { - // Non-fatal — persona display falls back to state default - } - } - - if (!sid) { - const newSessionId = await createSession(API_BASE_URL); - if (cancelled) return; - sid = newSessionId; - } - - if (cancelled) return; - sessionId.current = sid; - connectVoiceSession(sid); - } catch (err) { - const msg = err instanceof Error ? err.message : 'Unknown error'; - showToast(`Could not connect: ${msg}`, 'error'); - } - } - - void init(); - - return () => { - cancelled = true; - voiceSessionRef.current?.disconnect(); - micCaptureRef.current?.dispose(); - pcmPlayerRef.current?.dispose(); - }; - }, [connectVoiceSession, showToast]); - - // Apply TTS volume whenever it changes (0 when disabled) - useEffect(() => { - const volume = settings.ttsEnabled ? settings.ttsVolume : 0; - pcmPlayerRef.current?.setVolume(volume); - }, [settings.ttsEnabled, settings.ttsVolume]); - - /** - * Initialize AudioContext and audio pipeline on first user gesture. - * Must run in a tap/click handler to satisfy browser audio autoplay policy. - */ - const initAudio = useCallback(async () => { - if (audioInitializedRef.current) return; - audioInitializedRef.current = true; - - const ctx = new AudioContext(); - audioContextRef.current = ctx; - - const player = new PcmPlayer({ - onLockScreenPause: () => { - micCaptureRef.current?.stop(); - setMicState('idle'); - }, - onLockScreenPlay: () => {}, - }); - await player.init(ctx); - pcmPlayerRef.current = player; - - // Mic is optional — TTS playback works without microphone permission - let mic: MicCapture | null = null; - try { - mic = new MicCapture((frame: ArrayBuffer) => { - voiceSessionRef.current?.sendAudioFrame(frame); - }); - await mic.init(ctx); - micCaptureRef.current = mic; - } catch { - // Mic permission denied — voice input disabled, TTS playback unaffected - } - - voiceSessionRef.current?.attachAudio(mic, player); - }, []); - - const handleMicPressStart = useCallback(async () => { - try { - await initAudio(); - } catch (err) { - if (err instanceof DOMException && err.name === 'NotAllowedError') { - showToast('Microphone access denied. Enable it in browser settings.', 'error'); - } else { - showToast('Could not start microphone.', 'error'); - } - setMicState('idle'); - return; - } - - activeAssistantIdRef.current = null; - setMicState('listening'); - - micCaptureRef.current?.start(); - }, [initAudio, showToast]); - - const handleMicPressEnd = useCallback(() => { - micCaptureRef.current?.stop(); - setMicState('processing'); - }, []); - - const handleTextTranscript = useCallback((text: string) => { - const id = crypto.randomUUID(); - dispatch({ type: 'ADD_USER_MESSAGE', id, text }); - activeAssistantIdRef.current = null; - // Init assistant message slot — will be filled by onSegment - const assistantId = crypto.randomUUID(); - activeAssistantIdRef.current = assistantId; - dispatch({ type: 'INIT_ASSISTANT_MESSAGE', id: assistantId }); - }, []); - - const handleTextSegment = useCallback((event: SegmentEvent) => { - if (!activeAssistantIdRef.current) return; - dispatch({ - type: 'APPEND_SEGMENT', - id: activeAssistantIdRef.current, - partIndex: event.part_index, - text: event.text, - emotion: event.emotion, - }); - }, []); - - const handleTextComplete = useCallback(() => { - dispatch({ type: 'CLEAR_ACTIVE_ASSISTANT' }); - }, []); - - const handleSwitchSession = useCallback((targetSessionId: string, newPersonaId?: string) => { - // Disconnect current voice session - voiceSessionRef.current?.disconnect(); - activeAssistantIdRef.current = null; - - const switchTo = async (sid: string) => { - dispatch({ type: 'LOAD_HISTORY', messages: [] }); - sessionId.current = sid; - sessionStorage.setItem('companion_session_id', sid); - - try { - const detailRes = await fetch(`${API_BASE_URL}/session/${sid}`); - if (detailRes.ok) { - const details = (await detailRes.json()) as { persona_id: string }; - setPersonaId(details.persona_id); - } - } catch { - // Non-fatal - } - - try { - const res = await fetch(`${API_BASE_URL}/session/${sid}/history`); - if (res.ok) { - const messages = (await res.json()) as HistoryMessage[]; - if (messages.length > 0) dispatch({ type: 'LOAD_HISTORY', messages }); - } - } catch { - // Non-fatal - } - - connectVoiceSession(sid); - }; - - if (targetSessionId === 'new') { - createSession(API_BASE_URL, newPersonaId) - .then((newId) => { - if (newPersonaId) setPersonaId(newPersonaId); - return switchTo(newId); - }) - .catch((err: unknown) => { - const msg = err instanceof Error ? err.message : 'Unknown error'; - showToast(`Could not create session: ${msg}`, 'error'); - }); - } else { - void switchTo(targetSessionId); - } - }, [connectVoiceSession, showToast]); - - const handleChatError = useCallback((message: string) => { - // Remove the stuck typing indicator - if (activeAssistantIdRef.current) { - dispatch({ type: 'REMOVE_MESSAGE', id: activeAssistantIdRef.current }); - activeAssistantIdRef.current = null; - } - // Show inline error in chat - dispatch({ - type: 'ADD_SYSTEM_MESSAGE', - id: crypto.randomUUID(), - text: message, - error: true, - }); - }, []); - - return ( - <> - - -
- Companion - {installable && ( - - Install - - )} - - setHistoryOpen(true)} - aria-label="Open conversation history" - > - - - - - - - setSettingsOpen(true)} - aria-label="Open settings" - > - - - - - - - - {connectionState !== 'connected' && ( - - {connectionState === 'error' ? 'Error' : 'Disconnected'} - - )} - - - -
- - - - - - {settings.sttEnabled && ( - - { void handleMicPressStart(); }} - onPressEnd={handleMicPressEnd} - /> - - )} - -
- - setSettingsOpen(false)} - sessionId={sessionId.current} - connectionState={connectionState} - settings={settings} - onSettings={{ setTtsEnabled, setTtsVolume, setSttEnabled }} - apiBaseUrl={API_BASE_URL} - currentPersonaId={personaId} - onPersonaChange={(id) => handleSwitchSession('new', id)} - /> - - setHistoryOpen(false)} - currentSessionId={sessionId.current} - apiBaseUrl={API_BASE_URL} - onSwitchSession={handleSwitchSession} - /> - - ); -} +const NAVIGATION: NavSection[] = [ + { + title: 'Core', + items: [ + { to: '/', label: 'Chat' }, + { to: '/personas', label: 'Personas' }, + { to: '/personality', label: 'Personality' }, + ], + }, + { + title: 'Automation', + items: [ + { to: '/nag', label: 'Nag' }, + { to: '/process', label: 'Process' }, + { to: '/status', label: 'Status' }, + ], + }, + { + title: 'Roadmap', + items: [ + { to: '/memory', label: 'Memory' }, + { to: '/skills', label: 'Skills' }, + { to: '/routines', label: 'Routines' }, + { to: '/nudges', label: 'Nudges' }, + { to: '/settings', label: 'Settings' }, + ], + }, +]; export function CompanionApp(): ReactElement { return ( - + + + + + + ); diff --git a/@applications/web/src/app/routes.tsx b/@applications/web/src/app/routes.tsx new file mode 100644 index 0000000..1c5bd12 --- /dev/null +++ b/@applications/web/src/app/routes.tsx @@ -0,0 +1,33 @@ +import { lazy, Suspense } from 'react'; +import type { ReactElement } from 'react'; +import { Routes, Route } from 'react-router-dom'; +import { StubPage } from '../features/stubs/StubPage'; + +const ChatPage = lazy(() => import('../features/chat/ChatPage').then((m) => ({ default: m.ChatPage }))); +const PersonasPage = lazy(() => import('../features/personas/PersonasPage').then((m) => ({ default: m.PersonasPage }))); +const PersonaDetailPage = lazy(() => import('../features/personas/PersonaDetailPage').then((m) => ({ default: m.PersonaDetailPage }))); +const PersonalityPage = lazy(() => import('../features/personality/PersonalityPage').then((m) => ({ default: m.PersonalityPage }))); +const NagPage = lazy(() => import('../features/nag/NagPage').then((m) => ({ default: m.NagPage }))); +const ProcessPage = lazy(() => import('../features/process/ProcessPage').then((m) => ({ default: m.ProcessPage }))); +const StatusPage = lazy(() => import('../features/status/StatusPage').then((m) => ({ default: m.StatusPage }))); + +export function AppRoutes(): ReactElement { + return ( + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ); +} diff --git a/@applications/web/src/features/chat/ChatPage.tsx b/@applications/web/src/features/chat/ChatPage.tsx new file mode 100644 index 0000000..b8786e2 --- /dev/null +++ b/@applications/web/src/features/chat/ChatPage.tsx @@ -0,0 +1,542 @@ +import { useReducer, useCallback, useRef, useEffect, useState } from 'react'; +import type { ReactElement } from 'react'; +import styled from '@lilith/ui-styled-components'; +import type { SegmentEvent, TtsStartEvent, TtsEndEvent } from '@lilith/companion-client/types'; +import { ChatView } from './ChatView'; +import { MicButton } from './MicButton'; +import type { MicState } from './MicButton'; +import { TextInput } from './TextInput'; +import { MicCapture } from '../voice/MicCapture'; +import { PcmPlayer } from '../voice/PcmPlayer'; +import { VoiceSession } from '../voice/VoiceSession'; +import { chatReducer, initialChatState } from './types'; +import type { HistoryMessage } from './types'; +import { useToast, Tooltip } from '@lilith/ui-feedback'; +import { createSession } from '../errors/sessionRecovery'; +import { SettingsPanel } from '../settings/SettingsPanel'; +import { useSettings } from '../settings/useSettings'; +import { SessionHistoryPanel } from '../history/SessionHistoryPanel'; +import { API_BASE_URL, SOCKET_BASE_URL } from '../../lib/constants'; + +type ConnectionState = 'connected' | 'disconnected' | 'error'; + +const CONNECTION_COLORS: Record = { + connected: '#68d391', + disconnected: '#4a5568', + error: '#f56565', +}; + +const ChatContainer = styled.div` + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + max-width: 640px; + margin: 0 auto; + position: relative; +`; + +const Header = styled.header` + display: flex; + align-items: center; + padding: 12px 16px; + border-bottom: 1px solid #1a1a2e; + flex-shrink: 0; +`; + +const HeaderTitle = styled.h1` + font-size: 17px; + font-weight: 600; + color: #e2e8f0; + flex: 1; +`; + +const ConnectionIndicator = styled.div` + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +`; + +const ConnectionDot = styled.span<{ $state: ConnectionState }>` + width: 8px; + height: 8px; + border-radius: 50%; + background: ${({ $state }) => CONNECTION_COLORS[$state]}; + flex-shrink: 0; +`; + +const ConnectionLabel = styled.span` + font-size: 11px; + color: #4a5568; +`; + +const InstallButton = styled.button` + background: none; + border: 1px solid #2d3748; + border-radius: 6px; + color: #a0aec0; + font-size: 11px; + padding: 3px 8px; + cursor: pointer; + margin-right: 8px; + flex-shrink: 0; + + &:active { + background: #1a1a2e; + } +`; + +const HeaderIconButton = styled.button` + background: none; + border: none; + color: #4a5568; + cursor: pointer; + padding: 4px; + margin-right: 8px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + border-radius: 6px; + + &:active { + background: #1a1a2e; + color: #a0aec0; + } +`; + +const BottomBar = styled.div` + display: flex; + align-items: flex-end; + gap: 10px; + padding: 10px 16px; + padding-bottom: max(10px, env(safe-area-inset-bottom)); + border-top: 1px solid #1a1a2e; + flex-shrink: 0; +`; + +interface BeforeInstallPromptEvent extends Event { + prompt(): void; +} + +export function ChatPage(): ReactElement { + const sessionId = useRef(sessionStorage.getItem('companion_session_id') ?? ''); + const [chatState, dispatch] = useReducer(chatReducer, initialChatState); + const [micState, setMicState] = useState('idle'); + const [connectionState, setConnectionState] = useState('disconnected'); + const [installable, setInstallable] = useState(false); + const [settingsOpen, setSettingsOpen] = useState(false); + const [historyOpen, setHistoryOpen] = useState(false); + const [personaId, setPersonaId] = useState('miku'); + const { showToast } = useToast(); + const { setTtsEnabled, setTtsVolume, setSttEnabled, ...settings } = useSettings(); + + const audioContextRef = useRef(null); + const micCaptureRef = useRef(null); + const pcmPlayerRef = useRef(null); + const voiceSessionRef = useRef(null); + const activeAssistantIdRef = useRef(null); + const audioInitializedRef = useRef(false); + + useEffect(() => { + if (!('serviceWorker' in navigator)) return; + const handler = (event: MessageEvent): void => { + if ( + event.data !== null && + typeof event.data === 'object' && + 'type' in event.data && + (event.data as { type: unknown }).type === 'play-tts' && + 'url' in event.data && + typeof (event.data as { url: unknown }).url === 'string' + ) { + const audioUrl = (event.data as { url: string }).url; + const audio = new Audio(audioUrl); + audio.play().catch(() => {/* autoplay may be blocked */}); + } + }; + navigator.serviceWorker.addEventListener('message', handler); + return () => navigator.serviceWorker.removeEventListener('message', handler); + }, []); + + const installPromptRef = useRef(null); + useEffect(() => { + const handler = (e: Event): void => { + e.preventDefault(); + installPromptRef.current = e; + setInstallable(true); + }; + window.addEventListener('beforeinstallprompt', handler); + return () => window.removeEventListener('beforeinstallprompt', handler); + }, []); + + const handleInstall = useCallback((): void => { + if (!installPromptRef.current) return; + (installPromptRef.current as BeforeInstallPromptEvent).prompt(); + installPromptRef.current = null; + setInstallable(false); + }, []); + + const connectVoiceSession = useCallback((sid: string): void => { + const session = new VoiceSession(API_BASE_URL, SOCKET_BASE_URL, sid, { + onTranscript: (text) => { + const id = crypto.randomUUID(); + dispatch({ type: 'ADD_USER_MESSAGE', id, text }); + }, + + onSegment: (event: SegmentEvent) => { + if (!activeAssistantIdRef.current) { + const id = crypto.randomUUID(); + activeAssistantIdRef.current = id; + dispatch({ type: 'INIT_ASSISTANT_MESSAGE', id }); + } + dispatch({ + type: 'APPEND_SEGMENT', + id: activeAssistantIdRef.current, + partIndex: event.part_index, + text: event.text, + emotion: event.emotion, + }); + }, + + onTtsStart: (event: TtsStartEvent) => { + if (activeAssistantIdRef.current) { + dispatch({ + type: 'SET_SPEAKING', + id: activeAssistantIdRef.current, + partIndex: event.part_index, + }); + } + }, + + onTtsEnd: (_event: TtsEndEvent) => { + if (activeAssistantIdRef.current) { + dispatch({ type: 'CLEAR_SPEAKING', id: activeAssistantIdRef.current }); + } + dispatch({ type: 'CLEAR_ACTIVE_ASSISTANT' }); + setMicState('idle'); + }, + + onListening: () => { + setMicState('listening'); + activeAssistantIdRef.current = null; + }, + + onStateChange: (state) => { + setConnectionState(state === 'connecting' ? 'disconnected' : state); + if (state === 'error') { + setMicState('idle'); + } + }, + + onError: (message) => { + showToast(message, 'error'); + }, + }); + + voiceSessionRef.current = session; + session.connect(); + }, [showToast]); + + useEffect(() => { + let cancelled = false; + + async function init(): Promise { + try { + let sid = sessionStorage.getItem('companion_session_id'); + + if (sid) { + const histRes = await fetch(`${API_BASE_URL}/session/${sid}/history`); + if (histRes.ok) { + const messages = (await histRes.json()) as HistoryMessage[]; + if (!cancelled && messages.length > 0) { + dispatch({ type: 'LOAD_HISTORY', messages }); + } + } else if (histRes.status === 404) { + sessionStorage.removeItem('companion_session_id'); + sid = null; + } + } + + if (sid) { + try { + const detailRes = await fetch(`${API_BASE_URL}/session/${sid}`); + if (detailRes.ok) { + const details = (await detailRes.json()) as { persona_id: string }; + if (!cancelled) setPersonaId(details.persona_id); + } + } catch { + // Non-fatal + } + } + + if (!sid) { + const newSessionId = await createSession(API_BASE_URL); + if (cancelled) return; + sid = newSessionId; + } + + if (cancelled) return; + sessionId.current = sid; + connectVoiceSession(sid); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Unknown error'; + showToast(`Could not connect: ${msg}`, 'error'); + } + } + + void init(); + + return () => { + cancelled = true; + voiceSessionRef.current?.disconnect(); + micCaptureRef.current?.dispose(); + pcmPlayerRef.current?.dispose(); + }; + }, [connectVoiceSession, showToast]); + + useEffect(() => { + const volume = settings.ttsEnabled ? settings.ttsVolume : 0; + pcmPlayerRef.current?.setVolume(volume); + }, [settings.ttsEnabled, settings.ttsVolume]); + + const initAudio = useCallback(async (): Promise => { + if (audioInitializedRef.current) return; + audioInitializedRef.current = true; + + const ctx = new AudioContext(); + audioContextRef.current = ctx; + + const player = new PcmPlayer({ + onLockScreenPause: () => { + micCaptureRef.current?.stop(); + setMicState('idle'); + }, + onLockScreenPlay: () => {}, + }); + await player.init(ctx); + pcmPlayerRef.current = player; + + let mic: MicCapture | null = null; + try { + mic = new MicCapture((frame: ArrayBuffer) => { + voiceSessionRef.current?.sendAudioFrame(frame); + }); + await mic.init(ctx); + micCaptureRef.current = mic; + } catch { + // Mic permission denied + } + + voiceSessionRef.current?.attachAudio(mic, player); + }, []); + + const handleMicPressStart = useCallback(async (): Promise => { + try { + await initAudio(); + } catch (err) { + if (err instanceof DOMException && err.name === 'NotAllowedError') { + showToast('Microphone access denied. Enable it in browser settings.', 'error'); + } else { + showToast('Could not start microphone.', 'error'); + } + setMicState('idle'); + return; + } + + activeAssistantIdRef.current = null; + setMicState('listening'); + micCaptureRef.current?.start(); + }, [initAudio, showToast]); + + const handleMicPressEnd = useCallback((): void => { + micCaptureRef.current?.stop(); + setMicState('processing'); + }, []); + + const handleTextTranscript = useCallback((text: string): void => { + const id = crypto.randomUUID(); + dispatch({ type: 'ADD_USER_MESSAGE', id, text }); + activeAssistantIdRef.current = null; + const assistantId = crypto.randomUUID(); + activeAssistantIdRef.current = assistantId; + dispatch({ type: 'INIT_ASSISTANT_MESSAGE', id: assistantId }); + }, []); + + const handleTextSegment = useCallback((event: SegmentEvent): void => { + if (!activeAssistantIdRef.current) return; + dispatch({ + type: 'APPEND_SEGMENT', + id: activeAssistantIdRef.current, + partIndex: event.part_index, + text: event.text, + emotion: event.emotion, + }); + }, []); + + const handleTextComplete = useCallback((): void => { + dispatch({ type: 'CLEAR_ACTIVE_ASSISTANT' }); + }, []); + + const handleSwitchSession = useCallback((targetSessionId: string, newPersonaId?: string): void => { + voiceSessionRef.current?.disconnect(); + activeAssistantIdRef.current = null; + + const switchTo = async (sid: string): Promise => { + dispatch({ type: 'LOAD_HISTORY', messages: [] }); + sessionId.current = sid; + sessionStorage.setItem('companion_session_id', sid); + + try { + const detailRes = await fetch(`${API_BASE_URL}/session/${sid}`); + if (detailRes.ok) { + const details = (await detailRes.json()) as { persona_id: string }; + setPersonaId(details.persona_id); + } + } catch { + // Non-fatal + } + + try { + const res = await fetch(`${API_BASE_URL}/session/${sid}/history`); + if (res.ok) { + const messages = (await res.json()) as HistoryMessage[]; + if (messages.length > 0) dispatch({ type: 'LOAD_HISTORY', messages }); + } + } catch { + // Non-fatal + } + + connectVoiceSession(sid); + }; + + if (targetSessionId === 'new') { + createSession(API_BASE_URL, newPersonaId) + .then((newId) => { + if (newPersonaId) setPersonaId(newPersonaId); + return switchTo(newId); + }) + .catch((err: unknown) => { + const msg = err instanceof Error ? err.message : 'Unknown error'; + showToast(`Could not create session: ${msg}`, 'error'); + }); + } else { + void switchTo(targetSessionId); + } + }, [connectVoiceSession, showToast]); + + const handleChatError = useCallback((message: string): void => { + if (activeAssistantIdRef.current) { + dispatch({ type: 'REMOVE_MESSAGE', id: activeAssistantIdRef.current }); + activeAssistantIdRef.current = null; + } + dispatch({ + type: 'ADD_SYSTEM_MESSAGE', + id: crypto.randomUUID(), + text: message, + error: true, + }); + }, []); + + return ( + <> + +
+ Companion + {installable && ( + + Install + + )} + + setHistoryOpen(true)} + aria-label="Open conversation history" + > + + + + + + + setSettingsOpen(true)} + aria-label="Open settings" + > + + + + + + + + {connectionState !== 'connected' && ( + + {connectionState === 'error' ? 'Error' : 'Disconnected'} + + )} + + + +
+ + + + + + {settings.sttEnabled && ( + + { void handleMicPressStart(); }} + onPressEnd={handleMicPressEnd} + /> + + )} + +
+ + setSettingsOpen(false)} + sessionId={sessionId.current} + connectionState={connectionState} + settings={settings} + onSettings={{ setTtsEnabled, setTtsVolume, setSttEnabled }} + apiBaseUrl={API_BASE_URL} + currentPersonaId={personaId} + onPersonaChange={(id) => handleSwitchSession('new', id)} + /> + + setHistoryOpen(false)} + currentSessionId={sessionId.current} + apiBaseUrl={API_BASE_URL} + onSwitchSession={handleSwitchSession} + /> + + ); +}