refactor(chat): ♻️ Restructure ChatPage exports and CompanionApp imports to simplify imports and reduce complexity in chat-related routes

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-04-09 23:13:33 -07:00
parent 072d148206
commit f1ccf62900
3 changed files with 619 additions and 566 deletions

View file

@ -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<ConnectionState, string> = {
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<MicState>('idle');
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected');
const [installable, setInstallable] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false);
const [personaId, setPersonaId] = useState<string>('miku');
const { showToast } = useToast();
const { setTtsEnabled, setTtsVolume, setSttEnabled, ...settings } = useSettings();
const audioContextRef = useRef<AudioContext | null>(null);
const micCaptureRef = useRef<MicCapture | null>(null);
const pcmPlayerRef = useRef<PcmPlayer | null>(null);
const voiceSessionRef = useRef<VoiceSession | null>(null);
const activeAssistantIdRef = useRef<string | null>(null);
const audioInitializedRef = useRef(false);
// SW → client audio message (notification tap with audioUrl)
useEffect(() => {
if (!('serviceWorker' in navigator)) return;
const handler = (event: MessageEvent<unknown>) => {
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<Event | null>(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<void> {
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 (
<>
<GlobalStyle />
<AppContainer>
<Header>
<HeaderTitle>Companion</HeaderTitle>
{installable && (
<Tooltip content="Install as app" position="bottom">
<InstallButton onClick={handleInstall}>Install</InstallButton>
</Tooltip>
)}
<Tooltip content="Conversations" position="bottom">
<HeaderIconButton
onClick={() => setHistoryOpen(true)}
aria-label="Open conversation history"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H6l-2 2V4h16v12z" />
</svg>
</HeaderIconButton>
</Tooltip>
<Tooltip content="Settings" position="bottom">
<HeaderIconButton
onClick={() => setSettingsOpen(true)}
aria-label="Open settings"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<path d="M12 15.5A3.5 3.5 0 0 1 8.5 12 3.5 3.5 0 0 1 12 8.5a3.5 3.5 0 0 1 3.5 3.5 3.5 3.5 0 0 1-3.5 3.5m7.43-2.92c.04-.34.07-.68.07-1.08s-.03-.74-.07-1.08l2.33-1.82a.55.55 0 0 0 .13-.71l-2.21-3.82a.55.55 0 0 0-.67-.24l-2.75 1.1a8.07 8.07 0 0 0-1.87-1.09l-.42-2.92A.55.55 0 0 0 14 2h-4a.55.55 0 0 0-.54.46l-.42 2.92a8.07 8.07 0 0 0-1.87 1.09L4.43 5.37a.55.55 0 0 0-.67.24L1.55 9.43a.54.54 0 0 0 .13.71l2.33 1.82c-.04.34-.07.69-.07 1.08s.03.74.07 1.08l-2.33 1.82a.55.55 0 0 0-.13.71l2.21 3.82c.14.24.41.32.67.24l2.75-1.1c.58.42 1.21.78 1.87 1.09l.42 2.92c.05.29.3.46.54.46h4c.28 0 .5-.17.54-.46l.42-2.92a8.07 8.07 0 0 0 1.87-1.09l2.75 1.1a.55.55 0 0 0 .67-.24l2.21-3.82a.55.55 0 0 0-.13-.71z" />
</svg>
</HeaderIconButton>
</Tooltip>
<Tooltip
content={connectionState === 'connected' ? 'Connected' : connectionState === 'error' ? 'Connection error' : 'Disconnected'}
position="bottom"
>
<ConnectionIndicator>
{connectionState !== 'connected' && (
<ConnectionLabel>
{connectionState === 'error' ? 'Error' : 'Disconnected'}
</ConnectionLabel>
)}
<ConnectionDot $state={connectionState} />
</ConnectionIndicator>
</Tooltip>
</Header>
<ChatView
messages={chatState.messages}
activeAssistantId={chatState.activeAssistantId}
/>
<BottomBar>
<TextInput
sessionId={sessionId}
apiBaseUrl={API_BASE_URL}
onTranscript={handleTextTranscript}
onSegment={handleTextSegment}
onError={handleChatError}
onComplete={handleTextComplete}
onWillSend={initAudio}
disabled={micState === 'listening'}
/>
{settings.sttEnabled && (
<Tooltip
content={
micState === 'listening' ? 'Release to send' :
micState === 'processing' ? 'Processing…' :
'Hold to speak'
}
position="top"
>
<MicButton
state={micState}
onPressStart={() => { void handleMicPressStart(); }}
onPressEnd={handleMicPressEnd}
/>
</Tooltip>
)}
</BottomBar>
</AppContainer>
<SettingsPanel
open={settingsOpen}
onClose={() => setSettingsOpen(false)}
sessionId={sessionId.current}
connectionState={connectionState}
settings={settings}
onSettings={{ setTtsEnabled, setTtsVolume, setSttEnabled }}
apiBaseUrl={API_BASE_URL}
currentPersonaId={personaId}
onPersonaChange={(id) => handleSwitchSession('new', id)}
/>
<SessionHistoryPanel
open={historyOpen}
onClose={() => 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 (
<ThemeProvider defaultTheme="cyberpunk">
<ToastProvider>
<CompanionAppInner />
<GlobalStyle />
<BrowserRouter>
<AdminShell
logo={{ title: 'Companion', subtitle: 'Developer Dashboard' }}
navigation={NAVIGATION}
footerText="Companion v1.0"
>
<AppRoutes />
</AdminShell>
</BrowserRouter>
</ToastProvider>
</ThemeProvider>
);

View file

@ -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 (
<Suspense fallback={null}>
<Routes>
<Route path="/" element={<ChatPage />} />
<Route path="/personas" element={<PersonasPage />} />
<Route path="/personas/:id" element={<PersonaDetailPage />} />
<Route path="/personality" element={<PersonalityPage />} />
<Route path="/nag" element={<NagPage />} />
<Route path="/process" element={<ProcessPage />} />
<Route path="/status" element={<StatusPage />} />
<Route path="/memory" element={<StubPage title="Memory" version="v1.1" description="Semantic memory storage, fact extraction, and contextual recall for persistent personality." />} />
<Route path="/skills" element={<StubPage title="Skills" version="future" description="Skill catalog for managing AI capabilities — enable, disable, and configure domain-specific tools." />} />
<Route path="/routines" element={<StubPage title="Routines" version="future" description="Scheduled routine management — morning check-ins, evening digests, and custom recurring flows." />} />
<Route path="/nudges" element={<StubPage title="Nudges" version="future" description="Gentle recurring pings — configurable speed, escalation, and context-aware prompting." />} />
<Route path="/settings" element={<StubPage title="Settings" version="future" description="Companion preferences, voice configuration, persona selection, and notification settings." />} />
</Routes>
</Suspense>
);
}

View file

@ -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<ConnectionState, string> = {
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<MicState>('idle');
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected');
const [installable, setInstallable] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false);
const [personaId, setPersonaId] = useState<string>('miku');
const { showToast } = useToast();
const { setTtsEnabled, setTtsVolume, setSttEnabled, ...settings } = useSettings();
const audioContextRef = useRef<AudioContext | null>(null);
const micCaptureRef = useRef<MicCapture | null>(null);
const pcmPlayerRef = useRef<PcmPlayer | null>(null);
const voiceSessionRef = useRef<VoiceSession | null>(null);
const activeAssistantIdRef = useRef<string | null>(null);
const audioInitializedRef = useRef(false);
useEffect(() => {
if (!('serviceWorker' in navigator)) return;
const handler = (event: MessageEvent<unknown>): 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<Event | null>(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<void> {
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<void> => {
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<void> => {
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<void> => {
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 (
<>
<ChatContainer>
<Header>
<HeaderTitle>Companion</HeaderTitle>
{installable && (
<Tooltip content="Install as app" position="bottom">
<InstallButton onClick={handleInstall}>Install</InstallButton>
</Tooltip>
)}
<Tooltip content="Conversations" position="bottom">
<HeaderIconButton
onClick={() => setHistoryOpen(true)}
aria-label="Open conversation history"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H6l-2 2V4h16v12z" />
</svg>
</HeaderIconButton>
</Tooltip>
<Tooltip content="Settings" position="bottom">
<HeaderIconButton
onClick={() => setSettingsOpen(true)}
aria-label="Open settings"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<path d="M12 15.5A3.5 3.5 0 0 1 8.5 12 3.5 3.5 0 0 1 12 8.5a3.5 3.5 0 0 1 3.5 3.5 3.5 3.5 0 0 1-3.5 3.5m7.43-2.92c.04-.34.07-.68.07-1.08s-.03-.74-.07-1.08l2.33-1.82a.55.55 0 0 0 .13-.71l-2.21-3.82a.55.55 0 0 0-.67-.24l-2.75 1.1a8.07 8.07 0 0 0-1.87-1.09l-.42-2.92A.55.55 0 0 0 14 2h-4a.55.55 0 0 0-.54.46l-.42 2.92a8.07 8.07 0 0 0-1.87 1.09L4.43 5.37a.55.55 0 0 0-.67.24L1.55 9.43a.54.54 0 0 0 .13.71l2.33 1.82c-.04.34-.07.69-.07 1.08s.03.74.07 1.08l-2.33 1.82a.55.55 0 0 0-.13.71l2.21 3.82c.14.24.41.32.67.24l2.75-1.1c.58.42 1.21.78 1.87 1.09l.42 2.92c.05.29.3.46.54.46h4c.28 0 .5-.17.54-.46l.42-2.92a8.07 8.07 0 0 0 1.87-1.09l2.75 1.1a.55.55 0 0 0 .67-.24l2.21-3.82a.55.55 0 0 0-.13-.71z" />
</svg>
</HeaderIconButton>
</Tooltip>
<Tooltip
content={connectionState === 'connected' ? 'Connected' : connectionState === 'error' ? 'Connection error' : 'Disconnected'}
position="bottom"
>
<ConnectionIndicator>
{connectionState !== 'connected' && (
<ConnectionLabel>
{connectionState === 'error' ? 'Error' : 'Disconnected'}
</ConnectionLabel>
)}
<ConnectionDot $state={connectionState} />
</ConnectionIndicator>
</Tooltip>
</Header>
<ChatView
messages={chatState.messages}
activeAssistantId={chatState.activeAssistantId}
/>
<BottomBar>
<TextInput
sessionId={sessionId}
apiBaseUrl={API_BASE_URL}
onTranscript={handleTextTranscript}
onSegment={handleTextSegment}
onError={handleChatError}
onComplete={handleTextComplete}
onWillSend={initAudio}
disabled={micState === 'listening'}
/>
{settings.sttEnabled && (
<Tooltip
content={
micState === 'listening' ? 'Release to send' :
micState === 'processing' ? 'Processing...' :
'Hold to speak'
}
position="top"
>
<MicButton
state={micState}
onPressStart={() => { void handleMicPressStart(); }}
onPressEnd={handleMicPressEnd}
/>
</Tooltip>
)}
</BottomBar>
</ChatContainer>
<SettingsPanel
open={settingsOpen}
onClose={() => setSettingsOpen(false)}
sessionId={sessionId.current}
connectionState={connectionState}
settings={settings}
onSettings={{ setTtsEnabled, setTtsVolume, setSttEnabled }}
apiBaseUrl={API_BASE_URL}
currentPersonaId={personaId}
onPersonaChange={(id) => handleSwitchSession('new', id)}
/>
<SessionHistoryPanel
open={historyOpen}
onClose={() => setHistoryOpen(false)}
currentSessionId={sessionId.current}
apiBaseUrl={API_BASE_URL}
onSwitchSession={handleSwitchSession}
/>
</>
);
}