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 */}
+ }
+ onClick={handleOpenAddMenu}
+ >
+ Add
+
+
+
+ {/* Add Engine Popup Menu */}
+
+ {ADDABLE_VENDORS.map(vendor => (
+
+ ))}
+
+
+ {/* 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'
+ />
+ )}
+
>;
}