Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 23 additions & 15 deletions ui/desktop/src/components/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -265,6 +265,7 @@ export default function ChatInput({
stopRecording,
recordingDuration,
estimatedSize,
dictationSettings,
} = useWhisper({
onTranscription: (text) => {
trackVoiceDictation('transcribed');
Expand All @@ -289,8 +290,6 @@ export default function ChatInput({
});
},
});

const { settings: dictationSettings } = useDictationSettings();
const internalTextAreaRef = useRef<HTMLTextAreaElement>(null);
const textAreaRef = inputRef || internalTextAreaRef;
const timeoutRefsRef = useRef<Set<ReturnType<typeof setTimeout>>>(new Set());
Expand Down Expand Up @@ -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('');
Expand Down Expand Up @@ -1189,7 +1191,13 @@ export default function ChatInput({
onDrop={handleLocalDrop}
onDragOver={handleLocalDragOver}
>
<input ref={fileInputRef} type="file" onChange={handleFileInputChange} style={{ display: 'none' }} accept="*/*" />
<input
ref={fileInputRef}
type="file"
onChange={handleFileInputChange}
style={{ display: 'none' }}
accept="*/*"
/>
{/* Message Queue Display */}
{queuedMessages.length > 0 && (
<MessageQueue
Expand Down Expand Up @@ -1270,7 +1278,7 @@ export default function ChatInput({
<b>Models.</b>
</p>
) : VOICE_DICTATION_ELEVENLABS_ENABLED &&
dictationSettings.provider === 'elevenlabs' ? (
dictationSettings.provider === DICTATION_PROVIDER_ELEVENLABS ? (
<p>
ElevenLabs API key is not configured. Set it up in <b>Settings</b> {'>'}{' '}
<b>Chat</b> {'>'} <b>Voice Dictation.</b>
Expand Down
33 changes: 17 additions & 16 deletions ui/desktop/src/components/ConfigContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
const [extensionsList, setExtensionsList] = useState<FixedExtensionEntry[]>([]);
const [extensionWarnings, setExtensionWarnings] = useState<string[]>([]);

// Ref to access providersList in getProviders without recreating the callback
const providersListRef = React.useRef<ProviderDetails[]>(providersList);
providersListRef.current = providersList;

const reloadConfig = useCallback(async () => {
const response = await readAllConfig();
setConfig(response.data?.config || {});
Expand Down Expand Up @@ -168,23 +172,20 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
[addExtension, getExtensions]
);

const getProviders = useCallback(
async (forceRefresh = false): Promise<ProviderDetails[]> => {
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<ProviderDetails[]> => {
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<string[]> => {
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS
const [pendingSort, setPendingSort] = useState(false);
const [togglingExtension, setTogglingExtension] = useState<string | null>(null);
const [refreshTrigger, setRefreshTrigger] = useState(0);
const [isSessionExtensionsLoaded, setIsSessionExtensionsLoaded] = useState(false);
const sortTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const { extensionsList: allExtensions } = useConfig();
const isHubView = !sessionId;
Expand Down Expand Up @@ -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]);

Expand Down Expand Up @@ -225,7 +229,7 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS
>
<DropdownMenuTrigger asChild>
<button
className="flex items-center cursor-pointer [&_svg]:size-4 text-text-default/70 hover:text-text-default hover:scale-100 hover:bg-transparent text-xs"
className={`flex items-center [&_svg]:size-4 text-text-default/70 hover:text-text-default hover:scale-100 hover:bg-transparent text-xs cursor-pointer ${allExtensions.length === 0 || (!isHubView && !isSessionExtensionsLoaded) ? 'invisible' : ''}`}
title="manage extensions"
>
<Puzzle className="mr-1 h-4 w-4" />
Expand Down
25 changes: 15 additions & 10 deletions ui/desktop/src/components/settings/dictation/ElevenLabsKeyInput.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { useState, useEffect, useRef } from 'react';
import { Input } from '../../ui/input';
import { useConfig } from '../../ConfigContext';
import { ELEVENLABS_API_KEY } from '../../../hooks/dictationConstants';
import { ELEVENLABS_API_KEY, isSecretKeyConfigured } from '../../../hooks/dictationConstants';
import { setElevenLabsKeyCache } from '../../../hooks/useDictationSettings';

export const ElevenLabsKeyInput = () => {
const [elevenLabsApiKey, setElevenLabsApiKey] = useState('');
Expand All @@ -14,12 +15,16 @@ export const ElevenLabsKeyInput = () => {
const loadKey = async () => {
setIsLoadingKey(true);
try {
const keyExists = await read(ELEVENLABS_API_KEY, true);
if (keyExists === true) {
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);
}
Expand All @@ -34,9 +39,11 @@ export const ElevenLabsKeyInput = () => {
if (elevenLabsApiKeyRef.current) {
const keyToSave = elevenLabsApiKeyRef.current;
if (keyToSave.trim()) {
upsert(ELEVENLABS_API_KEY, keyToSave, true).catch((error) => {
console.error('Error saving ElevenLabs API key on unmount:', error);
});
upsert(ELEVENLABS_API_KEY, keyToSave, true)
.then(() => setElevenLabsKeyCache(true))
.catch((error) => {
console.error('Error saving ElevenLabs API key on unmount:', error);
});
}
}
};
Expand All @@ -53,15 +60,13 @@ export const ElevenLabsKeyInput = () => {
const saveElevenLabsKey = async () => {
try {
if (elevenLabsApiKey.trim()) {
console.log('Saving ElevenLabs API key to secure storage...');
await upsert(ELEVENLABS_API_KEY, elevenLabsApiKey, true);
setHasElevenLabsKey(true);
console.log('ElevenLabs API key saved successfully');
setElevenLabsKeyCache(true);
} else {
console.log('Removing ElevenLabs API key from secure storage...');
await upsert(ELEVENLABS_API_KEY, null, true);
setHasElevenLabsKey(false);
console.log('ElevenLabs API key removed successfully');
setElevenLabsKeyCache(false);
}
} catch (error) {
console.error('Error saving ElevenLabs API key:', error);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { DictationProvider } from '../../../hooks/useDictationSettings';
import { DICTATION_PROVIDER_ELEVENLABS } from '../../../hooks/dictationConstants';
import { VOICE_DICTATION_ELEVENLABS_ENABLED } from '../../../updates';

interface ProviderInfoProps {
Expand All @@ -16,7 +17,7 @@ export const ProviderInfo = ({ provider }: ProviderInfoProps) => {
configured in the Models section.
</p>
)}
{VOICE_DICTATION_ELEVENLABS_ENABLED && provider === 'elevenlabs' && (
{VOICE_DICTATION_ELEVENLABS_ENABLED && provider === DICTATION_PROVIDER_ELEVENLABS && (
<div>
<p className="text-xs text-text-muted">
Uses ElevenLabs speech-to-text API for high-quality transcription.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import { ChevronDown } from 'lucide-react';
import { DictationProvider, DictationSettings } from '../../../hooks/useDictationSettings';
import { DICTATION_PROVIDER_ELEVENLABS } from '../../../hooks/dictationConstants';
import { useConfig } from '../../ConfigContext';
import { ElevenLabsKeyInput } from './ElevenLabsKeyInput';
import { ProviderInfo } from './ProviderInfo';
Expand Down Expand Up @@ -57,7 +58,7 @@ export const ProviderSelector = ({ settings, onProviderChange }: ProviderSelecto
switch (provider) {
case 'openai':
return 'OpenAI Whisper';
case 'elevenlabs':
case DICTATION_PROVIDER_ELEVENLABS:
return 'ElevenLabs';
default:
return 'None (disabled)';
Expand Down Expand Up @@ -95,21 +96,22 @@ export const ProviderSelector = ({ settings, onProviderChange }: ProviderSelecto

{VOICE_DICTATION_ELEVENLABS_ENABLED && (
<button
onClick={() => handleProviderChange('elevenlabs')}
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"
>
ElevenLabs
{settings.provider === 'elevenlabs' && <span className="float-right">✓</span>}
{settings.provider === DICTATION_PROVIDER_ELEVENLABS && (
<span className="float-right">✓</span>
)}
</button>
)}
</div>
)}
</div>
</div>

{VOICE_DICTATION_ELEVENLABS_ENABLED && settings.provider === 'elevenlabs' && (
<ElevenLabsKeyInput />
)}
{VOICE_DICTATION_ELEVENLABS_ENABLED &&
settings.provider === DICTATION_PROVIDER_ELEVENLABS && <ElevenLabsKeyInput />}

<ProviderInfo provider={settings.provider} />
</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_ELEVENLABS,
getDefaultDictationSettings,
} from '../../../hooks/dictationConstants';
import { useConfig } from '../../ConfigContext';
Expand All @@ -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',
Expand Down
7 changes: 7 additions & 0 deletions ui/desktop/src/hooks/dictationConstants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Array<{ name: string; is_configured: boolean }>>
Expand Down
41 changes: 27 additions & 14 deletions ui/desktop/src/hooks/useDictationSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,42 +3,55 @@ 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<DictationSettings | null>(null);
const [hasElevenLabsKey, setHasElevenLabsKey] = useState<boolean>(false);
const [hasElevenLabsKey, setHasElevenLabsKey] = useState<boolean>(elevenLabsKeyCache ?? false);
const { read, getProviders } = useConfig();

useEffect(() => {
const loadSettings = async () => {
// 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);
}
};

Expand Down
Loading
Loading