import { useState, useRef } from 'react'; import { Label } from '@/components/ui/label'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { useI18n } from '@/lib/hooks/use-i18n'; import { useSettingsStore } from '@/lib/store/settings'; import { ASR_PROVIDERS } from '@/lib/audio/constants'; import type { ASRProviderId } from '@/lib/audio/types'; import { isCustomASRProvider } from '@/lib/audio/types'; import { Mic, MicOff, CheckCircle2, XCircle, Eye, EyeOff, Plus, Loader2 } from 'lucide-react'; import { cn } from '@/lib/utils'; import { toast } from 'sonner'; import { createLogger } from '@/lib/logger'; const log = createLogger('ASRSettings'); interface ASRSettingsProps { selectedProviderId: ASRProviderId; } export function ASRSettings({ selectedProviderId }: ASRSettingsProps) { const { t } = useI18n(); const asrLanguage = useSettingsStore((state) => state.asrLanguage); const asrProvidersConfig = useSettingsStore((state) => state.asrProvidersConfig); const setASRProviderConfig = useSettingsStore((state) => state.setASRProviderConfig); const removeCustomASRProvider = useSettingsStore((state) => state.removeCustomASRProvider); const asrProvider = ASR_PROVIDERS[selectedProviderId as keyof typeof ASR_PROVIDERS]; const isCustom = isCustomASRProvider(selectedProviderId); const providerConfig = asrProvidersConfig[selectedProviderId]; const isServerConfigured = !!providerConfig?.isServerConfigured; const requiresApiKey = isCustom ? !!providerConfig?.requiresApiKey : !!asrProvider?.requiresApiKey; const [showApiKey, setShowApiKey] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [isRecording, setIsRecording] = useState(false); const [isProcessing, setIsProcessing] = useState(false); const [asrResult, setASRResult] = useState(''); const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'success' | 'error'>('idle'); const [testMessage, setTestMessage] = useState(''); const mediaRecorderRef = useRef(null); // Reset state when provider changes (derived state pattern) const [prevProviderId, setPrevProviderId] = useState(selectedProviderId); if (selectedProviderId !== prevProviderId) { setPrevProviderId(selectedProviderId); setShowApiKey(false); setTestStatus('idle'); setTestMessage(''); setASRResult(''); } const handleToggleASRRecording = async () => { if (isRecording) { if (mediaRecorderRef.current && mediaRecorderRef.current.state === 'recording') { mediaRecorderRef.current.stop(); } setIsRecording(false); } else { setASRResult(''); setTestStatus('testing'); setTestMessage(''); if (selectedProviderId === 'browser-native') { const SpeechRecognitionCtor = (window as unknown as Record).SpeechRecognition || (window as unknown as Record).webkitSpeechRecognition; if (!SpeechRecognitionCtor) { setTestStatus('error'); setTestMessage(t('settings.asrNotSupported')); return; } // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Vendor-prefixed API without standard typings const recognition = new (SpeechRecognitionCtor as new () => any)(); recognition.lang = asrLanguage || 'zh-CN'; recognition.onresult = (event: { results: { [index: number]: { [index: number]: { transcript: string } }; }; }) => { const transcript = event.results[0][0].transcript; setASRResult(transcript); setTestStatus('success'); setTestMessage(t('settings.asrTestSuccess')); }; recognition.onerror = (event: { error: string }) => { setTestStatus('error'); setTestMessage(t('settings.asrTestFailed') + ': ' + event.error); }; recognition.onend = () => { setIsRecording(false); }; recognition.start(); setIsRecording(true); } else { try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true, }); const mediaRecorder = new MediaRecorder(stream); mediaRecorderRef.current = mediaRecorder; const audioChunks: Blob[] = []; mediaRecorder.ondataavailable = (event) => { audioChunks.push(event.data); }; mediaRecorder.onstop = async () => { stream.getTracks().forEach((track) => track.stop()); setIsProcessing(true); const audioBlob = new Blob(audioChunks, { type: 'audio/webm' }); const formData = new FormData(); formData.append('audio', audioBlob, 'recording.webm'); formData.append('providerId', selectedProviderId); formData.append( 'modelId', asrProvidersConfig[selectedProviderId]?.modelId || asrProvider?.defaultModelId || '', ); formData.append('language', asrLanguage); const apiKeyValue = asrProvidersConfig[selectedProviderId]?.apiKey; if (apiKeyValue?.trim()) formData.append('apiKey', apiKeyValue); const baseUrlValue = asrProvidersConfig[selectedProviderId]?.baseUrl || providerConfig?.customDefaultBaseUrl || ''; if (baseUrlValue?.trim()) formData.append('baseUrl', baseUrlValue); try { const response = await fetch('/api/transcription', { method: 'POST', body: formData, }); if (response.ok) { const data = await response.json(); if (data.text?.trim()) { setASRResult(data.text); setTestStatus('success'); setTestMessage(t('settings.asrTestSuccess')); } else { setTestStatus('error'); setTestMessage(data.error || t('settings.asrNoTranscription')); } } else { setTestStatus('error'); const errorData = await response .json() .catch(() => ({ error: response.statusText })); setTestMessage(errorData.details || errorData.error || t('settings.asrTestFailed')); } } catch (error) { log.error('ASR test failed:', error); setTestStatus('error'); setTestMessage( error instanceof Error && error.message ? `${t('settings.asrTestFailed')}: ${error.message}` : t('settings.asrTestFailed'), ); } finally { setIsProcessing(false); } }; mediaRecorder.start(); setIsRecording(true); } catch (error) { log.error('Failed to access microphone:', error); setTestStatus('error'); setTestMessage(t('settings.microphoneAccessFailed')); } } } }; return (
{/* Server-configured notice */} {isServerConfigured && (
{t('settings.serverConfiguredNotice')}
)} {/* No models warning for custom providers */} {isCustom && ((providerConfig?.customModels as Array<{ id: string }>) || []).length === 0 && (
{t('settings.noModelsWarning')}
)} {/* API Key & Base URL */} {(requiresApiKey || isServerConfigured || isCustom) && ( <>
setASRProviderConfig(selectedProviderId, { apiKey: e.target.value, }) } className="font-mono text-sm pr-10" />
setASRProviderConfig(selectedProviderId, { baseUrl: e.target.value, }) } className="text-sm" />
{/* Request URL Preview */} {(() => { const effectiveBaseUrl = asrProvidersConfig[selectedProviderId]?.baseUrl || (isCustom ? providerConfig?.customDefaultBaseUrl : asrProvider?.defaultBaseUrl) || ''; if (!effectiveBaseUrl) return null; let endpointPath = ''; if (isCustom) { endpointPath = '/audio/transcriptions'; } else { switch (selectedProviderId) { case 'openai-whisper': endpointPath = '/audio/transcriptions'; break; case 'qwen-asr': endpointPath = '/services/aigc/multimodal-generation/generation'; break; } } if (!endpointPath) return null; return (

{t('settings.requestUrl')}: {effectiveBaseUrl + endpointPath}

); })()} )} {/* Test ASR */}
{testMessage && (
{testStatus === 'success' && } {testStatus === 'error' && }

{testMessage}

)} {/* Model Selection — built-in providers */} {!isCustom && asrProvider?.models?.length > 0 && (
)} {/* Model Management — custom providers */} {isCustom && (
{(() => { const customModels = (providerConfig?.customModels as Array<{ id: string; name: string }>) || []; const activeModelId = asrProvidersConfig[selectedProviderId]?.modelId || customModels[0]?.id || ''; return ( <> {customModels.length > 0 ? (
ID {t('settings.modelNamePlaceholder')}
{customModels.map((model, index) => { const isActive = model.id === activeModelId; return (
setASRProviderConfig(selectedProviderId, { modelId: model.id }) } className={cn( 'grid grid-cols-[20px_1fr_1fr_36px] gap-0 items-center px-3 py-2 group cursor-pointer transition-colors', isActive ? 'bg-primary/5' : 'hover:bg-muted/20', index > 0 && 'border-t border-border/30', )} >
{isActive &&
}
{model.id} {model.name}
); })}
) : (

{t('settings.noModelsAdded')}

)} m.id)} onAdd={(modelId, modelName) => { const models = [...customModels, { id: modelId, name: modelName }]; setASRProviderConfig(selectedProviderId, { customModels: models, modelId: models[0].id, }); }} /> ); })()}
)} {/* Delete Custom Provider */} {isCustom && (
)} {/* Delete Confirmation Dialog */} !open && setShowDeleteConfirm(false)} > {t('settings.deleteProvider')} {t('settings.deleteProviderConfirm')} {t('settings.cancelEdit')} { removeCustomASRProvider(selectedProviderId); setShowDeleteConfirm(false); }} > {t('settings.deleteProvider')}
); } function AddModelRow({ onAdd, existingIds, }: { onAdd: (id: string, name: string) => void; existingIds: string[]; }) { const { t } = useI18n(); const [modelId, setModelId] = useState(''); const [modelName, setModelName] = useState(''); const handleAdd = () => { if (!modelId.trim()) return; if (existingIds.includes(modelId.trim())) { toast.error('Duplicate ID'); return; } onAdd(modelId.trim(), modelName.trim() || modelId.trim()); setModelId(''); setModelName(''); }; return (
setModelId(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleAdd()} className="text-sm font-mono" placeholder={t('settings.modelIdPlaceholder')} /> setModelName(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleAdd()} className="text-sm" placeholder={t('settings.modelNamePlaceholder')} />
); }