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
25 changes: 19 additions & 6 deletions ui/desktop/src/components/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1006,7 +1006,7 @@ export default function ChatInput({
{/* Inline action buttons on the right */}
<div className="flex items-center gap-1 px-2 relative">
{/* Microphone button - show if dictation is enabled, disable if not configured */}
{dictationSettings?.enabled && (
{(dictationSettings?.enabled || dictationSettings?.provider === null) && (
<>
{!canUseDictation ? (
<Tooltip>
Expand All @@ -1026,11 +1026,24 @@ export default function ChatInput({
</span>
</TooltipTrigger>
<TooltipContent>
{dictationSettings.provider === 'openai'
? 'OpenAI API key is not configured. Set it up in Settings > Models.'
: dictationSettings.provider === 'elevenlabs'
? 'ElevenLabs API key is not configured. Set it up in Settings > Chat > Voice Dictation.'
: 'Dictation provider is not properly configured.'}
{dictationSettings.provider === 'openai' ? (
<p>
OpenAI API key is not configured. Set it up in <b>Settings</b> {'>'}{' '}
<b>Models.</b>
</p>
) : dictationSettings.provider === 'elevenlabs' ? (
<p>
ElevenLabs API key is not configured. Set it up in <b>Settings</b> {'>'}{' '}
<b>Chat</b> {'>'} <b>Voice Dictation.</b>
</p>
) : dictationSettings.provider === null ? (
<p>
Dictation is not configured. Configure it in <b>Settings</b> {'>'}{' '}
<b>Chat</b> {'>'} <b>Voice Dictation.</b>
</p>
) : (
<p>Dictation provider is not properly configured.</p>
)}
</TooltipContent>
</Tooltip>
) : (
Expand Down
20 changes: 9 additions & 11 deletions ui/desktop/src/components/settings/dictation/DictationSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,15 @@ import { Switch } from '../../ui/switch';
import { ChevronDown } from 'lucide-react';
import { Input } from '../../ui/input';
import { useConfig } from '../../ConfigContext';

type DictationProvider = 'openai' | 'elevenlabs';

interface DictationSettings {
enabled: boolean;
provider: DictationProvider;
}
import { DictationProvider, DictationSettings } from '../../../hooks/useDictationSettings';

const DICTATION_SETTINGS_KEY = 'dictation_settings';
const ELEVENLABS_API_KEY = 'ELEVENLABS_API_KEY';

export default function DictationSection() {
const [settings, setSettings] = useState<DictationSettings>({
enabled: true,
provider: 'openai',
enabled: false,
provider: null,
});
const [hasOpenAIKey, setHasOpenAIKey] = useState(false);
const [showProviderDropdown, setShowProviderDropdown] = useState(false);
Expand Down Expand Up @@ -120,7 +114,11 @@ export default function DictationSection() {
};

const handleToggle = (enabled: boolean) => {
saveSettings({ ...settings, enabled });
saveSettings({
...settings,
enabled,
provider: settings.provider === null ? 'openai' : settings.provider,
});
};

const handleProviderChange = (provider: DictationProvider) => {
Expand Down Expand Up @@ -157,7 +155,7 @@ export default function DictationSection() {
case 'elevenlabs':
return 'ElevenLabs';
default:
return provider;
return 'None (disabled)';
}
};

Expand Down
26 changes: 17 additions & 9 deletions ui/desktop/src/hooks/useDictationSettings.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react';
import { useConfig } from '../components/ConfigContext';

export type DictationProvider = 'openai' | 'elevenlabs';
export type DictationProvider = 'openai' | 'elevenlabs' | null;

export interface DictationSettings {
enabled: boolean;
Expand All @@ -14,7 +14,7 @@ const ELEVENLABS_API_KEY = 'ELEVENLABS_API_KEY';
export const useDictationSettings = () => {
const [settings, setSettings] = useState<DictationSettings | null>(null);
const [hasElevenLabsKey, setHasElevenLabsKey] = useState<boolean>(false);
const { read } = useConfig();
const { read, getProviders } = useConfig();

useEffect(() => {
const loadSettings = async () => {
Expand All @@ -23,12 +23,20 @@ export const useDictationSettings = () => {
if (saved) {
setSettings(JSON.parse(saved));
} else {
// Default settings
const defaultSettings: DictationSettings = {
enabled: true,
provider: 'openai',
};
setSettings(defaultSettings);
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) {
setSettings({
enabled: true,
provider: 'openai',
});
} else {
setSettings({
enabled: false,
provider: null,
});
}
}

// Load ElevenLabs API key from storage (non-secret for frontend access)
Expand All @@ -54,7 +62,7 @@ export const useDictationSettings = () => {

window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [read]);
}, [read, getProviders]);

return { settings, hasElevenLabsKey };
};
93 changes: 49 additions & 44 deletions ui/desktop/src/hooks/useWhisper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,54 @@ export const useWhisper = ({ onTranscription, onError, onSizeWarning }: UseWhisp
}
}, [dictationSettings, hasOpenAIKey, hasElevenLabsKey]);

// Define stopRecording before startRecording to avoid circular dependency
const stopRecording = useCallback(() => {
setIsRecording(false); // Always update the visual state

if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop();
}

// Clear interval
if (durationIntervalRef.current) {
clearInterval(durationIntervalRef.current);
durationIntervalRef.current = null;
}

// Stop all tracks in the stream
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop());
streamRef.current = null;
}

// Close audio context
if (audioContext && audioContext.state !== 'closed') {
audioContext.close().catch(console.error);
setAudioContext(null);
setAnalyser(null);
}
}, [audioContext]);

// Cleanup effect to prevent memory leaks
useEffect(() => {
return () => {
// Cleanup on unmount
if (durationIntervalRef.current) {
clearInterval(durationIntervalRef.current);
}
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop());
}
if (audioContext && audioContext.state !== 'closed') {
audioContext.close().catch(console.error);
}
};
}, [audioContext]);

const transcribeAudio = useCallback(
async (audioBlob: Blob) => {
if (!dictationSettings) {
stopRecording();
onError?.(new Error('Dictation settings not loaded'));
return;
}
Expand Down Expand Up @@ -169,61 +214,20 @@ export const useWhisper = ({ onTranscription, onError, onSizeWarning }: UseWhisp
}
} catch (error) {
console.error('Error transcribing audio:', error);
stopRecording();
onError?.(error as Error);
} finally {
setIsTranscribing(false);
setRecordingDuration(0);
setEstimatedSize(0);
}
},
[onTranscription, onError, dictationSettings]
[onTranscription, onError, dictationSettings, stopRecording]
);

// Define stopRecording before startRecording to avoid circular dependency
const stopRecording = useCallback(() => {
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop();
setIsRecording(false);
}

// Clear interval
if (durationIntervalRef.current) {
clearInterval(durationIntervalRef.current);
durationIntervalRef.current = null;
}

// Stop all tracks in the stream
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop());
streamRef.current = null;
}

// Close audio context
if (audioContext && audioContext.state !== 'closed') {
audioContext.close().catch(console.error);
setAudioContext(null);
setAnalyser(null);
}
}, [audioContext]);

// Cleanup effect to prevent memory leaks
useEffect(() => {
return () => {
// Cleanup on unmount
if (durationIntervalRef.current) {
clearInterval(durationIntervalRef.current);
}
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop());
}
if (audioContext && audioContext.state !== 'closed') {
audioContext.close().catch(console.error);
}
};
}, [audioContext]);

const startRecording = useCallback(async () => {
if (!dictationSettings) {
stopRecording();
onError?.(new Error('Dictation settings not loaded'));
return;
}
Expand Down Expand Up @@ -301,6 +305,7 @@ export const useWhisper = ({ onTranscription, onError, onSizeWarning }: UseWhisp
setIsRecording(true);
} catch (error) {
console.error('Error starting recording:', error);
stopRecording();
onError?.(error as Error);
}
}, [onError, onSizeWarning, transcribeAudio, stopRecording, dictationSettings]);
Expand Down
Loading