'use client'; import { useState, useRef } from 'react'; import { Label } from '@/components/ui/label'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; 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 { Mic, MicOff, CheckCircle2, XCircle, Eye, EyeOff } from 'lucide-react'; import { cn } from '@/lib/utils'; 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 asrProvider = ASR_PROVIDERS[selectedProviderId] ?? ASR_PROVIDERS['openai-whisper']; const isServerConfigured = !!asrProvidersConfig[selectedProviderId]?.isServerConfigured; const [showApiKey, setShowApiKey] = useState(false); const [isRecording, setIsRecording] = 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()); 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; 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(); setASRResult(data.text); setTestStatus('success'); setTestMessage(t('settings.asrTestSuccess')); } 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(t('settings.asrTestFailed')); } }; 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')}
)} {/* API Key & Base URL */} {(asrProvider.requiresApiKey || isServerConfigured) && ( <>
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 || asrProvider.defaultBaseUrl || ''; if (!effectiveBaseUrl) return null; let endpointPath = ''; 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 */} {asrProvider.models.length > 0 && (
)}
); }