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:
parent
072d148206
commit
f1ccf62900
3 changed files with 619 additions and 566 deletions
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
33
@applications/web/src/app/routes.tsx
Normal file
33
@applications/web/src/app/routes.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
542
@applications/web/src/features/chat/ChatPage.tsx
Normal file
542
@applications/web/src/features/chat/ChatPage.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue