Speex: UI: credentials edit and add new

This commit is contained in:
Enrico Ros
2025-11-26 21:13:47 -08:00
parent 22752abc38
commit 910260c2c8
2 changed files with 217 additions and 15 deletions
@@ -17,11 +17,45 @@ import StopRoundedIcon from '@mui/icons-material/StopRounded';
import { FormLabelStart } from '~/common/components/forms/FormLabelStart';
import type { DSpeexEngine, DSpeexEngineAny, DVoiceElevenLabs, DVoiceLocalAI, DVoiceOpenAI, DVoiceWebSpeech } from '../speex.types';
import type { DCredentialsApiKey, DSpeexEngine, DSpeexEngineAny, DVoiceElevenLabs, DVoiceLocalAI, DVoiceOpenAI, DVoiceWebSpeech } from '../speex.types';
import { speakText } from '../speex.client';
import { SpeexVoiceDropdown } from './SpeexVoiceDropdown';
// Credential input helper - shared across vendors
function CredentialsApiKeyInputs({ credentials, onUpdate, showHost, hostRequired, hostPlaceholder }: {
credentials: DCredentialsApiKey;
onUpdate: (credentials: DCredentialsApiKey) => void;
showHost?: boolean;
hostRequired?: boolean;
hostPlaceholder?: string;
}) {
return <>
<FormControl orientation='horizontal' sx={{ justifyContent: 'space-between', alignItems: 'center' }}>
<FormLabelStart title='API Key' description={hostRequired ? 'Optional' : 'Required'} />
<Input
type='password'
value={credentials.apiKey}
onChange={(e) => onUpdate({ ...credentials, apiKey: e.target.value })}
placeholder='sk-...'
sx={{ minWidth: 200 }}
/>
</FormControl>
{showHost && (
<FormControl orientation='horizontal' sx={{ justifyContent: 'space-between', alignItems: 'center' }}>
<FormLabelStart title='API Host' description={hostRequired ? 'Required' : 'Optional'} />
<Input
value={credentials.apiHost ?? ''}
onChange={(e) => onUpdate({ ...credentials, apiHost: e.target.value || undefined })}
placeholder={hostPlaceholder ?? 'https://api.example.com'}
sx={{ minWidth: 200 }}
/>
</FormControl>
)}
</>;
}
// configuration
const PREVIEW_TEXT = 'Hello, this is my voice.';
@@ -84,13 +118,26 @@ function ElevenLabsConfig({ engine, onUpdate, mode }: {
mode: 'full' | 'voice-only';
}) {
const { voice } = engine;
const { credentials, voice } = engine;
const showCredentials = mode === 'full' && !engine.isAutoLinked && credentials.type === 'api-key';
const handleCredentialsUpdate = React.useCallback((newCredentials: DCredentialsApiKey) => {
onUpdate({ credentials: newCredentials });
}, [onUpdate]);
const handleVoiceChange = React.useCallback((ttsVoiceId: DVoiceElevenLabs['ttsVoiceId']) => {
onUpdate({ voice: { ...voice, ttsVoiceId } });
}, [onUpdate, voice]);
return <>
{/* Credentials (only for manually added engines in full mode) */}
{showCredentials && (
<CredentialsApiKeyInputs
credentials={credentials}
onUpdate={handleCredentialsUpdate}
/>
)}
<FormControl orientation='horizontal' sx={{ justifyContent: 'space-between', alignItems: 'center' }}>
<FormLabelStart title='Voice' description='ElevenLabs voice' />
<SpeexVoiceDropdown
@@ -117,9 +164,11 @@ function ElevenLabsConfig({ engine, onUpdate, mode }: {
</FormHelperText>
</FormControl>
<FormHelperText>
Voice listing requires API key. Language auto-detected from preferences.
</FormHelperText>
{showCredentials && (
<FormHelperText>
Voice listing requires API key. Language auto-detected from preferences.
</FormHelperText>
)}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 1 }}>
<PreviewButton engine={engine} />
@@ -133,13 +182,29 @@ function LocalAIConfig({ engine, onUpdate, mode }: {
onUpdate: (updates: Partial<DSpeexEngineAny>) => void;
mode: 'full' | 'voice-only';
}) {
const { voice } = engine;
const { credentials, voice } = engine;
const showCredentials = mode === 'full' && !engine.isAutoLinked && credentials.type === 'api-key';
const handleCredentialsUpdate = React.useCallback((newCredentials: DCredentialsApiKey) => {
onUpdate({ credentials: newCredentials });
}, [onUpdate]);
const handleModelChange = React.useCallback((ttsModel: DVoiceLocalAI['ttsModel']) => {
onUpdate({ voice: { ...voice, ttsModel } });
}, [onUpdate, voice]);
return <>
{/* Credentials (only for manually added engines in full mode) */}
{showCredentials && (
<CredentialsApiKeyInputs
credentials={credentials}
onUpdate={handleCredentialsUpdate}
showHost
hostRequired
hostPlaceholder='http://localhost:8080'
/>
)}
<FormControl orientation='horizontal' sx={{ justifyContent: 'space-between', alignItems: 'center' }}>
<FormLabelStart title='Model' description='TTS model' />
<SpeexVoiceDropdown
@@ -182,7 +247,12 @@ function OpenAIConfig({ engine, onUpdate, mode }: {
mode: 'full' | 'voice-only';
}) {
const { voice } = engine;
const { credentials, voice } = engine;
const showCredentials = mode === 'full' && !engine.isAutoLinked && credentials.type === 'api-key';
const handleCredentialsUpdate = React.useCallback((newCredentials: DCredentialsApiKey) => {
onUpdate({ credentials: newCredentials });
}, [onUpdate]);
const handleVoiceChange = React.useCallback((ttsVoiceId: DVoiceOpenAI['ttsVoiceId']) => {
onUpdate({ voice: { ...voice, ttsVoiceId } });
@@ -193,6 +263,16 @@ function OpenAIConfig({ engine, onUpdate, mode }: {
}, [onUpdate, voice]);
return <>
{/* Credentials (only for manually added engines in full mode) */}
{showCredentials && (
<CredentialsApiKeyInputs
credentials={credentials}
onUpdate={handleCredentialsUpdate}
showHost
hostPlaceholder='https://api.openai.com (optional)'
/>
)}
<FormControl orientation='horizontal' sx={{ justifyContent: 'space-between', alignItems: 'center' }}>
<FormLabelStart title='Voice' description='OpenAI TTS voice' />
<SpeexVoiceDropdown
@@ -2,39 +2,56 @@
* SpeexOutputSettings - Voice output settings for the Settings Modal
*
* Provides:
* - Engine selection dropdown
* - Engine selection dropdown with Add/Delete controls
* - Per-engine voice configuration
* - Auto-speak toggle (from chat settings)
*/
import * as React from 'react';
import { FormControl, FormHelperText, Option, Select, Typography } from '@mui/joy';
import { Box, Button, FormControl, FormHelperText, IconButton, ListItemDecorator, MenuItem, Option, Select, Typography } from '@mui/joy';
import AddIcon from '@mui/icons-material/Add';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import { useChatAutoAI } from '../../../apps/chat/store-app-chat';
import { CloseablePopup } from '~/common/components/CloseablePopup';
import { ConfirmationModal } from '~/common/components/modals/ConfirmationModal';
import { FormLabelStart } from '~/common/components/forms/FormLabelStart';
import { FormRadioControl } from '~/common/components/forms/FormRadioControl';
import type { DSpeexEngineAny } from '../speex.types';
import type { DSpeexEngineAny, DSpeexVendorType } from '../speex.types';
import { SpeexEngineConfig } from './SpeexEngineConfig';
import { useSpeexActiveEngineId, useSpeexEngines, useSpeexStore } from '../store-module-speex';
import { themeZIndexOverMobileDrawer } from '~/common/app.theme';
// Vendor options for the Add menu
const ADDABLE_VENDORS: { vendorType: DSpeexVendorType; label: string; description: string }[] = [
{ vendorType: 'elevenlabs', label: 'ElevenLabs', description: 'High-quality voices' },
{ vendorType: 'openai', label: 'OpenAI TTS', description: 'Fast and reliable' },
{ vendorType: 'localai', label: 'LocalAI', description: 'Self-hosted TTS' },
];
export function SpeexOutputSettings() {
// state
const [addMenuAnchor, setAddMenuAnchor] = React.useState<HTMLElement | null>(null);
const [confirmDeleteEngine, setConfirmDeleteEngine] = React.useState<DSpeexEngineAny | null>(null);
// external state
const { autoSpeak, setAutoSpeak } = useChatAutoAI();
// external state - module
const engines = useSpeexEngines();
const activeEngineId = useSpeexActiveEngineId();
// const { setActiveEngineId, updateEngine } = useSpeexStore.getState();
// derived state
const hasEngines = engines.length > 0;
const activeEngine = engines.find(e => e.engineId === activeEngineId);
const canDeleteActiveEngine = activeEngine && !activeEngine.isAutoDetected && !activeEngine.isAutoLinked;
// handlers
@@ -49,6 +66,54 @@ export function SpeexOutputSettings() {
}, [activeEngineId]);
// Add engine handlers
const handleOpenAddMenu = React.useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
setAddMenuAnchor(event.currentTarget);
}, []);
const handleCloseAddMenu = React.useCallback(() => {
setAddMenuAnchor(null);
}, []);
const handleAddEngine = React.useCallback((vendorType: DSpeexVendorType) => {
handleCloseAddMenu();
const newEngineId = useSpeexStore.getState().createEngine(vendorType);
useSpeexStore.getState().setActiveEngineId(newEngineId);
}, [handleCloseAddMenu]);
// Delete engine handlers
const handleDeleteClick = React.useCallback((event: React.MouseEvent) => {
if (!activeEngine || !canDeleteActiveEngine) return;
// Shift+click skips confirmation
if (event.shiftKey) {
useSpeexStore.getState().deleteEngine(activeEngine.engineId);
// Auto-select next available engine
const remaining = engines.filter(e => e.engineId !== activeEngine.engineId);
useSpeexStore.getState().setActiveEngineId(remaining[0]?.engineId ?? null);
} else {
setConfirmDeleteEngine(activeEngine);
}
}, [activeEngine, canDeleteActiveEngine, engines]);
const handleConfirmDelete = React.useCallback(() => {
if (!confirmDeleteEngine) return;
useSpeexStore.getState().deleteEngine(confirmDeleteEngine.engineId);
// Auto-select next available engine
const remaining = engines.filter(e => e.engineId !== confirmDeleteEngine.engineId);
useSpeexStore.getState().setActiveEngineId(remaining[0]?.engineId ?? null);
setConfirmDeleteEngine(null);
}, [confirmDeleteEngine, engines]);
const handleCancelDelete = React.useCallback(() => {
setConfirmDeleteEngine(null);
}, []);
return <>
{/* Auto-speak setting */}
@@ -65,15 +130,48 @@ export function SpeexOutputSettings() {
value={autoSpeak} onChange={setAutoSpeak}
/>
{/* Engine selection */}
{/* Voice Engine label + Add button */}
<FormControl orientation='horizontal' sx={{ justifyContent: 'space-between', alignItems: 'center' }}>
<FormLabelStart title='Voice Engine' description='TTS provider' />
<Button
size='sm'
variant='outlined'
color='neutral'
startDecorator={<AddIcon />}
onClick={handleOpenAddMenu}
>
Add
</Button>
</FormControl>
{/* Add Engine Popup Menu */}
<CloseablePopup
menu
anchorEl={addMenuAnchor}
onClose={handleCloseAddMenu}
placement='bottom-end'
minWidth={200}
zIndex={themeZIndexOverMobileDrawer}
>
{ADDABLE_VENDORS.map(vendor => (
<MenuItem key={vendor.vendorType} onClick={() => handleAddEngine(vendor.vendorType)}>
<ListItemDecorator />
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Typography level='title-sm'>{vendor.label}</Typography>
<Typography level='body-xs'>{vendor.description}</Typography>
</Box>
</MenuItem>
))}
</CloseablePopup>
{/* Engine selection + Delete button */}
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<Select
value={activeEngineId || ''}
onChange={handleEngineChange}
placeholder={hasEngines ? 'Select engine' : 'No engines available'}
disabled={!hasEngines}
sx={{ minWidth: 200 }}
sx={{ flex: 1, minWidth: 200 }}
>
{engines.map(engine => (
<Option key={engine.engineId} value={engine.engineId}>
@@ -82,14 +180,25 @@ export function SpeexOutputSettings() {
</Option>
))}
</Select>
</FormControl>
<IconButton
size='sm'
variant='plain'
color='neutral'
disabled={!canDeleteActiveEngine}
onClick={handleDeleteClick}
sx={{ opacity: canDeleteActiveEngine ? 1 : 0.4 }}
>
<DeleteOutlineIcon />
</IconButton>
</Box>
{/* Engine-specific configuration */}
{activeEngine ? (
<SpeexEngineConfig
engine={activeEngine}
onUpdate={handleEngineUpdate}
mode='voice-only'
mode={activeEngine.isAutoLinked || activeEngine.isAutoDetected ? 'voice-only' : 'full'}
/>
) : (
<FormHelperText>
@@ -99,5 +208,18 @@ export function SpeexOutputSettings() {
</FormHelperText>
)}
{/* Delete Confirmation Modal */}
{!!confirmDeleteEngine && (
<ConfirmationModal
open
onClose={handleCancelDelete}
onPositive={handleConfirmDelete}
lowStakes
noTitleBar
confirmationText={<>Remove <strong>{confirmDeleteEngine.label}</strong>? This cannot be undone.</>}
positiveActionText='Remove'
/>
)}
</>;
}