'use client'; import { useState, useRef, useEffect } 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 { resolveASRLanguage, useSettingsStore } from '@/lib/store/settings'; import { ASR_PROVIDERS } from '@/lib/audio/constants'; import { regionalEndpointTemplate } from '@/lib/config/official-endpoints'; import type { ASRProviderId } from '@/lib/audio/types'; import { Mic, MicOff, CheckCircle2, XCircle, Loader2 } from 'lucide-react'; import { cn } from '@/lib/utils'; import { createLogger } from '@/lib/logger'; import { normalizeASRUploadAudio } from '@/lib/audio/wav-utils'; import { modelChange, modelRef } from '@/lib/model-settings/edit'; import { ApiKeyField, EndpointServerOnlyHint, RegionalEndpointField, ServerConfiguredNotice, ServerOnlyNotice, reportApply, rootUse, saveServiceProvider, type ServicePanelProps, } from './server-settings'; const log = createLogger('ASRSettings'); /** Language names for the recognition language picker, in the UI language (codes as a fallback). */ function languageName(code: string, locale: string): string { if (code === 'auto') return code; try { return new Intl.DisplayNames([locale], { type: 'language' }).of(code) ?? code; } catch { return code; } } /** * A speech recognition service: its key (write-only), a recording test on the * server with the saved configuration, its model (the workspace's while it is * in use), and the language the user's speech input listens for. */ export function ASRSettings({ view, apply, entry }: ServicePanelProps) { const { t, locale } = useI18n(); const localeRef = useRef(locale); useEffect(() => { localeRef.current = locale; }, [locale]); const asrLanguage = useSettingsStore((state) => state.asrLanguage); const setASRLanguage = useSettingsStore((state) => state.setASRLanguage); const providerId = entry.registryId as ASRProviderId; const asrProvider = ASR_PROVIDERS[providerId as keyof typeof ASR_PROVIDERS]; const regionalEndpoint = regionalEndpointTemplate('asr', providerId); const isBrowser = providerId === 'browser-native'; const editable = (entry.state === 'workspace' || entry.state === 'available') && !isBrowser; const use = rootUse(view, 'asr', entry.id); const languages = asrProvider?.supportedLanguages ?? []; const effectiveLanguage = resolveASRLanguage(providerId, asrLanguage, locale); const modelId = use.modelId || asrProvider?.defaultModelId; 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); const canTest = isBrowser || !!entry.provider; const handleToggleASRRecording = async () => { if (isRecording) { if (mediaRecorderRef.current && mediaRecorderRef.current.state === 'recording') { mediaRecorderRef.current.stop(); } setIsRecording(false); return; } setASRResult(''); setTestStatus('testing'); setTestMessage(''); if (isBrowser) { 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 = effectiveLanguage; recognition.onresult = (event: { results: { [index: number]: { [index: number]: { transcript: string } } }; }) => { setASRResult(event.results[0][0].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); return; } 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); try { const audioBlob = new Blob(audioChunks, { type: 'audio/webm' }); const uploadAudio = await normalizeASRUploadAudio(providerId, audioBlob, use.modelId); const formData = new FormData(); formData.append('audio', uploadAudio.blob, uploadAudio.fileName); // The saved service is tested with the server's configuration of it. formData.append('previewProvider', entry.id); if (use.inUse && use.modelId) formData.append('previewModel', use.modelId); // 测试录音也在转录时解析,不能使用录音开始时闭包中的旧界面语言。 formData.append( 'language', resolveASRLanguage( providerId, useSettingsStore.getState().asrLanguage, localeRef.current, ), ); 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 (
{entry.state === 'deployment' && } {entry.state === 'server-only' && } {/* API Key & Base URL — the server's services are the operator's. */} {editable && (
saveServiceProvider(view, apply, entry, { apiKey }, t)} onRemove={() => saveServiceProvider(view, apply, entry, { apiKey: '' }, t)} />
{regionalEndpoint ? ( // Azure Speech takes its official endpoint for the key's region. saveServiceProvider(view, apply, entry, { baseUrl }, t)} /> ) : ( <> {asrProvider?.defaultBaseUrl && (

{t('settings.requestUrl')}: {asrProvider.defaultBaseUrl} {asrProvider.endpointPath ?? ''}

)} )}
)} {/* Test ASR */} {entry.state !== 'server-only' && (
)} {testMessage && (
{testStatus === 'success' && } {testStatus === 'error' && }

{testMessage}

)} {/* Model: the workspace's speech recognition model while this service is in use. */} {asrProvider?.models?.length > 0 && entry.state !== 'server-only' && (
{!use.inUse && (

{t('settings.serverConfig.modelWhenInUse')}

)}
)} {/* The language the user's speech input listens for (kept per user). */} {languages.length > 0 && (
)}
); }