Rejig dictation (#6844)
Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -190,6 +190,35 @@ export type DetectProviderResponse = {
|
||||
provider_name: string;
|
||||
};
|
||||
|
||||
export type DictationProvider = 'openai' | 'elevenlabs';
|
||||
|
||||
export type DictationProviderStatus = {
|
||||
/**
|
||||
* Config key name if uses_provider_config is false
|
||||
*/
|
||||
config_key?: string | null;
|
||||
/**
|
||||
* Whether the provider is fully configured and ready to use
|
||||
*/
|
||||
configured: boolean;
|
||||
/**
|
||||
* Description of what this provider does
|
||||
*/
|
||||
description: string;
|
||||
/**
|
||||
* Custom host URL if configured (only for providers that support it)
|
||||
*/
|
||||
host?: string | null;
|
||||
/**
|
||||
* Path to settings if uses_provider_config is true
|
||||
*/
|
||||
settings_path?: string | null;
|
||||
/**
|
||||
* Whether this provider uses the main provider config (true) or has its own key (false)
|
||||
*/
|
||||
uses_provider_config: boolean;
|
||||
};
|
||||
|
||||
export type EmbeddedResource = {
|
||||
_meta?: {
|
||||
[key: string]: unknown;
|
||||
@@ -1173,6 +1202,25 @@ export type ToolResponse = {
|
||||
};
|
||||
};
|
||||
|
||||
export type TranscribeRequest = {
|
||||
/**
|
||||
* Base64 encoded audio data
|
||||
*/
|
||||
audio: string;
|
||||
/**
|
||||
* MIME type of the audio (e.g., "audio/webm", "audio/wav")
|
||||
*/
|
||||
mime_type: string;
|
||||
provider: DictationProvider;
|
||||
};
|
||||
|
||||
export type TranscribeResponse = {
|
||||
/**
|
||||
* Transcribed text from the audio
|
||||
*/
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type TunnelInfo = {
|
||||
hostname: string;
|
||||
secret: string;
|
||||
@@ -2456,6 +2504,79 @@ export type DiagnosticsResponses = {
|
||||
|
||||
export type DiagnosticsResponse = DiagnosticsResponses[keyof DiagnosticsResponses];
|
||||
|
||||
export type GetDictationConfigData = {
|
||||
body?: never;
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/dictation/config';
|
||||
};
|
||||
|
||||
export type GetDictationConfigResponses = {
|
||||
/**
|
||||
* Audio transcription provider configurations
|
||||
*/
|
||||
200: {
|
||||
[key: string]: DictationProviderStatus;
|
||||
};
|
||||
};
|
||||
|
||||
export type GetDictationConfigResponse = GetDictationConfigResponses[keyof GetDictationConfigResponses];
|
||||
|
||||
export type TranscribeDictationData = {
|
||||
body: TranscribeRequest;
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/dictation/transcribe';
|
||||
};
|
||||
|
||||
export type TranscribeDictationErrors = {
|
||||
/**
|
||||
* Invalid request (bad base64 or unsupported format)
|
||||
*/
|
||||
400: unknown;
|
||||
/**
|
||||
* Invalid API key
|
||||
*/
|
||||
401: unknown;
|
||||
/**
|
||||
* DictationProvider not configured
|
||||
*/
|
||||
412: unknown;
|
||||
/**
|
||||
* Audio file too large (max 25MB)
|
||||
*/
|
||||
413: unknown;
|
||||
/**
|
||||
* Rate limit exceeded
|
||||
*/
|
||||
429: unknown;
|
||||
/**
|
||||
* Internal server error
|
||||
*/
|
||||
500: unknown;
|
||||
/**
|
||||
* DictationProvider API error
|
||||
*/
|
||||
502: unknown;
|
||||
/**
|
||||
* Service unavailable
|
||||
*/
|
||||
503: unknown;
|
||||
/**
|
||||
* Request timeout
|
||||
*/
|
||||
504: unknown;
|
||||
};
|
||||
|
||||
export type TranscribeDictationResponses = {
|
||||
/**
|
||||
* Audio transcribed successfully
|
||||
*/
|
||||
200: TranscribeResponse;
|
||||
};
|
||||
|
||||
export type TranscribeDictationResponse = TranscribeDictationResponses[keyof TranscribeDictationResponses];
|
||||
|
||||
export type StartOpenrouterSetupData = {
|
||||
body?: never;
|
||||
path?: never;
|
||||
|
||||
@@ -16,12 +16,10 @@ import { BottomMenuExtensionSelection } from './bottom_menu/BottomMenuExtensionS
|
||||
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 { useAudioRecorder } from '../hooks/useAudioRecorder';
|
||||
import { toastError } from '../toasts';
|
||||
import MentionPopover, { DisplayItemWithMatch } from './MentionPopover';
|
||||
import { COST_TRACKING_ENABLED, VOICE_DICTATION_ELEVENLABS_ENABLED } from '../updates';
|
||||
import { COST_TRACKING_ENABLED } from '../updates';
|
||||
import { CostTracker } from './bottom_menu/CostTracker';
|
||||
import { DroppedFile, useFileDrop } from '../hooks/useFileDrop';
|
||||
import { Recipe } from '../recipe';
|
||||
@@ -254,19 +252,17 @@ export default function ChatInput({
|
||||
selectFile: (index: number) => void;
|
||||
}>(null);
|
||||
|
||||
// Whisper hook for voice dictation
|
||||
// Audio recorder hook for voice dictation
|
||||
const {
|
||||
isEnabled,
|
||||
dictationProvider,
|
||||
isRecording,
|
||||
isTranscribing,
|
||||
canUseDictation,
|
||||
audioContext,
|
||||
analyser,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
recordingDuration,
|
||||
estimatedSize,
|
||||
dictationSettings,
|
||||
} = useWhisper({
|
||||
} = useAudioRecorder({
|
||||
onTranscription: (text) => {
|
||||
trackVoiceDictation('transcribed');
|
||||
// Append transcribed text to the current input
|
||||
@@ -275,18 +271,12 @@ export default function ChatInput({
|
||||
setValue(newValue);
|
||||
textAreaRef.current?.focus();
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorType = error.name || 'DictationError';
|
||||
onError: (message) => {
|
||||
const errorType = 'DictationError';
|
||||
trackVoiceDictation('error', undefined, errorType);
|
||||
toastError({
|
||||
title: 'Dictation Error',
|
||||
msg: error.message,
|
||||
});
|
||||
},
|
||||
onSizeWarning: (sizeMB) => {
|
||||
toastError({
|
||||
title: 'Recording Size Warning',
|
||||
msg: `Recording is ${sizeMB.toFixed(1)}MB. Maximum size is 25MB.`,
|
||||
msg: message,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -1236,26 +1226,25 @@ export default function ChatInput({
|
||||
maxHeight: `${maxHeight}px`,
|
||||
overflowY: 'auto',
|
||||
opacity: isRecording ? 0 : 1,
|
||||
paddingRight: dictationSettings?.enabled ? '180px' : '120px',
|
||||
paddingRight: dictationProvider ? '180px' : '120px',
|
||||
}}
|
||||
className="w-full outline-none border-none focus:ring-0 bg-transparent px-3 pt-3 pb-1.5 text-sm resize-none text-textStandard placeholder:text-textPlaceholder"
|
||||
/>
|
||||
{isRecording && (
|
||||
<div className="absolute inset-0 flex items-center pl-4 pr-32 pt-3 pb-1.5">
|
||||
<WaveformVisualizer
|
||||
audioContext={audioContext}
|
||||
analyser={analyser}
|
||||
isRecording={isRecording}
|
||||
/>
|
||||
<div className="flex items-center gap-2 text-textSubtle">
|
||||
<span className="inline-block w-2 h-2 bg-red-500 rounded-full animate-pulse" />
|
||||
<span>Recording...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inline action buttons - absolutely positioned on the right */}
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
|
||||
{/* Microphone button - show only if dictation is enabled */}
|
||||
{dictationSettings?.enabled && (
|
||||
{/* Microphone button - show only if provider is selected */}
|
||||
{dictationProvider && (
|
||||
<>
|
||||
{!canUseDictation ? (
|
||||
{!isEnabled ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex">
|
||||
@@ -1273,22 +1262,16 @@ export default function ChatInput({
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{dictationSettings.provider === 'openai' ? (
|
||||
{dictationProvider === 'openai' ? (
|
||||
<p>
|
||||
OpenAI API key is not configured. Set it up in <b>Settings</b> {'>'}{' '}
|
||||
<b>Models.</b>
|
||||
</p>
|
||||
) : VOICE_DICTATION_ELEVENLABS_ENABLED &&
|
||||
dictationSettings.provider === DICTATION_PROVIDER_ELEVENLABS ? (
|
||||
) : dictationProvider === '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>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ModeSection } from '../mode/ModeSection';
|
||||
import DictationSection from '../dictation/DictationSection';
|
||||
import { DictationSettings } from '../dictation/DictationSettings';
|
||||
import { SecurityToggle } from '../security/SecurityToggle';
|
||||
import { ResponseStylesSection } from '../response_styles/ResponseStylesSection';
|
||||
import { GoosehintsSection } from './GoosehintsSection';
|
||||
@@ -21,7 +21,14 @@ export default function ChatSettingsSection() {
|
||||
|
||||
<Card className="pb-2 rounded-lg">
|
||||
<CardContent className="px-2">
|
||||
<SecurityToggle />
|
||||
<GoosehintsSection />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="pb-2 rounded-lg">
|
||||
<CardContent className="px-2">
|
||||
<DictationSettings />
|
||||
<SpellcheckToggle />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -37,14 +44,7 @@ export default function ChatSettingsSection() {
|
||||
|
||||
<Card className="pb-2 rounded-lg">
|
||||
<CardContent className="px-2">
|
||||
<DictationSection />
|
||||
<SpellcheckToggle />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="pb-2 rounded-lg">
|
||||
<CardContent className="px-2">
|
||||
<GoosehintsSection />
|
||||
<SecurityToggle />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { VoiceDictationToggle } from './VoiceDictationToggle';
|
||||
|
||||
export default function DictationSection() {
|
||||
return <VoiceDictationToggle />;
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { DictationProvider, getDictationConfig, DictationProviderStatus } from '../../../api';
|
||||
import { useConfig } from '../../ConfigContext';
|
||||
import { Input } from '../../ui/input';
|
||||
import { Button } from '../../ui/button';
|
||||
import { trackSettingToggled } from '../../../utils/analytics';
|
||||
|
||||
export const DictationSettings = () => {
|
||||
const [provider, setProvider] = useState<DictationProvider | null>(null);
|
||||
const [showProviderDropdown, setShowProviderDropdown] = useState(false);
|
||||
const [providerStatuses, setProviderStatuses] = useState<Record<string, DictationProviderStatus>>(
|
||||
{}
|
||||
);
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [isEditingKey, setIsEditingKey] = useState(false);
|
||||
const [keyValidationError, setKeyValidationError] = useState('');
|
||||
const { read, upsert, remove } = useConfig();
|
||||
|
||||
useEffect(() => {
|
||||
const loadSettings = async () => {
|
||||
const providerValue = await read('voice_dictation_provider', false);
|
||||
const loadedProvider: DictationProvider | null = (providerValue as DictationProvider) || null;
|
||||
setProvider(loadedProvider);
|
||||
|
||||
const audioConfig = await getDictationConfig();
|
||||
setProviderStatuses(audioConfig.data || {});
|
||||
};
|
||||
|
||||
loadSettings();
|
||||
}, [read]);
|
||||
|
||||
const saveProvider = async (newProvider: DictationProvider | null) => {
|
||||
console.log('Saving dictation provider to backend config:', newProvider);
|
||||
setProvider(newProvider);
|
||||
await upsert('voice_dictation_provider', newProvider || '', false);
|
||||
trackSettingToggled('voice_dictation', newProvider !== null);
|
||||
};
|
||||
|
||||
const handleProviderChange = (newProvider: DictationProvider | null) => {
|
||||
saveProvider(newProvider);
|
||||
setShowProviderDropdown(false);
|
||||
};
|
||||
|
||||
const handleDropdownToggle = async () => {
|
||||
const newShowState = !showProviderDropdown;
|
||||
setShowProviderDropdown(newShowState);
|
||||
|
||||
if (newShowState) {
|
||||
const audioConfig = await getDictationConfig();
|
||||
setProviderStatuses(audioConfig.data || {});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveKey = async () => {
|
||||
if (!provider) return;
|
||||
const providerConfig = providerStatuses[provider];
|
||||
if (!providerConfig || providerConfig.uses_provider_config) return;
|
||||
|
||||
const trimmedKey = apiKey.trim();
|
||||
if (!trimmedKey) {
|
||||
setKeyValidationError('API key is required');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const keyName = providerConfig.config_key!;
|
||||
await upsert(keyName, trimmedKey, true);
|
||||
setApiKey('');
|
||||
setKeyValidationError('');
|
||||
setIsEditingKey(false);
|
||||
|
||||
const audioConfig = await getDictationConfig();
|
||||
setProviderStatuses(audioConfig.data || {});
|
||||
} catch (error) {
|
||||
console.error('Error saving API key:', error);
|
||||
setKeyValidationError('Failed to save API key');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveKey = async () => {
|
||||
if (!provider) return;
|
||||
const providerConfig = providerStatuses[provider];
|
||||
if (!providerConfig || providerConfig.uses_provider_config) return;
|
||||
|
||||
try {
|
||||
const keyName = providerConfig.config_key!;
|
||||
await remove(keyName, true);
|
||||
setApiKey('');
|
||||
setKeyValidationError('');
|
||||
setIsEditingKey(false);
|
||||
|
||||
const audioConfig = await getDictationConfig();
|
||||
setProviderStatuses(audioConfig.data || {});
|
||||
} catch (error) {
|
||||
console.error('Error removing API key:', error);
|
||||
setKeyValidationError('Failed to remove API key');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setApiKey('');
|
||||
setKeyValidationError('');
|
||||
setIsEditingKey(false);
|
||||
};
|
||||
|
||||
const getProviderLabel = (provider: DictationProvider | null): string => {
|
||||
if (!provider) return 'Disabled';
|
||||
return provider.charAt(0).toUpperCase() + provider.slice(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between py-2 px-2 hover:bg-background-muted rounded-lg transition-all">
|
||||
<div>
|
||||
<h3 className="text-text-default">Voice Dictation Provider</h3>
|
||||
<p className="text-xs text-text-muted max-w-md mt-[2px]">
|
||||
Choose how voice is converted to text
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={handleDropdownToggle}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-sm border border-border-subtle rounded-md hover:border-border-default transition-colors text-text-default bg-background-default"
|
||||
>
|
||||
{getProviderLabel(provider)}
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{showProviderDropdown && (
|
||||
<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(null)}
|
||||
className="w-full px-3 py-2 text-left text-sm transition-colors hover:bg-background-subtle text-text-default whitespace-nowrap first:rounded-t-md"
|
||||
>
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<span>Disabled</span>
|
||||
{provider === null && <span>✓</span>}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{(Object.keys(providerStatuses) as DictationProvider[]).map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => handleProviderChange(p)}
|
||||
className="w-full px-3 py-2 text-left text-sm transition-colors hover:bg-background-subtle text-text-default whitespace-nowrap last:rounded-b-md"
|
||||
>
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<span>
|
||||
{getProviderLabel(p)}
|
||||
{!providerStatuses[p]?.configured && (
|
||||
<span className="text-xs ml-1 text-text-muted">(not configured)</span>
|
||||
)}
|
||||
</span>
|
||||
{provider === p && <span>✓</span>}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{provider && providerStatuses[provider] && (
|
||||
<>
|
||||
<div className="py-2 px-2">
|
||||
<p className="text-xs text-text-muted">{providerStatuses[provider].description}</p>
|
||||
</div>
|
||||
|
||||
{providerStatuses[provider].uses_provider_config ? (
|
||||
<div className="py-2 px-2 bg-background-subtle rounded-lg">
|
||||
{!providerStatuses[provider].configured ? (
|
||||
<p className="text-xs text-text-muted">
|
||||
Configure the API key in <b>{providerStatuses[provider].settings_path}</b>
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-green-600">
|
||||
✓ Configured in {providerStatuses[provider].settings_path}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-2 px-2 bg-background-subtle rounded-lg">
|
||||
<div className="mb-2">
|
||||
<h4 className="text-text-default text-sm">API Key</h4>
|
||||
<p className="text-xs text-text-muted mt-[2px]">
|
||||
Required for transcription
|
||||
{providerStatuses[provider]?.configured && (
|
||||
<span className="text-green-600 ml-2">(Configured)</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!isEditingKey ? (
|
||||
<Button variant="outline" size="sm" onClick={() => setIsEditingKey(true)}>
|
||||
{providerStatuses[provider]?.configured ? 'Update API Key' : 'Add API Key'}
|
||||
</Button>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => {
|
||||
setApiKey(e.target.value);
|
||||
if (keyValidationError) setKeyValidationError('');
|
||||
}}
|
||||
placeholder="Enter your API key"
|
||||
className="max-w-md"
|
||||
autoFocus
|
||||
/>
|
||||
{keyValidationError && (
|
||||
<p className="text-xs text-red-600 mt-1">{keyValidationError}</p>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={handleSaveKey}>
|
||||
Save
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleCancelEdit}>
|
||||
Cancel
|
||||
</Button>
|
||||
{providerStatuses[provider]?.configured && (
|
||||
<Button variant="destructive" size="sm" onClick={handleRemoveKey}>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,128 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
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';
|
||||
|
||||
export const ElevenLabsKeyInput = () => {
|
||||
const [elevenLabsApiKey, setElevenLabsApiKey] = useState('');
|
||||
const [isLoadingKey, setIsLoadingKey] = useState(false);
|
||||
const [hasElevenLabsKey, setHasElevenLabsKey] = useState(false);
|
||||
const [validationError, setValidationError] = useState('');
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const { upsert, read, remove } = useConfig();
|
||||
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
loadKey();
|
||||
}, [loadKey]);
|
||||
|
||||
const handleElevenLabsKeyChange = (key: string) => {
|
||||
setElevenLabsApiKey(key);
|
||||
if (validationError) {
|
||||
setValidationError('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
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();
|
||||
} catch (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">
|
||||
<h4 className="text-text-default text-sm">ElevenLabs API Key</h4>
|
||||
<p className="text-xs text-text-muted mt-[2px]">
|
||||
Required for ElevenLabs voice recognition
|
||||
{hasElevenLabsKey && <span className="text-green-600 ml-2">(Configured)</span>}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!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>
|
||||
);
|
||||
};
|
||||
@@ -1,41 +0,0 @@
|
||||
import { DictationProvider } from '../../../hooks/useDictationSettings';
|
||||
import { DICTATION_PROVIDER_ELEVENLABS } from '../../../hooks/dictationConstants';
|
||||
import { VOICE_DICTATION_ELEVENLABS_ENABLED } from '../../../updates';
|
||||
|
||||
interface ProviderInfoProps {
|
||||
provider: DictationProvider;
|
||||
}
|
||||
|
||||
export const ProviderInfo = ({ provider }: ProviderInfoProps) => {
|
||||
if (!provider) return null;
|
||||
|
||||
return (
|
||||
<div className="p-3 bg-background-subtle rounded-md">
|
||||
{provider === 'openai' && (
|
||||
<p className="text-xs text-text-muted">
|
||||
Uses OpenAI's Whisper API for high-quality transcription. Requires an OpenAI API key
|
||||
configured in the Models section.
|
||||
</p>
|
||||
)}
|
||||
{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.
|
||||
</p>
|
||||
<p className="text-xs text-text-muted mt-2">
|
||||
<strong>Features:</strong>
|
||||
</p>
|
||||
<ul className="text-xs text-text-muted ml-4 mt-1 list-disc">
|
||||
<li>Advanced voice processing</li>
|
||||
<li>High accuracy transcription</li>
|
||||
<li>Multiple language support</li>
|
||||
<li>Fast processing</li>
|
||||
</ul>
|
||||
<p className="text-xs text-text-muted mt-2">
|
||||
<strong>Note:</strong> Requires an ElevenLabs API key with speech-to-text access.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,128 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { DictationProvider, DictationSettings } from '../../../hooks/useDictationSettings';
|
||||
import {
|
||||
DICTATION_PROVIDER_OPENAI,
|
||||
DICTATION_PROVIDER_ELEVENLABS,
|
||||
} from '../../../hooks/dictationConstants';
|
||||
import { useConfig } from '../../ConfigContext';
|
||||
import { ElevenLabsKeyInput } from './ElevenLabsKeyInput';
|
||||
import { ProviderInfo } from './ProviderInfo';
|
||||
import { VOICE_DICTATION_ELEVENLABS_ENABLED } from '../../../updates';
|
||||
|
||||
interface ProviderSelectorProps {
|
||||
settings: DictationSettings;
|
||||
onProviderChange: (provider: DictationProvider) => void;
|
||||
}
|
||||
|
||||
export const ProviderSelector = ({ settings, onProviderChange }: ProviderSelectorProps) => {
|
||||
const [hasOpenAIKey, setHasOpenAIKey] = useState(false);
|
||||
const [showProviderDropdown, setShowProviderDropdown] = useState(false);
|
||||
const { getProviders } = useConfig();
|
||||
|
||||
useEffect(() => {
|
||||
const checkOpenAIKey = async () => {
|
||||
try {
|
||||
const providers = await getProviders(false);
|
||||
const openAIProvider = providers.find((p) => p.name === 'openai');
|
||||
setHasOpenAIKey(openAIProvider?.is_configured || false);
|
||||
} catch (error) {
|
||||
console.error('Error checking OpenAI configuration:', error);
|
||||
setHasOpenAIKey(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkOpenAIKey();
|
||||
}, [getProviders]);
|
||||
|
||||
const handleDropdownToggle = async () => {
|
||||
const newShowState = !showProviderDropdown;
|
||||
setShowProviderDropdown(newShowState);
|
||||
|
||||
if (newShowState) {
|
||||
try {
|
||||
const providers = await getProviders(true);
|
||||
const openAIProvider = providers.find((p) => p.name === 'openai');
|
||||
const isConfigured = !!openAIProvider?.is_configured;
|
||||
setHasOpenAIKey(isConfigured);
|
||||
} catch (error) {
|
||||
console.error('Error checking OpenAI configuration:', error);
|
||||
setHasOpenAIKey(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleProviderChange = (provider: DictationProvider) => {
|
||||
onProviderChange(provider);
|
||||
setShowProviderDropdown(false);
|
||||
};
|
||||
|
||||
const getProviderLabel = (provider: DictationProvider): string => {
|
||||
switch (provider) {
|
||||
case DICTATION_PROVIDER_OPENAI:
|
||||
return 'OpenAI Whisper';
|
||||
case DICTATION_PROVIDER_ELEVENLABS:
|
||||
return 'ElevenLabs';
|
||||
default:
|
||||
return 'None (disabled)';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between py-2 px-2 hover:bg-background-muted rounded-lg transition-all">
|
||||
<div>
|
||||
<h3 className="text-text-default">Dictation Provider</h3>
|
||||
<p className="text-xs text-text-muted max-w-md mt-[2px]">
|
||||
Choose how voice is converted to text
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={handleDropdownToggle}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-sm border border-border-subtle rounded-md hover:border-border-default transition-colors text-text-default bg-background-default"
|
||||
>
|
||||
{getProviderLabel(settings.provider)}
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{showProviderDropdown && (
|
||||
<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(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'}`}
|
||||
>
|
||||
<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 whitespace-nowrap"
|
||||
>
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<span>ElevenLabs</span>
|
||||
{settings.provider === DICTATION_PROVIDER_ELEVENLABS && <span>✓</span>}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{VOICE_DICTATION_ELEVENLABS_ENABLED &&
|
||||
settings.provider === DICTATION_PROVIDER_ELEVENLABS && <ElevenLabsKeyInput />}
|
||||
|
||||
<ProviderInfo provider={settings.provider} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,97 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
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';
|
||||
import { useConfig } from '../../ConfigContext';
|
||||
import { ProviderSelector } from './ProviderSelector';
|
||||
import { VOICE_DICTATION_ELEVENLABS_ENABLED } from '../../../updates';
|
||||
import { trackSettingToggled } from '../../../utils/analytics';
|
||||
|
||||
export const VoiceDictationToggle = () => {
|
||||
const [settings, setSettings] = useState<DictationSettings>({
|
||||
enabled: false,
|
||||
provider: null,
|
||||
});
|
||||
const { getProviders } = useConfig();
|
||||
|
||||
useEffect(() => {
|
||||
const loadSettings = async () => {
|
||||
const savedSettings = localStorage.getItem(DICTATION_SETTINGS_KEY);
|
||||
|
||||
let loadedSettings: DictationSettings;
|
||||
|
||||
if (savedSettings) {
|
||||
const parsed = JSON.parse(savedSettings);
|
||||
loadedSettings = parsed;
|
||||
|
||||
// If ElevenLabs is disabled and user has it selected, reset to OpenAI
|
||||
if (
|
||||
!VOICE_DICTATION_ELEVENLABS_ENABLED &&
|
||||
loadedSettings.provider === DICTATION_PROVIDER_ELEVENLABS
|
||||
) {
|
||||
loadedSettings = {
|
||||
...loadedSettings,
|
||||
provider: DICTATION_PROVIDER_OPENAI,
|
||||
};
|
||||
localStorage.setItem(DICTATION_SETTINGS_KEY, JSON.stringify(loadedSettings));
|
||||
}
|
||||
} else {
|
||||
loadedSettings = await getDefaultDictationSettings(getProviders);
|
||||
}
|
||||
|
||||
setSettings(loadedSettings);
|
||||
};
|
||||
|
||||
loadSettings();
|
||||
}, [getProviders]);
|
||||
|
||||
const saveSettings = (newSettings: DictationSettings) => {
|
||||
console.log('Saving dictation settings to localStorage:', newSettings);
|
||||
setSettings(newSettings);
|
||||
localStorage.setItem(DICTATION_SETTINGS_KEY, JSON.stringify(newSettings));
|
||||
};
|
||||
|
||||
const handleToggle = (enabled: boolean) => {
|
||||
saveSettings({
|
||||
...settings,
|
||||
enabled,
|
||||
provider: settings.provider === null ? DICTATION_PROVIDER_OPENAI : settings.provider,
|
||||
});
|
||||
trackSettingToggled('voice_dictation', enabled);
|
||||
};
|
||||
|
||||
const handleProviderChange = (provider: DictationProvider) => {
|
||||
saveSettings({ ...settings, provider });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between py-2 px-2 hover:bg-background-muted rounded-lg transition-all">
|
||||
<div>
|
||||
<h3 className="text-text-default">Enable Voice Dictation</h3>
|
||||
<p className="text-xs text-text-muted max-w-md mt-[2px]">
|
||||
Show microphone button for voice input
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Switch checked={settings.enabled} onCheckedChange={handleToggle} variant="mono" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
<div className="space-y-3 pb-2">
|
||||
<ProviderSelector settings={settings} onProviderChange={handleProviderChange} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,31 +0,0 @@
|
||||
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 =>
|
||||
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 }>>
|
||||
): Promise<DictationSettings> => {
|
||||
const providers = await getProviders(false);
|
||||
const openAIProvider = providers.find((p) => p.name === 'openai');
|
||||
|
||||
if (openAIProvider && openAIProvider.is_configured) {
|
||||
return {
|
||||
enabled: true,
|
||||
provider: DICTATION_PROVIDER_OPENAI,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
enabled: false,
|
||||
provider: null as DictationProvider,
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,219 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { transcribeDictation, getDictationConfig, DictationProvider } from '../api';
|
||||
import { useConfig } from '../components/ConfigContext';
|
||||
import { errorMessage } from '../utils/conversionUtils';
|
||||
|
||||
interface UseAudioRecorderOptions {
|
||||
onTranscription: (text: string) => void;
|
||||
onError: (message: string) => void;
|
||||
}
|
||||
|
||||
const MAX_AUDIO_SIZE_MB = 25;
|
||||
const MAX_RECORDING_DURATION_SECONDS = 10 * 60;
|
||||
|
||||
export const useAudioRecorder = ({ onTranscription, onError }: UseAudioRecorderOptions) => {
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [isTranscribing, setIsTranscribing] = useState(false);
|
||||
const [recordingDuration, setRecordingDuration] = useState(0);
|
||||
const [estimatedSize, setEstimatedSize] = useState(0);
|
||||
const [isEnabled, setIsEnabled] = useState(false);
|
||||
const [provider, setProvider] = useState<DictationProvider | null>(null);
|
||||
|
||||
const { read } = useConfig();
|
||||
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const audioChunksRef = useRef<Blob[]>([]);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const durationIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const checkProviderConfig = async () => {
|
||||
try {
|
||||
const providerValue = await read('voice_dictation_provider', false);
|
||||
const preferredProvider = (providerValue as DictationProvider) || null;
|
||||
|
||||
if (!preferredProvider) {
|
||||
setIsEnabled(false);
|
||||
setProvider(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const audioConfigResponse = await getDictationConfig();
|
||||
const providerStatus = audioConfigResponse.data?.[preferredProvider];
|
||||
|
||||
setIsEnabled(!!providerStatus?.configured);
|
||||
setProvider(preferredProvider);
|
||||
} catch (error) {
|
||||
console.error('Error checking audio config:', error);
|
||||
setIsEnabled(false);
|
||||
setProvider(null);
|
||||
}
|
||||
};
|
||||
|
||||
checkProviderConfig();
|
||||
}, [read]);
|
||||
|
||||
const stopRecording = useCallback(() => {
|
||||
setIsRecording(false);
|
||||
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
|
||||
mediaRecorderRef.current.stop();
|
||||
}
|
||||
|
||||
if (durationIntervalRef.current) {
|
||||
clearInterval(durationIntervalRef.current);
|
||||
durationIntervalRef.current = null;
|
||||
}
|
||||
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach((track) => track.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (durationIntervalRef.current) {
|
||||
clearInterval(durationIntervalRef.current);
|
||||
}
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach((track) => track.stop());
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const transcribeAudio = useCallback(
|
||||
async (audioBlob: Blob) => {
|
||||
if (!provider) {
|
||||
onError('No transcription provider configured');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsTranscribing(true);
|
||||
|
||||
try {
|
||||
const sizeMB = audioBlob.size / (1024 * 1024);
|
||||
if (sizeMB > MAX_AUDIO_SIZE_MB) {
|
||||
onError(
|
||||
`Audio file too large (${sizeMB.toFixed(1)}MB). Maximum size is ${MAX_AUDIO_SIZE_MB}MB.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
const base64Audio = await new Promise<string>((resolve, reject) => {
|
||||
reader.onloadend = () => {
|
||||
const base64 = reader.result as string;
|
||||
resolve(base64.split(',')[1]);
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(audioBlob);
|
||||
});
|
||||
|
||||
const mimeType = audioBlob.type;
|
||||
if (!mimeType) {
|
||||
throw new Error('Unable to determine audio format');
|
||||
}
|
||||
|
||||
const result = await transcribeDictation({
|
||||
body: {
|
||||
audio: base64Audio,
|
||||
mime_type: mimeType,
|
||||
provider: provider,
|
||||
},
|
||||
throwOnError: true,
|
||||
});
|
||||
|
||||
if (result.data?.text) {
|
||||
onTranscription(result.data.text);
|
||||
}
|
||||
} catch (error) {
|
||||
onError(errorMessage(error));
|
||||
} finally {
|
||||
setIsTranscribing(false);
|
||||
setRecordingDuration(0);
|
||||
setEstimatedSize(0);
|
||||
}
|
||||
},
|
||||
[provider, onTranscription, onError]
|
||||
);
|
||||
|
||||
const startRecording = useCallback(async () => {
|
||||
if (!isEnabled) {
|
||||
onError('Voice dictation is not enabled');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
},
|
||||
});
|
||||
streamRef.current = stream;
|
||||
|
||||
const supportedTypes = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/wav'];
|
||||
const mimeType = supportedTypes.find((type) => MediaRecorder.isTypeSupported(type)) || '';
|
||||
|
||||
const mediaRecorder = new MediaRecorder(stream, mimeType ? { mimeType } : {});
|
||||
mediaRecorderRef.current = mediaRecorder;
|
||||
audioChunksRef.current = [];
|
||||
|
||||
const startTime = Date.now();
|
||||
durationIntervalRef.current = setInterval(() => {
|
||||
const elapsed = (Date.now() - startTime) / 1000;
|
||||
setRecordingDuration(elapsed);
|
||||
|
||||
const estimatedSizeMB = (elapsed * 128 * 1024) / (8 * 1024 * 1024);
|
||||
setEstimatedSize(estimatedSizeMB);
|
||||
|
||||
if (elapsed >= MAX_RECORDING_DURATION_SECONDS) {
|
||||
stopRecording();
|
||||
onError(
|
||||
`Maximum recording duration (${MAX_RECORDING_DURATION_SECONDS / 60} minutes) reached`
|
||||
);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
audioChunksRef.current.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
mediaRecorder.onstop = async () => {
|
||||
const audioBlob = new Blob(audioChunksRef.current, { type: mimeType || 'audio/webm' });
|
||||
|
||||
if (audioBlob.size === 0) {
|
||||
onError('No audio data was recorded. Please check your microphone.');
|
||||
return;
|
||||
}
|
||||
|
||||
await transcribeAudio(audioBlob);
|
||||
};
|
||||
|
||||
mediaRecorder.onerror = (_event) => {
|
||||
onError('Recording failed');
|
||||
};
|
||||
|
||||
mediaRecorder.start(100);
|
||||
setIsRecording(true);
|
||||
} catch (error) {
|
||||
stopRecording();
|
||||
onError(errorMessage(error));
|
||||
}
|
||||
}, [isEnabled, onError, transcribeAudio, stopRecording]);
|
||||
|
||||
return {
|
||||
isEnabled,
|
||||
dictationProvider: provider,
|
||||
isRecording,
|
||||
isTranscribing,
|
||||
recordingDuration,
|
||||
estimatedSize,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
};
|
||||
};
|
||||
@@ -1,76 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
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 =
|
||||
| typeof DICTATION_PROVIDER_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>(elevenLabsKeyCache ?? false);
|
||||
const { read, getProviders } = useConfig();
|
||||
|
||||
useEffect(() => {
|
||||
const loadSettings = async () => {
|
||||
const saved = localStorage.getItem(DICTATION_SETTINGS_KEY);
|
||||
|
||||
let currentSettings: DictationSettings;
|
||||
if (saved) {
|
||||
currentSettings = JSON.parse(saved);
|
||||
} else {
|
||||
currentSettings = await getDefaultDictationSettings(getProviders);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadSettings();
|
||||
|
||||
// Listen for storage changes from other tabs/windows
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const handleStorageChange = (e: any) => {
|
||||
if (e.key === DICTATION_SETTINGS_KEY && e.newValue) {
|
||||
setSettings(JSON.parse(e.newValue));
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
return () => window.removeEventListener('storage', handleStorageChange);
|
||||
}, [read, getProviders]);
|
||||
|
||||
return { settings, hasElevenLabsKey };
|
||||
};
|
||||
@@ -1,378 +0,0 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { useConfig } from '../components/ConfigContext';
|
||||
import { getApiUrl } from '../config';
|
||||
import { useDictationSettings } from './useDictationSettings';
|
||||
import { DICTATION_PROVIDER_OPENAI, DICTATION_PROVIDER_ELEVENLABS } from './dictationConstants';
|
||||
import { safeJsonParse, errorMessage } from '../utils/conversionUtils';
|
||||
|
||||
interface UseWhisperOptions {
|
||||
onTranscription?: (text: string) => void;
|
||||
onError?: (error: Error) => void;
|
||||
onSizeWarning?: (sizeInMB: number) => void;
|
||||
}
|
||||
|
||||
// Constants
|
||||
const MAX_AUDIO_SIZE_MB = 25;
|
||||
const MAX_RECORDING_DURATION_SECONDS = 600; // 10 minutes
|
||||
const WARNING_SIZE_MB = 20; // Warn at 20MB
|
||||
|
||||
export const useWhisper = ({ onTranscription, onError, onSizeWarning }: UseWhisperOptions = {}) => {
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [isTranscribing, setIsTranscribing] = useState(false);
|
||||
const [hasOpenAIKey, setHasOpenAIKey] = useState(false);
|
||||
const [canUseDictation, setCanUseDictation] = useState(false);
|
||||
const [audioContext, setAudioContext] = useState<AudioContext | null>(null);
|
||||
const [analyser, setAnalyser] = useState<AnalyserNode | null>(null);
|
||||
const [recordingDuration, setRecordingDuration] = useState(0);
|
||||
const [estimatedSize, setEstimatedSize] = useState(0);
|
||||
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const audioChunksRef = useRef<Blob[]>([]);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const recordingStartTimeRef = useRef<number | null>(null);
|
||||
const durationIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const currentSizeRef = useRef<number>(0);
|
||||
|
||||
const { getProviders } = useConfig();
|
||||
const { settings: dictationSettings, hasElevenLabsKey } = useDictationSettings();
|
||||
|
||||
// Check if OpenAI API key is configured (regardless of current provider)
|
||||
useEffect(() => {
|
||||
const checkOpenAIKey = async () => {
|
||||
try {
|
||||
// Get all configured providers
|
||||
const providers = await getProviders(false);
|
||||
|
||||
// Find OpenAI provider
|
||||
const openAIProvider = providers.find((p) => p.name === 'openai');
|
||||
|
||||
// Check if OpenAI is configured
|
||||
if (openAIProvider && openAIProvider.is_configured) {
|
||||
setHasOpenAIKey(true);
|
||||
} else {
|
||||
setHasOpenAIKey(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking OpenAI configuration:', error);
|
||||
setHasOpenAIKey(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkOpenAIKey();
|
||||
}, [getProviders]); // Re-check when providers change
|
||||
|
||||
// Check if dictation can be used based on settings
|
||||
useEffect(() => {
|
||||
if (!dictationSettings) {
|
||||
setCanUseDictation(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dictationSettings.enabled) {
|
||||
setCanUseDictation(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check provider availability
|
||||
switch (dictationSettings.provider) {
|
||||
case DICTATION_PROVIDER_OPENAI:
|
||||
setCanUseDictation(hasOpenAIKey);
|
||||
break;
|
||||
case DICTATION_PROVIDER_ELEVENLABS:
|
||||
setCanUseDictation(hasElevenLabsKey);
|
||||
break;
|
||||
default:
|
||||
setCanUseDictation(false);
|
||||
}
|
||||
}, [dictationSettings, hasOpenAIKey, hasElevenLabsKey]);
|
||||
|
||||
// Define stopRecording before startRecording to avoid circular dependency
|
||||
const stopRecording = useCallback(() => {
|
||||
setIsRecording(false);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
setIsTranscribing(true);
|
||||
|
||||
try {
|
||||
// Check final size
|
||||
const sizeMB = audioBlob.size / (1024 * 1024);
|
||||
if (sizeMB > MAX_AUDIO_SIZE_MB) {
|
||||
throw new Error(
|
||||
`Audio file too large (${sizeMB.toFixed(1)}MB). Maximum size is ${MAX_AUDIO_SIZE_MB}MB.`
|
||||
);
|
||||
}
|
||||
|
||||
// Convert blob to base64 for easier transport
|
||||
const reader = new FileReader();
|
||||
const base64Audio = await new Promise<string>((resolve, reject) => {
|
||||
reader.onloadend = () => {
|
||||
const base64 = reader.result as string;
|
||||
resolve(base64.split(',')[1]); // Remove data:audio/webm;base64, prefix
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(audioBlob);
|
||||
});
|
||||
|
||||
const mimeType = audioBlob.type;
|
||||
if (!mimeType) {
|
||||
throw new Error('Unable to determine audio format. Please try again.');
|
||||
}
|
||||
|
||||
let endpoint = '';
|
||||
let headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Secret-Key': await window.electron.getSecretKey(),
|
||||
};
|
||||
|
||||
let body: Record<string, string> = {
|
||||
audio: base64Audio,
|
||||
mime_type: mimeType,
|
||||
};
|
||||
|
||||
// Choose endpoint based on provider
|
||||
switch (dictationSettings.provider) {
|
||||
case DICTATION_PROVIDER_OPENAI:
|
||||
endpoint = '/audio/transcribe';
|
||||
break;
|
||||
case DICTATION_PROVIDER_ELEVENLABS:
|
||||
endpoint = '/audio/transcribe/elevenlabs';
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported provider: ${dictationSettings.provider}`);
|
||||
}
|
||||
|
||||
const response = await fetch(getApiUrl(endpoint), {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new Error(
|
||||
`Audio transcription endpoint not found. Please implement ${endpoint} endpoint in the Goose backend.`
|
||||
);
|
||||
} else if (response.status === 401) {
|
||||
throw new Error('Invalid API key. Please check your API key is correct.');
|
||||
} else if (response.status === 402) {
|
||||
throw new Error('API quota exceeded. Please check your account limits.');
|
||||
}
|
||||
const errorData = await safeJsonParse<{
|
||||
error: { message: string };
|
||||
}>(response, 'Failed to parse error response').catch(() => ({
|
||||
error: { message: 'Transcription failed' },
|
||||
}));
|
||||
throw new Error(errorData.error?.message || 'Transcription failed');
|
||||
}
|
||||
|
||||
const data = await safeJsonParse<{ text: string }>(
|
||||
response,
|
||||
'Failed to parse transcription response'
|
||||
);
|
||||
if (data.text) {
|
||||
onTranscription?.(data.text);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error transcribing audio:', error);
|
||||
stopRecording();
|
||||
onError?.(error as Error);
|
||||
} finally {
|
||||
setIsTranscribing(false);
|
||||
setRecordingDuration(0);
|
||||
setEstimatedSize(0);
|
||||
}
|
||||
},
|
||||
[onTranscription, onError, dictationSettings, stopRecording]
|
||||
);
|
||||
|
||||
const startRecording = useCallback(async () => {
|
||||
if (!dictationSettings) {
|
||||
stopRecording();
|
||||
onError?.(new Error('Dictation settings not loaded'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Request microphone permission
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
sampleRate: 44100,
|
||||
},
|
||||
});
|
||||
streamRef.current = stream;
|
||||
|
||||
// Verify we have valid audio tracks
|
||||
const audioTracks = stream.getAudioTracks();
|
||||
if (audioTracks.length === 0) {
|
||||
throw new Error('No audio tracks available in the microphone stream');
|
||||
}
|
||||
|
||||
// AudioContext creation is disabled to prevent MediaRecorder conflicts
|
||||
setAudioContext(null);
|
||||
setAnalyser(null);
|
||||
|
||||
// Determine best supported MIME type
|
||||
const supportedTypes = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/wav'];
|
||||
|
||||
const mimeType = supportedTypes.find((type) => MediaRecorder.isTypeSupported(type)) || '';
|
||||
|
||||
const mediaRecorder = new MediaRecorder(stream, mimeType ? { mimeType } : {});
|
||||
|
||||
mediaRecorderRef.current = mediaRecorder;
|
||||
audioChunksRef.current = [];
|
||||
currentSizeRef.current = 0;
|
||||
recordingStartTimeRef.current = Date.now();
|
||||
|
||||
// Start duration and size tracking
|
||||
durationIntervalRef.current = setInterval(() => {
|
||||
const elapsed = (Date.now() - (recordingStartTimeRef.current || 0)) / 1000;
|
||||
setRecordingDuration(elapsed);
|
||||
|
||||
// Estimate size based on typical webm bitrate (~128kbps)
|
||||
const estimatedSizeMB = (elapsed * 128 * 1024) / (8 * 1024 * 1024);
|
||||
setEstimatedSize(estimatedSizeMB);
|
||||
|
||||
// Check if we're approaching the limit
|
||||
if (estimatedSizeMB > WARNING_SIZE_MB) {
|
||||
onSizeWarning?.(estimatedSizeMB);
|
||||
}
|
||||
|
||||
// Auto-stop if we hit the duration limit
|
||||
if (elapsed >= MAX_RECORDING_DURATION_SECONDS) {
|
||||
stopRecording();
|
||||
onError?.(
|
||||
new Error(
|
||||
`Maximum recording duration (${MAX_RECORDING_DURATION_SECONDS / 60} minutes) reached.`
|
||||
)
|
||||
);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
audioChunksRef.current.push(event.data);
|
||||
currentSizeRef.current += event.data.size;
|
||||
|
||||
// Check actual size
|
||||
const actualSizeMB = currentSizeRef.current / (1024 * 1024);
|
||||
if (actualSizeMB > MAX_AUDIO_SIZE_MB) {
|
||||
stopRecording();
|
||||
onError?.(new Error(`Maximum file size (${MAX_AUDIO_SIZE_MB}MB) reached.`));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
mediaRecorder.onstop = async () => {
|
||||
const audioBlob = new Blob(audioChunksRef.current, { type: mimeType || 'audio/webm' });
|
||||
|
||||
// Check if the blob is empty
|
||||
if (audioBlob.size === 0) {
|
||||
onError?.(
|
||||
new Error(
|
||||
'No audio data was recorded. Please check your microphone permissions and try again.'
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await transcribeAudio(audioBlob);
|
||||
};
|
||||
|
||||
// Add error handler for MediaRecorder
|
||||
mediaRecorder.onerror = (event) => {
|
||||
console.error('MediaRecorder error:', event);
|
||||
onError?.(new Error('Recording failed: Unknown error'));
|
||||
};
|
||||
|
||||
if (!stream.active) {
|
||||
throw new Error('Audio stream became inactive before recording could start');
|
||||
}
|
||||
|
||||
// Check audio tracks again before starting recording
|
||||
if (audioTracks.length === 0) {
|
||||
throw new Error('No audio tracks available in the stream');
|
||||
}
|
||||
|
||||
const activeAudioTracks = audioTracks.filter((track) => track.readyState === 'live');
|
||||
if (activeAudioTracks.length === 0) {
|
||||
throw new Error('No live audio tracks available');
|
||||
}
|
||||
|
||||
try {
|
||||
mediaRecorder.start(100);
|
||||
setIsRecording(true);
|
||||
} catch (startError) {
|
||||
console.error('Error calling mediaRecorder.start():', startError);
|
||||
throw new Error(`Failed to start recording: ${errorMessage(startError)}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error starting recording:', error);
|
||||
stopRecording();
|
||||
onError?.(error as Error);
|
||||
}
|
||||
}, [onError, onSizeWarning, transcribeAudio, stopRecording, dictationSettings]);
|
||||
|
||||
return {
|
||||
isRecording,
|
||||
isTranscribing,
|
||||
hasOpenAIKey,
|
||||
canUseDictation,
|
||||
audioContext,
|
||||
analyser,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
recordingDuration,
|
||||
estimatedSize,
|
||||
dictationSettings,
|
||||
};
|
||||
};
|
||||
@@ -2,5 +2,4 @@ export const UPDATES_ENABLED = true;
|
||||
export const COST_TRACKING_ENABLED = true;
|
||||
export const ANNOUNCEMENTS_ENABLED = false;
|
||||
export const CONFIGURATION_ENABLED = true;
|
||||
export const VOICE_DICTATION_ELEVENLABS_ENABLED = true;
|
||||
export const TELEMETRY_UI_ENABLED = true;
|
||||
|
||||
Reference in New Issue
Block a user