feat(companion-app): ✨ Update core CompanionApp component with new functionality, improved UI/UX, or restructured logic
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
073f740fc2
commit
c98a5fc54f
1 changed files with 289 additions and 34 deletions
|
|
@ -10,6 +10,13 @@ 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 { 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';
|
||||
|
||||
const GlobalStyle = createGlobalStyle`
|
||||
*, *::before, *::after {
|
||||
|
|
@ -60,14 +67,69 @@ const HeaderTitle = styled.h1`
|
|||
flex: 1;
|
||||
`;
|
||||
|
||||
const ConnectionDot = styled.span<{ $connected: boolean }>`
|
||||
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: ${({ $connected }) => ($connected ? '#68d391' : '#4a5568')};
|
||||
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;
|
||||
|
|
@ -78,14 +140,25 @@ const BottomBar = styled.div`
|
|||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const API_BASE_URL: string =
|
||||
import.meta.env.VITE_API_URL ?? 'http://localhost:3850';
|
||||
interface BeforeInstallPromptEvent extends Event {
|
||||
prompt(): void;
|
||||
}
|
||||
|
||||
export function CompanionApp(): ReactElement {
|
||||
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 [wsConnected, setWsConnected] = useState(false);
|
||||
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected');
|
||||
const [installable, setInstallable] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const { showToast } = useToast();
|
||||
const { setTtsEnabled, setTtsVolume, setSttEnabled, ...settings } = useSettings();
|
||||
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
const micCaptureRef = useRef<MicCapture | null>(null);
|
||||
|
|
@ -100,13 +173,21 @@ export function CompanionApp(): ReactElement {
|
|||
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, sid, {
|
||||
const session = new VoiceSession(API_BASE_URL, SOCKET_BASE_URL, sid, {
|
||||
onTranscript: (text) => {
|
||||
const id = crypto.randomUUID();
|
||||
dispatch({ type: 'ADD_USER_MESSAGE', id, text });
|
||||
|
|
@ -141,6 +222,8 @@ export function CompanionApp(): ReactElement {
|
|||
if (activeAssistantIdRef.current) {
|
||||
dispatch({ type: 'CLEAR_SPEAKING', id: activeAssistantIdRef.current });
|
||||
}
|
||||
dispatch({ type: 'CLEAR_ACTIVE_ASSISTANT' });
|
||||
setMicState('idle');
|
||||
},
|
||||
|
||||
onListening: () => {
|
||||
|
|
@ -149,37 +232,58 @@ export function CompanionApp(): ReactElement {
|
|||
},
|
||||
|
||||
onStateChange: (state) => {
|
||||
setWsConnected(state === 'connected');
|
||||
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> {
|
||||
const existing = sessionStorage.getItem('companion_session_id');
|
||||
if (existing) {
|
||||
sessionId.current = existing;
|
||||
connectVoiceSession(existing);
|
||||
return;
|
||||
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) {
|
||||
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');
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}/session`, { method: 'POST' });
|
||||
if (!res.ok) throw new Error(`POST /session failed: ${res.status}`);
|
||||
const { session_id } = (await res.json()) as { session_id: string };
|
||||
|
||||
if (cancelled) return;
|
||||
sessionId.current = session_id;
|
||||
sessionStorage.setItem('companion_session_id', session_id);
|
||||
connectVoiceSession(session_id);
|
||||
}
|
||||
|
||||
void init();
|
||||
|
|
@ -190,7 +294,13 @@ export function CompanionApp(): ReactElement {
|
|||
micCaptureRef.current?.dispose();
|
||||
pcmPlayerRef.current?.dispose();
|
||||
};
|
||||
}, [connectVoiceSession]);
|
||||
}, [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.
|
||||
|
|
@ -227,13 +337,23 @@ export function CompanionApp(): ReactElement {
|
|||
}, []);
|
||||
|
||||
const handleMicPressStart = useCallback(async () => {
|
||||
await initAudio();
|
||||
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]);
|
||||
}, [initAudio, showToast]);
|
||||
|
||||
const handleMicPressEnd = useCallback(() => {
|
||||
micCaptureRef.current?.stop();
|
||||
|
|
@ -261,32 +381,167 @@ export function CompanionApp(): ReactElement {
|
|||
});
|
||||
}, []);
|
||||
|
||||
const handleTextComplete = useCallback(() => {
|
||||
dispatch({ type: 'CLEAR_ACTIVE_ASSISTANT' });
|
||||
}, []);
|
||||
|
||||
const handleSwitchSession = useCallback((targetSessionId: 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 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)
|
||||
.then((newId) => 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>
|
||||
<ConnectionDot $connected={wsConnected} title={wsConnected ? 'Connected' : 'Disconnected'} />
|
||||
{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} />
|
||||
<ChatView
|
||||
messages={chatState.messages}
|
||||
activeAssistantId={chatState.activeAssistantId}
|
||||
/>
|
||||
|
||||
<BottomBar>
|
||||
<TextInput
|
||||
sessionId={sessionId.current}
|
||||
sessionId={sessionId}
|
||||
apiBaseUrl={API_BASE_URL}
|
||||
onTranscript={handleTextTranscript}
|
||||
onSegment={handleTextSegment}
|
||||
onError={handleChatError}
|
||||
onComplete={handleTextComplete}
|
||||
onWillSend={initAudio}
|
||||
disabled={micState === 'listening'}
|
||||
/>
|
||||
<MicButton
|
||||
state={micState}
|
||||
onPressStart={() => { void handleMicPressStart(); }}
|
||||
onPressEnd={handleMicPressEnd}
|
||||
/>
|
||||
{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 }}
|
||||
/>
|
||||
|
||||
<SessionHistoryPanel
|
||||
open={historyOpen}
|
||||
onClose={() => setHistoryOpen(false)}
|
||||
currentSessionId={sessionId.current}
|
||||
apiBaseUrl={API_BASE_URL}
|
||||
onSwitchSession={handleSwitchSession}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function CompanionApp(): ReactElement {
|
||||
return (
|
||||
<ThemeProvider defaultTheme="cyberpunk">
|
||||
<ToastProvider>
|
||||
<CompanionAppInner />
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue