diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index a53df80e474d..5bd04d03962a 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -17,10 +17,10 @@ import { AlertType, useAlerts } from './alerts'; import { useConfig } from './ConfigContext'; import { useModelAndProvider } from './ModelAndProviderContext'; import { useWhisper } from '../hooks/useWhisper'; +import { DICTATION_PROVIDER_ELEVENLABS } from '../hooks/dictationConstants'; import { WaveformVisualizer } from './WaveformVisualizer'; import { toastError } from '../toasts'; import MentionPopover, { DisplayItemWithMatch } from './MentionPopover'; -import { useDictationSettings } from '../hooks/useDictationSettings'; import { COST_TRACKING_ENABLED, VOICE_DICTATION_ELEVENLABS_ENABLED } from '../updates'; import { CostTracker } from './bottom_menu/CostTracker'; import { DroppedFile, useFileDrop } from '../hooks/useFileDrop'; @@ -265,6 +265,7 @@ export default function ChatInput({ stopRecording, recordingDuration, estimatedSize, + dictationSettings, } = useWhisper({ onTranscription: (text) => { trackVoiceDictation('transcribed'); @@ -289,8 +290,6 @@ export default function ChatInput({ }); }, }); - - const { settings: dictationSettings } = useDictationSettings(); const internalTextAreaRef = useRef(null); const textAreaRef = inputRef || internalTextAreaRef; const timeoutRefsRef = useRef>>(new Set()); @@ -618,17 +617,20 @@ export default function ChatInput({ return [...pastedImageData, ...droppedImageData]; }, [pastedImages, allDroppedFiles]); - const appendDroppedFilePaths = useCallback((text: string): string => { - const droppedFilePaths = allDroppedFiles - .filter((file) => !file.isImage && !file.error && !file.isLoading) - .map((file) => file.path); + const appendDroppedFilePaths = useCallback( + (text: string): string => { + const droppedFilePaths = allDroppedFiles + .filter((file) => !file.isImage && !file.error && !file.isLoading) + .map((file) => file.path); - if (droppedFilePaths.length > 0) { - const pathsString = droppedFilePaths.join(' '); - return text ? `${text} ${pathsString}` : pathsString; - } - return text; - }, [allDroppedFiles]); + if (droppedFilePaths.length > 0) { + const pathsString = droppedFilePaths.join(' '); + return text ? `${text} ${pathsString}` : pathsString; + } + return text; + }, + [allDroppedFiles] + ); const clearInputState = useCallback(() => { setDisplayValue(''); @@ -1189,7 +1191,13 @@ export default function ChatInput({ onDrop={handleLocalDrop} onDragOver={handleLocalDragOver} > - + {/* Message Queue Display */} {queuedMessages.length > 0 && ( Models.

) : VOICE_DICTATION_ELEVENLABS_ENABLED && - dictationSettings.provider === 'elevenlabs' ? ( + dictationSettings.provider === DICTATION_PROVIDER_ELEVENLABS ? (

ElevenLabs API key is not configured. Set it up in Settings {'>'}{' '} Chat {'>'} Voice Dictation. diff --git a/ui/desktop/src/components/ConfigContext.tsx b/ui/desktop/src/components/ConfigContext.tsx index add93c0cc00a..e2de27f30233 100644 --- a/ui/desktop/src/components/ConfigContext.tsx +++ b/ui/desktop/src/components/ConfigContext.tsx @@ -66,6 +66,10 @@ export const ConfigProvider: React.FC = ({ children }) => { const [extensionsList, setExtensionsList] = useState([]); const [extensionWarnings, setExtensionWarnings] = useState([]); + // Ref to access providersList in getProviders without recreating the callback + const providersListRef = React.useRef(providersList); + providersListRef.current = providersList; + const reloadConfig = useCallback(async () => { const response = await readAllConfig(); setConfig(response.data?.config || {}); @@ -168,23 +172,20 @@ export const ConfigProvider: React.FC = ({ children }) => { [addExtension, getExtensions] ); - const getProviders = useCallback( - async (forceRefresh = false): Promise => { - if (forceRefresh || providersList.length === 0) { - try { - const response = await providers(); - const providersData = response.data || []; - setProvidersList(providersData); - return providersData; - } catch (error) { - console.error('Failed to fetch providers:', error); - return []; - } + const getProviders = useCallback(async (forceRefresh = false): Promise => { + if (forceRefresh || providersListRef.current.length === 0) { + try { + const response = await providers(); + const providersData = response.data || []; + setProvidersList(providersData); + return providersData; + } catch (error) { + console.error('Failed to fetch providers:', error); + return []; } - return providersList; - }, - [providersList] - ); + } + return providersListRef.current; + }, []); const getProviderModels = useCallback(async (providerName: string): Promise => { try { diff --git a/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx b/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx index 3de8f3e3b82c..f1fa663ec8b8 100644 --- a/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx +++ b/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx @@ -28,6 +28,7 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS const [pendingSort, setPendingSort] = useState(false); const [togglingExtension, setTogglingExtension] = useState(null); const [refreshTrigger, setRefreshTrigger] = useState(0); + const [isSessionExtensionsLoaded, setIsSessionExtensionsLoaded] = useState(false); const sortTimeoutRef = useRef | null>(null); const { extensionsList: allExtensions } = useConfig(); const isHubView = !sessionId; @@ -70,12 +71,15 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS if (response.data?.extensions) { setSessionExtensions(response.data.extensions); + setIsSessionExtensionsLoaded(true); } } catch (error) { console.error('Failed to fetch session extensions:', error); + setIsSessionExtensionsLoaded(true); } }; + setIsSessionExtensionsLoaded(false); fetchExtensions(); }, [sessionId, isOpen, refreshTrigger]); @@ -225,7 +229,7 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS > )} @@ -107,9 +110,8 @@ export const ProviderSelector = ({ settings, onProviderChange }: ProviderSelecto - {VOICE_DICTATION_ELEVENLABS_ENABLED && settings.provider === 'elevenlabs' && ( - - )} + {VOICE_DICTATION_ELEVENLABS_ENABLED && + settings.provider === DICTATION_PROVIDER_ELEVENLABS && } diff --git a/ui/desktop/src/components/settings/dictation/VoiceDictationToggle.tsx b/ui/desktop/src/components/settings/dictation/VoiceDictationToggle.tsx index 57d59be4fb0a..dcb4fea9cec1 100644 --- a/ui/desktop/src/components/settings/dictation/VoiceDictationToggle.tsx +++ b/ui/desktop/src/components/settings/dictation/VoiceDictationToggle.tsx @@ -3,6 +3,7 @@ import { Switch } from '../../ui/switch'; import { DictationProvider, DictationSettings } from '../../../hooks/useDictationSettings'; import { DICTATION_SETTINGS_KEY, + DICTATION_PROVIDER_ELEVENLABS, getDefaultDictationSettings, } from '../../../hooks/dictationConstants'; import { useConfig } from '../../ConfigContext'; @@ -28,7 +29,10 @@ export const VoiceDictationToggle = () => { loadedSettings = parsed; // If ElevenLabs is disabled and user has it selected, reset to OpenAI - if (!VOICE_DICTATION_ELEVENLABS_ENABLED && loadedSettings.provider === 'elevenlabs') { + if ( + !VOICE_DICTATION_ELEVENLABS_ENABLED && + loadedSettings.provider === DICTATION_PROVIDER_ELEVENLABS + ) { loadedSettings = { ...loadedSettings, provider: 'openai', diff --git a/ui/desktop/src/hooks/dictationConstants.ts b/ui/desktop/src/hooks/dictationConstants.ts index 972ba08b6cfa..c9c7ee65d9d5 100644 --- a/ui/desktop/src/hooks/dictationConstants.ts +++ b/ui/desktop/src/hooks/dictationConstants.ts @@ -2,6 +2,13 @@ import { DictationSettings, DictationProvider } from './useDictationSettings'; export const DICTATION_SETTINGS_KEY = 'dictation_settings'; export const ELEVENLABS_API_KEY = 'ELEVENLABS_API_KEY'; +export const DICTATION_PROVIDER_ELEVENLABS = 'elevenlabs' as const; + +export const isSecretKeyConfigured = (response: unknown): boolean => + typeof response === 'object' && + response !== null && + 'maskedValue' in response && + !!(response as { maskedValue: string }).maskedValue; export const getDefaultDictationSettings = async ( getProviders: (refresh: boolean) => Promise> diff --git a/ui/desktop/src/hooks/useDictationSettings.ts b/ui/desktop/src/hooks/useDictationSettings.ts index d6fe046b5a4a..778006f7e6d4 100644 --- a/ui/desktop/src/hooks/useDictationSettings.ts +++ b/ui/desktop/src/hooks/useDictationSettings.ts @@ -3,19 +3,27 @@ import { useConfig } from '../components/ConfigContext'; import { DICTATION_SETTINGS_KEY, ELEVENLABS_API_KEY, + DICTATION_PROVIDER_ELEVENLABS, getDefaultDictationSettings, + isSecretKeyConfigured, } from './dictationConstants'; -export type DictationProvider = 'openai' | 'elevenlabs' | null; +export type DictationProvider = 'openai' | typeof DICTATION_PROVIDER_ELEVENLABS | null; export interface DictationSettings { enabled: boolean; provider: DictationProvider; } +let elevenLabsKeyCache: boolean | null = null; + +export const setElevenLabsKeyCache = (value: boolean) => { + elevenLabsKeyCache = value; +}; + export const useDictationSettings = () => { const [settings, setSettings] = useState(null); - const [hasElevenLabsKey, setHasElevenLabsKey] = useState(false); + const [hasElevenLabsKey, setHasElevenLabsKey] = useState(elevenLabsKeyCache ?? false); const { read, getProviders } = useConfig(); useEffect(() => { @@ -23,22 +31,27 @@ export const useDictationSettings = () => { // Load settings from localStorage const saved = localStorage.getItem(DICTATION_SETTINGS_KEY); + let currentSettings: DictationSettings; if (saved) { - const parsedSettings = JSON.parse(saved); - setSettings(parsedSettings); + currentSettings = JSON.parse(saved); } else { - const defaultSettings = await getDefaultDictationSettings(getProviders); - setSettings(defaultSettings); + currentSettings = await getDefaultDictationSettings(getProviders); } - - // Load ElevenLabs API key from storage (non-secret for frontend access) - try { - const keyExists = await read(ELEVENLABS_API_KEY, true); - if (keyExists === true) { - setHasElevenLabsKey(true); + setSettings(currentSettings); + if ( + currentSettings.provider === DICTATION_PROVIDER_ELEVENLABS && + elevenLabsKeyCache === null + ) { + try { + const response = await read(ELEVENLABS_API_KEY, true); + const hasKey = isSecretKeyConfigured(response); + elevenLabsKeyCache = hasKey; + setHasElevenLabsKey(hasKey); + } catch (error) { + elevenLabsKeyCache = false; + setHasElevenLabsKey(false); + console.error('[useDictationSettings] Error checking ElevenLabs API key:', error); } - } catch (error) { - console.error('[useDictationSettings] Error loading ElevenLabs API key:', error); } }; diff --git a/ui/desktop/src/hooks/useWhisper.ts b/ui/desktop/src/hooks/useWhisper.ts index 385f674e9423..0cc557f807e6 100644 --- a/ui/desktop/src/hooks/useWhisper.ts +++ b/ui/desktop/src/hooks/useWhisper.ts @@ -2,6 +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 { safeJsonParse } from '../utils/conversionUtils'; interface UseWhisperOptions { @@ -77,7 +78,7 @@ export const useWhisper = ({ onTranscription, onError, onSizeWarning }: UseWhisp case 'openai': setCanUseDictation(hasOpenAIKey); break; - case 'elevenlabs': + case DICTATION_PROVIDER_ELEVENLABS: setCanUseDictation(hasElevenLabsKey); break; default: @@ -180,7 +181,7 @@ export const useWhisper = ({ onTranscription, onError, onSizeWarning }: UseWhisp case 'openai': endpoint = '/audio/transcribe'; break; - case 'elevenlabs': + case DICTATION_PROVIDER_ELEVENLABS: endpoint = '/audio/transcribe/elevenlabs'; break; default: @@ -373,5 +374,6 @@ export const useWhisper = ({ onTranscription, onError, onSizeWarning }: UseWhisp stopRecording, recordingDuration, estimatedSize, + dictationSettings, }; };