mirror of
https://github.com/enricoros/big-AGI.git
synced 2026-05-11 14:10:15 -07:00
Speex: UI: credentials edit and add new
This commit is contained in:
@@ -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'
|
||||
/>
|
||||
)}
|
||||
|
||||
</>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user