diff --git a/src/modules/speex/components/SpeexEngineConfig.tsx b/src/modules/speex/components/SpeexEngineConfig.tsx index 41e0c26df..4dc0398dd 100644 --- a/src/modules/speex/components/SpeexEngineConfig.tsx +++ b/src/modules/speex/components/SpeexEngineConfig.tsx @@ -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 <> + + + onUpdate({ ...credentials, apiKey: e.target.value })} + placeholder='sk-...' + sx={{ minWidth: 200 }} + /> + + {showHost && ( + + + onUpdate({ ...credentials, apiHost: e.target.value || undefined })} + placeholder={hostPlaceholder ?? 'https://api.example.com'} + sx={{ minWidth: 200 }} + /> + + )} + ; +} + + // 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 && ( + + )} + - - Voice listing requires API key. Language auto-detected from preferences. - + {showCredentials && ( + + Voice listing requires API key. Language auto-detected from preferences. + + )} @@ -133,13 +182,29 @@ function LocalAIConfig({ engine, onUpdate, mode }: { onUpdate: (updates: Partial) => 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 && ( + + )} + { + 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 && ( + + )} + (null); + const [confirmDeleteEngine, setConfirmDeleteEngine] = React.useState(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) => { + 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 */} + + + + {/* Add Engine Popup Menu */} + + {ADDABLE_VENDORS.map(vendor => ( + handleAddEngine(vendor.vendorType)}> + + + {vendor.label} + {vendor.description} + + + ))} + + + {/* Engine selection + Delete button */} + - + + + + + {/* Engine-specific configuration */} {activeEngine ? ( ) : ( @@ -99,5 +208,18 @@ export function SpeexOutputSettings() { )} + {/* Delete Confirmation Modal */} + {!!confirmDeleteEngine && ( + Remove {confirmDeleteEngine.label}? This cannot be undone.} + positiveActionText='Remove' + /> + )} + ; }