Skip to content
152 changes: 91 additions & 61 deletions ui/desktop/src/components/settings/dictation/ElevenLabsKeyInput.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect, useCallback } from 'react';
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

frankly, this whole module feels rather complicated. aren't we just setting an API key? there's validation code, but all it seems to do is check that the API key is set. or that the server failed but that's not very useful.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

frankly, this whole module feels rather complicated. aren't we just setting an API key? there's validation code, but all it seems to do is check that the API key is set. or that the server failed but that's not very useful.

@lifeizhou-ap , @DOsinga should I consider this update in this PR or make another one for this refactor ?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @Abhijay007,

Let's make another PR for this refactor.

import { Input } from '../../ui/input';
import { Button } from '../../ui/button';
import { useConfig } from '../../ConfigContext';
import { ELEVENLABS_API_KEY, isSecretKeyConfigured } from '../../../hooks/dictationConstants';
import { setElevenLabsKeyCache } from '../../../hooks/useDictationSettings';
Expand All @@ -8,71 +9,75 @@ export const ElevenLabsKeyInput = () => {
const [elevenLabsApiKey, setElevenLabsApiKey] = useState('');
const [isLoadingKey, setIsLoadingKey] = useState(false);
const [hasElevenLabsKey, setHasElevenLabsKey] = useState(false);
const elevenLabsApiKeyRef = useRef('');
const { upsert, read } = useConfig();
const [validationError, setValidationError] = useState('');
const [isEditing, setIsEditing] = useState(false);
const { upsert, read, remove } = useConfig();

useEffect(() => {
const loadKey = async () => {
setIsLoadingKey(true);
try {
const response = await read(ELEVENLABS_API_KEY, true);
if (isSecretKeyConfigured(response)) {
setHasElevenLabsKey(true);
setElevenLabsKeyCache(true);
} else {
setElevenLabsKeyCache(false);
}
} catch (error) {
console.error('Error checking ElevenLabs API key:', error);
setElevenLabsKeyCache(false);
} finally {
setIsLoadingKey(false);
}
};

loadKey();
const loadKey = useCallback(async () => {
setIsLoadingKey(true);
try {
const response = await read(ELEVENLABS_API_KEY, true);
const hasKey = isSecretKeyConfigured(response);
setHasElevenLabsKey(hasKey);
setElevenLabsKeyCache(hasKey);
} catch (error) {
console.error(error);
setElevenLabsKeyCache(false);
} finally {
setIsLoadingKey(false);
}
}, [read]);

// Save key on unmount to avoid losing unsaved changes
useEffect(() => {
return () => {
if (elevenLabsApiKeyRef.current) {
const keyToSave = elevenLabsApiKeyRef.current;
if (keyToSave.trim()) {
upsert(ELEVENLABS_API_KEY, keyToSave, true)
.then(() => setElevenLabsKeyCache(true))
.catch((error) => {
console.error('Error saving ElevenLabs API key on unmount:', error);
});
}
}
};
}, [upsert]);
loadKey();
}, [loadKey]);

const handleElevenLabsKeyChange = (key: string) => {
setElevenLabsApiKey(key);
elevenLabsApiKeyRef.current = key;
if (key.length > 0) {
setHasElevenLabsKey(false);
if (validationError) {
setValidationError('');
}
};

const saveElevenLabsKey = async () => {
const handleSave = async () => {
try {
if (elevenLabsApiKey.trim()) {
await upsert(ELEVENLABS_API_KEY, elevenLabsApiKey, true);
setHasElevenLabsKey(true);
setElevenLabsKeyCache(true);
} else {
await upsert(ELEVENLABS_API_KEY, null, true);
setHasElevenLabsKey(false);
setElevenLabsKeyCache(false);
const trimmedKey = elevenLabsApiKey.trim();

if (!trimmedKey) {
setValidationError('API key is required');
return;
}

await upsert(ELEVENLABS_API_KEY, trimmedKey, true);
setElevenLabsApiKey('');
setValidationError('');
setIsEditing(false);
await loadKey();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @Abhijay007 just curious why we hav to loadKey again in the upsert and remove? It added an extra call to the server side and it seems unnecessary? Shall we just update the cache and hasElevenLabsKey instead of the additional call?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it was mentioned here by copoilt : #6557 (comment) so I thought of implementing it

} catch (error) {
console.error('Error saving ElevenLabs API key:', error);
console.error(error);
setValidationError('Failed to save API key');
}
};

const handleRemove = async () => {
try {
await remove(ELEVENLABS_API_KEY, true);
await loadKey();
setElevenLabsApiKey('');
setValidationError('');
setIsEditing(false);
} catch (error) {
console.error(error);
setValidationError('Failed to remove API key');
}
};

const handleCancel = () => {
setElevenLabsApiKey('');
setValidationError('');
setIsEditing(false);
};

return (
<div className="py-2 px-2 bg-background-subtle rounded-lg">
<div className="mb-2">
Expand All @@ -82,17 +87,42 @@ export const ElevenLabsKeyInput = () => {
{hasElevenLabsKey && <span className="text-green-600 ml-2">(Configured)</span>}
</p>
</div>
<Input
type="password"
value={elevenLabsApiKey}
onChange={(e) => handleElevenLabsKeyChange(e.target.value)}
onBlur={saveElevenLabsKey}
placeholder={
hasElevenLabsKey ? 'Enter new API key to update' : 'Enter your ElevenLabs API key'
}
className="max-w-md"
disabled={isLoadingKey}
/>

{!isEditing ? (
<Button
variant="outline"
size="sm"
onClick={() => setIsEditing(true)}
disabled={isLoadingKey}
>
{hasElevenLabsKey ? 'Update API Key' : 'Add API Key'}
</Button>
) : (
<div className="space-y-2">
<Input
type="password"
value={elevenLabsApiKey}
onChange={(e) => handleElevenLabsKeyChange(e.target.value)}
placeholder="Enter your ElevenLabs API key"
className="max-w-md"
autoFocus
/>
{validationError && <p className="text-xs text-red-600 mt-1">{validationError}</p>}
<div className="flex gap-2">
<Button size="sm" onClick={handleSave}>
Save
</Button>
<Button variant="outline" size="sm" onClick={handleCancel}>
Cancel
</Button>
{hasElevenLabsKey && (
<Button variant="destructive" size="sm" onClick={handleRemove}>
Remove
</Button>
)}
</div>
</div>
)}
</div>
);
};
35 changes: 22 additions & 13 deletions ui/desktop/src/components/settings/dictation/ProviderSelector.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { useState, useEffect } from 'react';
import { ChevronDown } from 'lucide-react';
import { DictationProvider, DictationSettings } from '../../../hooks/useDictationSettings';
import { DICTATION_PROVIDER_ELEVENLABS } from '../../../hooks/dictationConstants';
import {
DICTATION_PROVIDER_OPENAI,
DICTATION_PROVIDER_ELEVENLABS,
} from '../../../hooks/dictationConstants';
import { useConfig } from '../../ConfigContext';
import { ElevenLabsKeyInput } from './ElevenLabsKeyInput';
import { ProviderInfo } from './ProviderInfo';
Expand Down Expand Up @@ -56,7 +59,7 @@ export const ProviderSelector = ({ settings, onProviderChange }: ProviderSelecto

const getProviderLabel = (provider: DictationProvider): string => {
switch (provider) {
case 'openai':
case DICTATION_PROVIDER_OPENAI:
return 'OpenAI Whisper';
case DICTATION_PROVIDER_ELEVENLABS:
return 'ElevenLabs';
Expand Down Expand Up @@ -84,25 +87,31 @@ export const ProviderSelector = ({ settings, onProviderChange }: ProviderSelecto
</button>

{showProviderDropdown && (
<div className="absolute right-0 mt-1 w-48 bg-background-default border border-border-default rounded-md shadow-lg z-10">
<div className="absolute right-0 mt-1 w-max min-w-[250px] max-w-[350px] bg-background-default border border-border-default rounded-md shadow-lg z-50">
<button
onClick={() => handleProviderChange('openai')}
className={`w-full px-3 py-2 text-left text-sm transition-colors hover:bg-background-subtle text-text-default ${!VOICE_DICTATION_ELEVENLABS_ENABLED ? 'first:rounded-t-md last:rounded-b-md' : 'first:rounded-t-md'}`}
onClick={() => handleProviderChange(DICTATION_PROVIDER_OPENAI)}
className={`w-full px-3 py-2 text-left text-sm transition-colors hover:bg-background-subtle text-text-default whitespace-nowrap ${!VOICE_DICTATION_ELEVENLABS_ENABLED ? 'first:rounded-t-md last:rounded-b-md' : 'first:rounded-t-md'}`}
>
OpenAI Whisper
{!hasOpenAIKey && <span className="text-xs ml-1">(not configured)</span>}
{settings.provider === 'openai' && <span className="float-right">✓</span>}
<span className="flex items-center justify-between gap-2">
<span>
OpenAI Whisper
{!hasOpenAIKey && (
<span className="text-xs ml-1 text-text-muted">(not configured)</span>
)}
</span>
{settings.provider === DICTATION_PROVIDER_OPENAI && <span>✓</span>}
</span>
</button>

{VOICE_DICTATION_ELEVENLABS_ENABLED && (
<button
onClick={() => handleProviderChange(DICTATION_PROVIDER_ELEVENLABS)}
className="w-full px-3 py-2 text-left text-sm hover:bg-background-subtle transition-colors text-text-default last:rounded-b-md"
className="w-full px-3 py-2 text-left text-sm hover:bg-background-subtle transition-colors text-text-default last:rounded-b-md whitespace-nowrap"
>
ElevenLabs
{settings.provider === DICTATION_PROVIDER_ELEVENLABS && (
<span className="float-right">✓</span>
)}
<span className="flex items-center justify-between gap-2">
<span>ElevenLabs</span>
{settings.provider === DICTATION_PROVIDER_ELEVENLABS && <span>✓</span>}
</span>
</button>
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Switch } from '../../ui/switch';
import { DictationProvider, DictationSettings } from '../../../hooks/useDictationSettings';
import {
DICTATION_SETTINGS_KEY,
DICTATION_PROVIDER_OPENAI,
DICTATION_PROVIDER_ELEVENLABS,
getDefaultDictationSettings,
} from '../../../hooks/dictationConstants';
Expand Down Expand Up @@ -35,7 +36,7 @@ export const VoiceDictationToggle = () => {
) {
loadedSettings = {
...loadedSettings,
provider: 'openai',
provider: DICTATION_PROVIDER_OPENAI,
};
localStorage.setItem(DICTATION_SETTINGS_KEY, JSON.stringify(loadedSettings));
}
Expand All @@ -59,7 +60,7 @@ export const VoiceDictationToggle = () => {
saveSettings({
...settings,
enabled,
provider: settings.provider === null ? 'openai' : settings.provider,
provider: settings.provider === null ? DICTATION_PROVIDER_OPENAI : settings.provider,
});
trackSettingToggled('voice_dictation', enabled);
};
Expand All @@ -83,7 +84,7 @@ export const VoiceDictationToggle = () => {
</div>

<div
className={`overflow-hidden transition-all duration-300 ease-in-out ${
className={`overflow-visible transition-all duration-300 ease-in-out ${
settings.enabled ? 'max-h-96 opacity-100 mt-2' : 'max-h-0 opacity-0 mt-0'
}`}
>
Expand Down
5 changes: 2 additions & 3 deletions ui/desktop/src/hooks/dictationConstants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { DictationSettings, DictationProvider } from './useDictationSettings';

export const DICTATION_SETTINGS_KEY = 'dictation_settings';
export const ELEVENLABS_API_KEY = 'ELEVENLABS_API_KEY';
export const DICTATION_PROVIDER_OPENAI = 'openai' as const;
export const DICTATION_PROVIDER_ELEVENLABS = 'elevenlabs' as const;

export const isSecretKeyConfigured = (response: unknown): boolean =>
Expand All @@ -14,14 +15,12 @@ export const getDefaultDictationSettings = async (
getProviders: (refresh: boolean) => Promise<Array<{ name: string; is_configured: boolean }>>
): Promise<DictationSettings> => {
const providers = await getProviders(false);

// Check if we have an OpenAI API key as primary default
const openAIProvider = providers.find((p) => p.name === 'openai');

if (openAIProvider && openAIProvider.is_configured) {
return {
enabled: true,
provider: 'openai' as DictationProvider,
provider: DICTATION_PROVIDER_OPENAI,
};
} else {
return {
Expand Down
7 changes: 5 additions & 2 deletions ui/desktop/src/hooks/useDictationSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,16 @@ import { useConfig } from '../components/ConfigContext';
import {
DICTATION_SETTINGS_KEY,
ELEVENLABS_API_KEY,
DICTATION_PROVIDER_OPENAI,
DICTATION_PROVIDER_ELEVENLABS,
getDefaultDictationSettings,
isSecretKeyConfigured,
} from './dictationConstants';

export type DictationProvider = 'openai' | typeof DICTATION_PROVIDER_ELEVENLABS | null;
export type DictationProvider =
| typeof DICTATION_PROVIDER_OPENAI
| typeof DICTATION_PROVIDER_ELEVENLABS
| null;

export interface DictationSettings {
enabled: boolean;
Expand All @@ -28,7 +32,6 @@ export const useDictationSettings = () => {

useEffect(() => {
const loadSettings = async () => {
// Load settings from localStorage
const saved = localStorage.getItem(DICTATION_SETTINGS_KEY);

let currentSettings: DictationSettings;
Expand Down
6 changes: 3 additions & 3 deletions ui/desktop/src/hooks/useWhisper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useState, useRef, useCallback, useEffect } from 'react';
import { useConfig } from '../components/ConfigContext';
import { getApiUrl } from '../config';
import { useDictationSettings } from './useDictationSettings';
import { DICTATION_PROVIDER_ELEVENLABS } from './dictationConstants';
import { DICTATION_PROVIDER_OPENAI, DICTATION_PROVIDER_ELEVENLABS } from './dictationConstants';
import { safeJsonParse } from '../utils/conversionUtils';

interface UseWhisperOptions {
Expand Down Expand Up @@ -75,7 +75,7 @@ export const useWhisper = ({ onTranscription, onError, onSizeWarning }: UseWhisp

// Check provider availability
switch (dictationSettings.provider) {
case 'openai':
case DICTATION_PROVIDER_OPENAI:
setCanUseDictation(hasOpenAIKey);
break;
case DICTATION_PROVIDER_ELEVENLABS:
Expand Down Expand Up @@ -178,7 +178,7 @@ export const useWhisper = ({ onTranscription, onError, onSizeWarning }: UseWhisp

// Choose endpoint based on provider
switch (dictationSettings.provider) {
case 'openai':
case DICTATION_PROVIDER_OPENAI:
endpoint = '/audio/transcribe';
break;
case DICTATION_PROVIDER_ELEVENLABS:
Expand Down
Loading