Rejig dictation (#6844)

Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
Douwe Osinga
2026-02-02 14:25:23 +01:00
committed by GitHub
parent dd15fd0dd8
commit 2661454426
22 changed files with 1177 additions and 1448 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+121
View File
@@ -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;
+19 -36
View File
@@ -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,
};
}
};
+219
View File
@@ -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 };
};
-378
View File
@@ -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,
};
};
-1
View File
@@ -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;