feat(settings): ✨ Introduce new configuration options and settings panel enhancements for user customization
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
9098da09b9
commit
fce50d488b
2 changed files with 394 additions and 0 deletions
336
@applications/web/src/features/settings/SettingsPanel.tsx
Normal file
336
@applications/web/src/features/settings/SettingsPanel.tsx
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
import { useCallback } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import styled from '@lilith/ui-styled-components';
|
||||
import { Tooltip } from '@lilith/ui-feedback';
|
||||
import { AnimatePresence, motion } from '@lilith/ui-motion';
|
||||
import type { VoiceSessionState } from '../voice/VoiceSession';
|
||||
import type { Settings, SettingsActions } from './useSettings';
|
||||
|
||||
export interface SettingsPanelProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
sessionId: string;
|
||||
connectionState: VoiceSessionState | 'disconnected';
|
||||
settings: Settings;
|
||||
onSettings: SettingsActions;
|
||||
}
|
||||
|
||||
const Overlay = styled(motion.div)`
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
z-index: 500;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
`;
|
||||
|
||||
const Sheet = styled(motion.div)`
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
background: #0d0d1a;
|
||||
border-top: 1px solid #1a1a2e;
|
||||
border-radius: 20px 20px 0 0;
|
||||
padding: 8px 0 max(24px, env(safe-area-inset-bottom));
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
`;
|
||||
|
||||
const Handle = styled.div`
|
||||
width: 36px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: #2d3748;
|
||||
margin: 0 auto 20px;
|
||||
`;
|
||||
|
||||
const SectionTitle = styled.h2`
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: #4a5568;
|
||||
padding: 0 20px 8px;
|
||||
`;
|
||||
|
||||
const Row = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid #1a1a2e;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const RowLabel = styled.span`
|
||||
font-size: 15px;
|
||||
color: #e2e8f0;
|
||||
`;
|
||||
|
||||
const RowValue = styled.span`
|
||||
font-size: 13px;
|
||||
color: #4a5568;
|
||||
font-family: 'SF Mono', 'Fira Mono', monospace;
|
||||
max-width: 140px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const CopyButton = styled.button`
|
||||
background: none;
|
||||
border: 1px solid #2d3748;
|
||||
border-radius: 6px;
|
||||
color: #a0aec0;
|
||||
font-size: 11px;
|
||||
padding: 3px 8px;
|
||||
cursor: pointer;
|
||||
margin-left: 8px;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:active {
|
||||
background: #1a1a2e;
|
||||
}
|
||||
`;
|
||||
|
||||
const CONNECTION_LABELS: Record<string, { label: string; color: string }> = {
|
||||
connected: { label: 'Connected', color: '#68d391' },
|
||||
disconnected: { label: 'Disconnected', color: '#4a5568' },
|
||||
connecting: { label: 'Connecting…', color: '#f9d94e' },
|
||||
error: { label: 'Error', color: '#f56565' },
|
||||
};
|
||||
|
||||
const StatusDot = styled.span<{ $color: string }>`
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: ${({ $color }) => $color};
|
||||
margin-right: 6px;
|
||||
`;
|
||||
|
||||
const StatusValue = styled.span<{ $color: string }>`
|
||||
font-size: 13px;
|
||||
color: ${({ $color }) => $color};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const Section = styled.div`
|
||||
margin-bottom: 8px;
|
||||
`;
|
||||
|
||||
// Toggle
|
||||
const ToggleTrack = styled.button<{ $on: boolean }>`
|
||||
width: 44px;
|
||||
height: 26px;
|
||||
border-radius: 13px;
|
||||
border: none;
|
||||
background: ${({ $on }) => ($on ? '#553c9a' : '#2d3748')};
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: background 200ms ease;
|
||||
flex-shrink: 0;
|
||||
outline: none;
|
||||
|
||||
&:focus-visible {
|
||||
box-shadow: 0 0 0 2px #553c9a;
|
||||
}
|
||||
`;
|
||||
|
||||
const ToggleThumb = styled.span<{ $on: boolean }>`
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: ${({ $on }) => ($on ? '21px' : '3px')};
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: #e2e8f0;
|
||||
transition: left 200ms ease;
|
||||
`;
|
||||
|
||||
// Volume slider
|
||||
const VolumeRow = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
max-width: 180px;
|
||||
`;
|
||||
|
||||
const VolumeSlider = styled.input`
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: #2d3748;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
|
||||
&::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: #553c9a;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&::-moz-range-thumb {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: #553c9a;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`;
|
||||
|
||||
const VolumeLabel = styled.span`
|
||||
font-size: 12px;
|
||||
color: #4a5568;
|
||||
min-width: 32px;
|
||||
text-align: right;
|
||||
`;
|
||||
|
||||
function Toggle({ on, onChange, label }: { on: boolean; onChange: (v: boolean) => void; label: string }): ReactElement {
|
||||
return (
|
||||
<ToggleTrack
|
||||
$on={on}
|
||||
onClick={() => onChange(!on)}
|
||||
role="switch"
|
||||
aria-checked={on}
|
||||
aria-label={label}
|
||||
>
|
||||
<ToggleThumb $on={on} />
|
||||
</ToggleTrack>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsPanel({
|
||||
open,
|
||||
onClose,
|
||||
sessionId,
|
||||
connectionState,
|
||||
settings,
|
||||
onSettings,
|
||||
}: SettingsPanelProps): ReactElement {
|
||||
const handleCopySession = useCallback(() => {
|
||||
void navigator.clipboard.writeText(sessionId);
|
||||
}, [sessionId]);
|
||||
|
||||
const handleOverlayClick = useCallback((e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}, [onClose]);
|
||||
|
||||
const conn = CONNECTION_LABELS[connectionState] ?? { label: 'Disconnected', color: '#4a5568' };
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<Overlay
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
onClick={handleOverlayClick}
|
||||
>
|
||||
<Sheet
|
||||
initial={{ y: '100%' }}
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: '100%' }}
|
||||
transition={{ type: 'spring', damping: 30, stiffness: 300 }}
|
||||
>
|
||||
<Handle />
|
||||
|
||||
<Section>
|
||||
<SectionTitle>Voice Output (TTS)</SectionTitle>
|
||||
<Row>
|
||||
<RowLabel>Enable TTS</RowLabel>
|
||||
<Tooltip content="Play AI responses as speech" position="top">
|
||||
<Toggle
|
||||
on={settings.ttsEnabled}
|
||||
onChange={onSettings.setTtsEnabled}
|
||||
label="Enable text-to-speech"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Row>
|
||||
<Row>
|
||||
<RowLabel>Volume</RowLabel>
|
||||
<VolumeRow>
|
||||
<VolumeSlider
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={settings.ttsVolume}
|
||||
disabled={!settings.ttsEnabled}
|
||||
onChange={(e) => onSettings.setTtsVolume(Number(e.target.value))}
|
||||
aria-label="TTS volume"
|
||||
/>
|
||||
<VolumeLabel>{Math.round(settings.ttsVolume * 100)}%</VolumeLabel>
|
||||
</VolumeRow>
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<SectionTitle>Voice Input (STT)</SectionTitle>
|
||||
<Row>
|
||||
<RowLabel>Enable microphone</RowLabel>
|
||||
<Tooltip content="Hold-to-speak voice input" position="top">
|
||||
<Toggle
|
||||
on={settings.sttEnabled}
|
||||
onChange={onSettings.setSttEnabled}
|
||||
label="Enable speech-to-text"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<SectionTitle>Connection</SectionTitle>
|
||||
<Row>
|
||||
<RowLabel>Voice WebSocket</RowLabel>
|
||||
<StatusValue $color={conn.color}>
|
||||
<StatusDot $color={conn.color} />
|
||||
{conn.label}
|
||||
</StatusValue>
|
||||
</Row>
|
||||
<Row>
|
||||
<RowLabel>Session ID</RowLabel>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<RowValue title={sessionId}>
|
||||
{sessionId ? sessionId.slice(0, 12) + '…' : '—'}
|
||||
</RowValue>
|
||||
{sessionId && (
|
||||
<Tooltip content="Copy session ID to clipboard" position="top">
|
||||
<CopyButton onClick={handleCopySession}>Copy</CopyButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<SectionTitle>About</SectionTitle>
|
||||
<Row>
|
||||
<RowLabel>Companion</RowLabel>
|
||||
<RowValue>v1.0</RowValue>
|
||||
</Row>
|
||||
</Section>
|
||||
</Sheet>
|
||||
</Overlay>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
58
@applications/web/src/features/settings/useSettings.ts
Normal file
58
@applications/web/src/features/settings/useSettings.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
|
||||
export interface Settings {
|
||||
ttsEnabled: boolean;
|
||||
ttsVolume: number;
|
||||
sttEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface SettingsActions {
|
||||
setTtsEnabled: (enabled: boolean) => void;
|
||||
setTtsVolume: (volume: number) => void;
|
||||
setSttEnabled: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'companion_settings';
|
||||
|
||||
const DEFAULTS: Settings = {
|
||||
ttsEnabled: true,
|
||||
ttsVolume: 1.0,
|
||||
sttEnabled: true,
|
||||
};
|
||||
|
||||
function load(): Settings {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return DEFAULTS;
|
||||
return { ...DEFAULTS, ...(JSON.parse(raw) as Partial<Settings>) };
|
||||
} catch {
|
||||
return DEFAULTS;
|
||||
}
|
||||
}
|
||||
|
||||
function save(settings: Settings): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
||||
} catch {
|
||||
// storage unavailable — non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
export function useSettings(): Settings & SettingsActions {
|
||||
const [settings, setSettings] = useState<Settings>(load);
|
||||
|
||||
const update = useCallback((patch: Partial<Settings>) => {
|
||||
setSettings((prev) => {
|
||||
const next = { ...prev, ...patch };
|
||||
save(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
...settings,
|
||||
setTtsEnabled: useCallback((enabled: boolean) => update({ ttsEnabled: enabled }), [update]),
|
||||
setTtsVolume: useCallback((volume: number) => update({ ttsVolume: volume }), [update]),
|
||||
setSttEnabled: useCallback((enabled: boolean) => update({ sttEnabled: enabled }), [update]),
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue