Careless whisper (#6877)

Co-authored-by: Douwe Osinga <douwe@squareup.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Douwe Osinga
2026-02-03 12:54:29 +01:00
committed by GitHub
parent 3d0bb3c670
commit 1373d9c5f9
25 changed files with 118541 additions and 485 deletions
+85 -55
View File
@@ -260,16 +260,37 @@ export default function ChatInput({
isTranscribing,
startRecording,
stopRecording,
recordingDuration,
estimatedSize,
} = useAudioRecorder({
onTranscription: (text) => {
trackVoiceDictation('transcribed');
// Append transcribed text to the current input
const newValue = displayValue.trim() ? `${displayValue.trim()} ${text}` : text;
let filteredText = text.replace(/\([^)]*\)/g, '').trim();
if (!filteredText) {
return;
}
const shouldAutoSubmit = /\bsubmit[.,!?;'"\s]*$/i.test(filteredText);
const cleanedText = shouldAutoSubmit
? filteredText.replace(/\bsubmit[.,!?;'"\s]*$/i, '').trim()
: filteredText;
const newValue = displayValue.trim() && cleanedText
? `${displayValue.trim()} ${cleanedText}`
: displayValue.trim() || cleanedText;
setDisplayValue(newValue);
setValue(newValue);
textAreaRef.current?.focus();
if (shouldAutoSubmit && newValue.trim()) {
trackVoiceDictation('auto_submit');
setTimeout(() => {
performSubmit(newValue);
}, 100);
} else {
textAreaRef.current?.focus();
}
},
onError: (message) => {
const errorType = 'DictationError';
@@ -907,8 +928,8 @@ export default function ChatInput({
]
);
const handleKeyDown = (evt: React.KeyboardEvent<HTMLTextAreaElement>) => {
// If mention popover is open, handle arrow keys and enter
if (mentionPopover.isOpen && mentionPopoverRef.current) {
if (evt.key === 'ArrowDown') {
evt.preventDefault();
@@ -940,7 +961,6 @@ export default function ChatInput({
}
}
// Handle history navigation first
handleHistoryNavigation(evt);
if (evt.key === 'Enter') {
@@ -1221,23 +1241,15 @@ export default function ChatInput({
onBlur={() => setIsFocused(false)}
ref={textAreaRef}
rows={1}
readOnly={isRecording}
style={{
minHeight: `${minTextareaHeight}px`,
maxHeight: `${maxHeight}px`,
overflowY: 'auto',
opacity: isRecording ? 0 : 1,
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">
<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">
@@ -1272,37 +1284,52 @@ export default function ChatInput({
ElevenLabs API key is not configured. Set it up in <b>Settings</b> {'>'}{' '}
<b>Chat</b> {'>'} <b>Voice Dictation.</b>
</p>
) : dictationProvider === 'local' ? (
<p>
Local Whisper model not found. Download a model in{' '}
<b>Settings &gt; Dictation &gt; Local (Offline)</b>
</p>
) : (
<p>Dictation provider is not properly configured.</p>
)}
</TooltipContent>
</Tooltip>
) : (
<Button
type="button"
size="sm"
shape="round"
variant="outline"
onClick={() => {
if (isRecording) {
trackVoiceDictation('stop', Math.floor(recordingDuration));
stopRecording();
} else {
trackVoiceDictation('start');
startRecording();
}
}}
disabled={isTranscribing}
className={`rounded-full px-6 py-2 ${
isRecording
? 'bg-red-500 text-white hover:bg-red-600 border-red-500'
: isTranscribing
? 'bg-slate-600 text-white cursor-not-allowed animate-pulse border-slate-600'
: 'bg-slate-600 text-white hover:bg-slate-700 border-slate-600'
}`}
>
<Microphone />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
size="sm"
shape="round"
variant="outline"
onClick={() => {
if (isRecording) {
trackVoiceDictation('stop');
stopRecording();
} else {
trackVoiceDictation('start');
startRecording();
}
}}
disabled={isTranscribing}
className={`rounded-full px-6 py-2 ${
isRecording
? 'bg-red-500 text-white hover:bg-red-600 border-red-500'
: isTranscribing
? 'bg-slate-600 text-white cursor-not-allowed animate-pulse border-slate-600'
: 'bg-slate-600 text-white hover:bg-slate-700 border-slate-600'
}`}
>
<Microphone />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
Voice dictation
{isRecording ? '' : ' • Say "submit" to send'}
</p>
</TooltipContent>
</Tooltip>
)}
</>
)}
@@ -1349,20 +1376,23 @@ export default function ChatInput({
{/* Recording/transcribing status indicator - positioned above the button row */}
{(isRecording || isTranscribing) && (
<div className="absolute right-0 -top-8 bg-background-default px-2 py-1 rounded text-xs whitespace-nowrap shadow-md border border-borderSubtle">
{isTranscribing ? (
<span className="text-blue-500 flex items-center gap-1">
<span className="inline-block w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
Transcribing...
</span>
) : (
<span
className={`flex items-center gap-2 ${estimatedSize > 20 ? 'text-orange-500' : 'text-textSubtle'}`}
>
<span className="inline-block w-2 h-2 bg-red-500 rounded-full animate-pulse" />
{Math.floor(recordingDuration)}s ~{estimatedSize.toFixed(1)}MB
{estimatedSize > 20 && <span className="text-xs">(near 25MB limit)</span>}
</span>
)}
<span className="flex items-center gap-2">
{isRecording && (
<span className="flex items-center gap-1 text-textSubtle">
<span className="inline-block w-2 h-2 bg-red-500 rounded-full animate-pulse" />
Listening
</span>
)}
{isRecording && isTranscribing && (
<span className="text-textSubtle"></span>
)}
{isTranscribing && (
<span className="flex items-center gap-1 text-blue-500">
<span className="inline-block w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
Transcribing
</span>
)}
</span>
</div>
)}
</div>
@@ -5,6 +5,7 @@ import { useConfig } from '../../ConfigContext';
import { Input } from '../../ui/input';
import { Button } from '../../ui/button';
import { trackSettingToggled } from '../../../utils/analytics';
import { LocalModelManager } from './LocalModelManager';
export const DictationSettings = () => {
const [provider, setProvider] = useState<DictationProvider | null>(null);
@@ -163,11 +164,11 @@ export const DictationSettings = () => {
{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 ? (
{provider === 'local' ? (
<div className="py-2 px-2">
<LocalModelManager />
</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">
@@ -0,0 +1,305 @@
import { useState, useEffect } from 'react';
import { Download, Trash2, X, Check, ChevronDown, ChevronUp } from 'lucide-react';
import { Button } from '../../ui/button';
import { useConfig } from '../../ConfigContext';
import {
listModels,
downloadModel,
getDownloadProgress,
cancelDownload as cancelDownloadApi,
deleteModel as deleteModelApi,
type WhisperModelResponse,
type DownloadProgress,
} from '../../../api';
const LOCAL_WHISPER_MODEL_CONFIG_KEY = 'LOCAL_WHISPER_MODEL';
const formatBytes = (bytes: number): string => {
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(0)}MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}GB`;
};
const capitalize = (str: string): string => {
return str.charAt(0).toUpperCase() + str.slice(1);
};
export const LocalModelManager = () => {
const [models, setModels] = useState<WhisperModelResponse[]>([]);
const [downloads, setDownloads] = useState<Map<string, DownloadProgress>>(new Map());
const [selectedModelId, setSelectedModelId] = useState<string | null>(null);
const [showAllModels, setShowAllModels] = useState(false);
const { read, upsert } = useConfig();
useEffect(() => {
loadModels();
loadSelectedModel();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Determine if we should show all models by default (if non-recommended models are downloaded)
useEffect(() => {
if (models.length === 0) return;
const hasDownloadedNonRecommended = models.some(
(model) => model.downloaded && !model.recommended
);
if (hasDownloadedNonRecommended && !showAllModels) {
setShowAllModels(true);
}
}, [models, showAllModels]);
const loadSelectedModel = async () => {
try {
const value = await read(LOCAL_WHISPER_MODEL_CONFIG_KEY, false);
if (value && typeof value === 'string') {
setSelectedModelId(value);
} else {
setSelectedModelId(null);
}
} catch (error) {
console.error('Failed to load selected model:', error);
setSelectedModelId(null);
}
};
const selectModel = async (modelId: string) => {
await upsert(LOCAL_WHISPER_MODEL_CONFIG_KEY, modelId, false);
setSelectedModelId(modelId);
};
const loadModels = async () => {
try {
const response = await listModels();
if (response.data) {
setModels(response.data);
}
} catch (error) {
console.error('Failed to load models:', error);
}
};
const startDownload = async (modelId: string) => {
try {
await downloadModel({ path: { model_id: modelId } });
pollDownloadProgress(modelId);
} catch (error) {
console.error('Failed to start download:', error);
}
};
const pollDownloadProgress = (modelId: string) => {
const interval = setInterval(async () => {
try {
const response = await getDownloadProgress({ path: { model_id: modelId } });
if (response.data) {
const progress = response.data;
setDownloads((prev) => new Map(prev).set(modelId, progress));
if (progress.status === 'completed') {
clearInterval(interval);
await loadModels(); // Refresh model list
// Backend auto-selects, but also update frontend state
await loadSelectedModel();
} else if (progress.status === 'failed') {
clearInterval(interval);
await loadModels();
}
} else {
clearInterval(interval);
}
} catch {
clearInterval(interval);
}
}, 500);
};
const cancelDownload = async (modelId: string) => {
try {
await cancelDownloadApi({ path: { model_id: modelId } });
setDownloads((prev) => {
const next = new Map(prev);
next.delete(modelId);
return next;
});
loadModels();
} catch (error) {
console.error('Failed to cancel download:', error);
}
};
const deleteModel = async (modelId: string) => {
if (!window.confirm('Delete this model? You can re-download it later.')) return;
try {
await deleteModelApi({ path: { model_id: modelId } });
if (selectedModelId === modelId) {
await upsert(LOCAL_WHISPER_MODEL_CONFIG_KEY, '', false);
setSelectedModelId(null);
}
loadModels();
} catch (error) {
console.error('Failed to delete model:', error);
}
};
const displayedModels = showAllModels ? models : models.filter((m) => m.recommended);
const hasNonRecommendedModels = models.some((m) => !m.recommended);
return (
<div className="space-y-3">
<div className="text-xs text-text-muted mb-2">
<p>Supports GPU acceleration (CUDA for NVIDIA, Metal for Apple Silicon). GPU features must be enabled at build time for hardware acceleration.</p>
</div>
<div className="space-y-2">
{displayedModels.map((model) => {
const progress = downloads.get(model.id);
const isDownloading = progress?.status === 'downloading';
const isSelected = selectedModelId === model.id;
const canSelect = model.downloaded && !isDownloading;
return (
<div
key={model.id}
className={`border rounded-lg p-3 transition-colors ${
isSelected
? 'border-accent-primary bg-accent-primary/5'
: 'border-border-subtle bg-background-default hover:border-border-default'
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
{canSelect && (
<input
type="radio"
checked={isSelected}
onChange={() => selectModel(model.id)}
className="cursor-pointer"
/>
)}
<h4 className="text-sm font-medium text-text-default">
{capitalize(model.id)}
</h4>
<span className="text-xs text-text-muted">
{model.size_mb}MB
</span>
{model.recommended && (
<span className="text-xs bg-blue-500 text-white px-2 py-0.5 rounded">
Recommended
</span>
)}
{isSelected && (
<span className="text-xs bg-accent-primary text-white px-2 py-0.5 rounded">
Active
</span>
)}
</div>
<p className="text-xs text-text-muted mt-1">
{model.description}
</p>
{model.recommended && (
<p className="text-xs text-blue-600 mt-1 font-medium">
Recommended for your hardware
</p>
)}
</div>
<div className="flex items-center gap-2">
{model.downloaded ? (
<>
<div className="flex items-center gap-1 text-xs text-green-600">
<Check className="w-4 h-4" />
<span>Downloaded</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => deleteModel(model.id)}
className="text-destructive hover:text-destructive"
>
<Trash2 className="w-4 h-4" />
</Button>
</>
) : isDownloading ? (
<>
<div className="text-xs text-text-muted min-w-[60px]">
{progress.progress_percent.toFixed(0)}%
</div>
<Button
variant="ghost"
size="sm"
onClick={() => cancelDownload(model.id)}
>
<X className="w-4 h-4" />
</Button>
</>
) : (
<Button variant="outline" size="sm" onClick={() => startDownload(model.id)}>
<Download className="w-4 h-4 mr-1" />
Download
</Button>
)}
</div>
</div>
{isDownloading && progress && (
<div className="mt-2 space-y-1">
<div className="w-full bg-background-subtle rounded-full h-1.5">
<div
className="bg-accent-primary h-1.5 rounded-full transition-all"
style={{ width: `${progress.progress_percent}%` }}
/>
</div>
<div className="flex justify-between text-xs text-text-muted">
<span>
{formatBytes(progress.bytes_downloaded)} / {formatBytes(progress.total_bytes)}
</span>
{progress.speed_bps && (
<span>{formatBytes(progress.speed_bps)}/s</span>
)}
</div>
</div>
)}
{progress?.status === 'failed' && progress.error && (
<div className="mt-2 text-xs text-destructive">{progress.error}</div>
)}
</div>
);
})}
</div>
{hasNonRecommendedModels && (
<Button
variant="ghost"
size="sm"
onClick={() => setShowAllModels(!showAllModels)}
className="w-full text-text-muted hover:text-text-default"
>
{showAllModels ? (
<>
<ChevronUp className="w-4 h-4 mr-1" />
Show recommended only
</>
) : (
<>
<ChevronDown className="w-4 h-4 mr-1" />
Show all models
</>
)}
</Button>
)}
{models.length === 0 && (
<div className="text-center py-6 text-text-muted text-sm">
No models available
</div>
)}
</div>
);
};