audio-settings.tsx 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745
  1. 'use client';
  2. import { useState, useRef, useEffect, useMemo, useCallback } from 'react';
  3. import { Label } from '@/components/ui/label';
  4. import { Input } from '@/components/ui/input';
  5. import {
  6. Select,
  7. SelectContent,
  8. SelectItem,
  9. SelectTrigger,
  10. SelectValue,
  11. } from '@/components/ui/select';
  12. import { Switch } from '@/components/ui/switch';
  13. import { Button } from '@/components/ui/button';
  14. import { useI18n } from '@/lib/hooks/use-i18n';
  15. import { useSettingsStore } from '@/lib/store/settings';
  16. import {
  17. TTS_PROVIDERS,
  18. getTTSVoices,
  19. ASR_PROVIDERS,
  20. getASRSupportedLanguages,
  21. } from '@/lib/audio/constants';
  22. import type { TTSProviderId, ASRProviderId } from '@/lib/audio/types';
  23. import { Volume2, Mic, MicOff, CheckCircle2, XCircle, Eye, EyeOff } from 'lucide-react';
  24. import { cn } from '@/lib/utils';
  25. import azureVoicesData from '@/lib/audio/azure.json';
  26. import { createLogger } from '@/lib/logger';
  27. const log = createLogger('AudioSettings');
  28. /**
  29. * Get provider display name with i18n
  30. */
  31. function getTTSProviderName(providerId: TTSProviderId, t: (key: string) => string): string {
  32. const names: Record<TTSProviderId, string> = {
  33. 'openai-tts': t('settings.providerOpenAITTS'),
  34. 'azure-tts': t('settings.providerAzureTTS'),
  35. 'glm-tts': t('settings.providerGLMTTS'),
  36. 'qwen-tts': t('settings.providerQwenTTS'),
  37. 'doubao-tts': t('settings.providerDoubaoTTS'),
  38. 'elevenlabs-tts': t('settings.providerElevenLabsTTS'),
  39. 'minimax-tts': t('settings.providerMiniMaxTTS'),
  40. 'browser-native-tts': t('settings.providerBrowserNativeTTS'),
  41. };
  42. return names[providerId];
  43. }
  44. function getASRProviderName(providerId: ASRProviderId, t: (key: string) => string): string {
  45. const names: Record<ASRProviderId, string> = {
  46. 'openai-whisper': t('settings.providerOpenAIWhisper'),
  47. 'browser-native': t('settings.providerBrowserNative'),
  48. 'qwen-asr': t('settings.providerQwenASR'),
  49. };
  50. return names[providerId];
  51. }
  52. function getLanguageName(code: string, t: (key: string) => string): string {
  53. const key = `settings.lang_${code}`;
  54. const translated = t(key);
  55. // If translation key not found, return the code itself
  56. return translated === key ? code : translated;
  57. }
  58. interface AudioSettingsProps {
  59. onSave?: () => void;
  60. }
  61. export function AudioSettings({ onSave }: AudioSettingsProps = {}) {
  62. const { t } = useI18n();
  63. // TTS state
  64. const ttsProviderId = useSettingsStore((state) => state.ttsProviderId);
  65. const ttsVoice = useSettingsStore((state) => state.ttsVoice);
  66. const ttsProvidersConfig = useSettingsStore((state) => state.ttsProvidersConfig);
  67. const setTTSProvider = useSettingsStore((state) => state.setTTSProvider);
  68. const setTTSVoice = useSettingsStore((state) => state.setTTSVoice);
  69. const setTTSProviderConfig = useSettingsStore((state) => state.setTTSProviderConfig);
  70. // ASR state
  71. const asrProviderId = useSettingsStore((state) => state.asrProviderId);
  72. const asrLanguage = useSettingsStore((state) => state.asrLanguage);
  73. const asrProvidersConfig = useSettingsStore((state) => state.asrProvidersConfig);
  74. const setASRProvider = useSettingsStore((state) => state.setASRProvider);
  75. const setASRLanguage = useSettingsStore((state) => state.setASRLanguage);
  76. const setASRProviderConfig = useSettingsStore((state) => state.setASRProviderConfig);
  77. const ttsEnabled = useSettingsStore((state) => state.ttsEnabled);
  78. const asrEnabled = useSettingsStore((state) => state.asrEnabled);
  79. const setTTSEnabled = useSettingsStore((state) => state.setTTSEnabled);
  80. const setASREnabled = useSettingsStore((state) => state.setASREnabled);
  81. const ttsProvider = TTS_PROVIDERS[ttsProviderId] ?? TTS_PROVIDERS['openai-tts'];
  82. // Azure voices - load from static JSON
  83. const azureVoices = useMemo(() => azureVoicesData.voices, []);
  84. // Wrapped setters that trigger onSave callback
  85. const handleTTSProviderChange = (providerId: TTSProviderId) => {
  86. setTTSProvider(providerId);
  87. onSave?.();
  88. };
  89. const handleTTSProviderConfigChange = (
  90. providerId: TTSProviderId,
  91. config: Partial<{ apiKey: string; baseUrl: string; model?: string; enabled: boolean }>,
  92. ) => {
  93. setTTSProviderConfig(providerId, config);
  94. onSave?.();
  95. };
  96. const handleASRProviderChange = (providerId: ASRProviderId) => {
  97. setASRProvider(providerId);
  98. onSave?.();
  99. };
  100. const handleASRLanguageChange = (language: string) => {
  101. setASRLanguage(language);
  102. onSave?.();
  103. };
  104. const handleASRProviderConfigChange = (
  105. providerId: ASRProviderId,
  106. config: Partial<{ apiKey: string; baseUrl: string; enabled: boolean }>,
  107. ) => {
  108. setASRProviderConfig(providerId, config);
  109. onSave?.();
  110. };
  111. // Password visibility state
  112. const [showTTSApiKey, setShowTTSApiKey] = useState(false);
  113. const [showASRApiKey, setShowASRApiKey] = useState(false);
  114. // Language filter state
  115. const [selectedLocale, setSelectedLocale] = useState<string>('all');
  116. // Test state
  117. const [isRecording, setIsRecording] = useState(false);
  118. const [asrResult, setASRResult] = useState('');
  119. const [asrTestStatus, setASRTestStatus] = useState<'idle' | 'testing' | 'success' | 'error'>(
  120. 'idle',
  121. );
  122. const [asrTestMessage, setASRTestMessage] = useState('');
  123. const audioRef = useRef<HTMLAudioElement>(null);
  124. const audioUrlRef = useRef<string | null>(null);
  125. const browserPreviewCancelRef = useRef<(() => void) | null>(null);
  126. const ttsTestRequestIdRef = useRef(0);
  127. const mediaRecorderRef = useRef<MediaRecorder | null>(null);
  128. const asrProvider = ASR_PROVIDERS[asrProviderId] ?? ASR_PROVIDERS['openai-whisper'];
  129. // Reset locale filter when provider changes (derived state pattern)
  130. const [prevTTSProviderId, setPrevTTSProviderId] = useState(ttsProviderId);
  131. if (ttsProviderId !== prevTTSProviderId) {
  132. setPrevTTSProviderId(ttsProviderId);
  133. if (ttsProviderId !== 'azure-tts') {
  134. setSelectedLocale('all');
  135. }
  136. }
  137. const stopTTSPreview = useCallback(() => {
  138. ttsTestRequestIdRef.current += 1;
  139. browserPreviewCancelRef.current?.();
  140. browserPreviewCancelRef.current = null;
  141. audioRef.current?.pause();
  142. if (audioRef.current) {
  143. audioRef.current.src = '';
  144. }
  145. if (audioUrlRef.current) {
  146. URL.revokeObjectURL(audioUrlRef.current);
  147. audioUrlRef.current = null;
  148. }
  149. }, []);
  150. // Update voice selection when locale filter changes
  151. useEffect(() => {
  152. if (ttsProviderId === 'azure-tts' && selectedLocale !== 'all') {
  153. // Filter Azure voices by selected locale
  154. const filteredVoices = azureVoices.filter((voice) => voice.Locale === selectedLocale);
  155. // Check if current voice is in the filtered list
  156. const currentVoiceInFilter = filteredVoices.some((voice) => voice.ShortName === ttsVoice);
  157. // If current voice is not in filtered list, select the first voice in the filtered list
  158. if (!currentVoiceInFilter && filteredVoices.length > 0) {
  159. setTTSVoice(filteredVoices[0].ShortName);
  160. }
  161. }
  162. // Intentionally exclude ttsVoice from dependencies to avoid infinite loop
  163. // eslint-disable-next-line react-hooks/exhaustive-deps
  164. }, [selectedLocale, ttsProviderId, azureVoices, setTTSVoice]);
  165. useEffect(() => {
  166. stopTTSPreview();
  167. }, [ttsProviderId, stopTTSPreview]);
  168. // Initialize and reset TTS voice when provider changes
  169. useEffect(() => {
  170. let availableVoices: Array<{ id: string; name: string }> = [];
  171. if (ttsProviderId === 'azure-tts') {
  172. // Use Azure voices from JSON
  173. availableVoices = azureVoices.map((voice) => ({
  174. id: voice.ShortName,
  175. name: voice.LocalName,
  176. }));
  177. } else {
  178. // Use static voices from constants
  179. availableVoices = getTTSVoices(ttsProviderId);
  180. }
  181. if (availableVoices.length > 0) {
  182. // Initialize default voice if not set
  183. if (!ttsVoice) {
  184. setTTSVoice(availableVoices[0].id);
  185. } else {
  186. // Check if current voice is available in new provider
  187. const currentVoiceExists = availableVoices.some((v) => v.id === ttsVoice);
  188. if (!currentVoiceExists) {
  189. setTTSVoice(availableVoices[0].id);
  190. }
  191. }
  192. }
  193. }, [ttsProviderId, ttsVoice, azureVoices, setTTSVoice]);
  194. // Initialize and reset ASR language when provider changes
  195. useEffect(() => {
  196. const availableLanguages = getASRSupportedLanguages(asrProviderId);
  197. if (availableLanguages.length > 0) {
  198. // Initialize default language if not set
  199. if (!asrLanguage) {
  200. setASRLanguage(availableLanguages[0]);
  201. } else {
  202. // Check if current language is available in new provider
  203. const currentLanguageExists = availableLanguages.includes(asrLanguage);
  204. if (!currentLanguageExists) {
  205. setASRLanguage(availableLanguages[0]);
  206. }
  207. }
  208. }
  209. }, [asrProviderId, asrLanguage, setASRLanguage]);
  210. useEffect(() => {
  211. return () => {
  212. stopTTSPreview();
  213. };
  214. }, [stopTTSPreview]);
  215. // Clear ASR test status when provider changes (derived state pattern)
  216. const [prevASRProviderId, setPrevASRProviderId] = useState(asrProviderId);
  217. if (asrProviderId !== prevASRProviderId) {
  218. setPrevASRProviderId(asrProviderId);
  219. setASRTestStatus('idle');
  220. setASRTestMessage('');
  221. setASRResult('');
  222. }
  223. // Test ASR
  224. const handleToggleASRRecording = async () => {
  225. if (isRecording) {
  226. if (mediaRecorderRef.current && mediaRecorderRef.current.state === 'recording') {
  227. mediaRecorderRef.current.stop();
  228. }
  229. setIsRecording(false);
  230. } else {
  231. setASRResult('');
  232. setASRTestStatus('testing');
  233. setASRTestMessage('');
  234. if (asrProviderId === 'browser-native') {
  235. const SpeechRecognitionCtor =
  236. (window as unknown as Record<string, unknown>).SpeechRecognition ||
  237. (window as unknown as Record<string, unknown>).webkitSpeechRecognition;
  238. if (!SpeechRecognitionCtor) {
  239. setASRTestStatus('error');
  240. setASRTestMessage(t('settings.asrNotSupported'));
  241. return;
  242. }
  243. // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Vendor-prefixed API without standard typings
  244. const recognition = new (SpeechRecognitionCtor as new () => any)();
  245. recognition.lang = asrLanguage || 'zh-CN';
  246. recognition.onresult = (event: {
  247. results: {
  248. [index: number]: { [index: number]: { transcript: string } };
  249. };
  250. }) => {
  251. const transcript = event.results[0][0].transcript;
  252. setASRResult(transcript);
  253. setASRTestStatus('success');
  254. setASRTestMessage(t('settings.asrTestSuccess'));
  255. };
  256. recognition.onerror = (event: { error: string }) => {
  257. log.error('Speech recognition error:', event.error);
  258. setASRTestStatus('error');
  259. setASRTestMessage(t('settings.asrTestFailed') + ': ' + event.error);
  260. };
  261. recognition.onend = () => {
  262. setIsRecording(false);
  263. };
  264. recognition.start();
  265. setIsRecording(true);
  266. } else {
  267. try {
  268. const stream = await navigator.mediaDevices.getUserMedia({
  269. audio: true,
  270. });
  271. const mediaRecorder = new MediaRecorder(stream);
  272. mediaRecorderRef.current = mediaRecorder;
  273. const audioChunks: Blob[] = [];
  274. mediaRecorder.ondataavailable = (event) => {
  275. audioChunks.push(event.data);
  276. };
  277. mediaRecorder.onstop = async () => {
  278. stream.getTracks().forEach((track) => track.stop());
  279. const audioBlob = new Blob(audioChunks, { type: 'audio/webm' });
  280. const formData = new FormData();
  281. formData.append('audio', audioBlob, 'recording.webm');
  282. formData.append('providerId', asrProviderId);
  283. formData.append('language', asrLanguage);
  284. // Only append non-empty values
  285. const apiKeyValue = asrProvidersConfig[asrProviderId]?.apiKey;
  286. if (apiKeyValue && apiKeyValue.trim()) {
  287. formData.append('apiKey', apiKeyValue);
  288. }
  289. const baseUrlValue = asrProvidersConfig[asrProviderId]?.baseUrl;
  290. if (baseUrlValue && baseUrlValue.trim()) {
  291. formData.append('baseUrl', baseUrlValue);
  292. }
  293. try {
  294. const response = await fetch('/api/transcription', {
  295. method: 'POST',
  296. body: formData,
  297. });
  298. if (response.ok) {
  299. const data = await response.json();
  300. setASRResult(data.text);
  301. setASRTestStatus('success');
  302. setASRTestMessage(t('settings.asrTestSuccess'));
  303. } else {
  304. setASRTestStatus('error');
  305. const errorData = await response
  306. .json()
  307. .catch(() => ({ error: response.statusText }));
  308. // Show details if available, otherwise show error message
  309. setASRTestMessage(
  310. errorData.details || errorData.error || t('settings.asrTestFailed'),
  311. );
  312. }
  313. } catch (error) {
  314. log.error('ASR test failed:', error);
  315. setASRTestStatus('error');
  316. setASRTestMessage(t('settings.asrTestFailed'));
  317. }
  318. };
  319. mediaRecorder.start();
  320. setIsRecording(true);
  321. } catch (error) {
  322. log.error('Failed to access microphone:', error);
  323. setASRTestStatus('error');
  324. setASRTestMessage(t('settings.microphoneAccessFailed'));
  325. }
  326. }
  327. }
  328. };
  329. return (
  330. <div className="space-y-6 max-w-4xl">
  331. {/* TTS Section */}
  332. <div className="space-y-4">
  333. <div
  334. className={cn(
  335. 'relative flex items-center gap-3 rounded-lg border px-4 py-3 transition-all duration-300',
  336. ttsEnabled ? 'bg-background border-border' : 'bg-muted/30 border-transparent',
  337. )}
  338. >
  339. <div
  340. className={cn(
  341. 'absolute left-0 top-2.5 bottom-2.5 w-[3px] rounded-full transition-colors duration-300',
  342. ttsEnabled ? 'bg-primary' : 'bg-muted-foreground/20',
  343. )}
  344. />
  345. <div
  346. className={cn(
  347. 'flex h-8 w-8 shrink-0 items-center justify-center rounded-md transition-colors duration-300',
  348. ttsEnabled ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground',
  349. )}
  350. >
  351. <Volume2 className="h-4 w-4" />
  352. </div>
  353. <div className="flex-1 min-w-0">
  354. <h3
  355. className={cn(
  356. 'text-sm font-medium transition-colors duration-300',
  357. !ttsEnabled && 'text-muted-foreground',
  358. )}
  359. >
  360. {t('settings.ttsSection')}
  361. </h3>
  362. <p className="text-xs text-muted-foreground">{t('settings.ttsEnabledDescription')}</p>
  363. </div>
  364. <Switch
  365. checked={ttsEnabled}
  366. onCheckedChange={(checked) => {
  367. setTTSEnabled(checked);
  368. onSave?.();
  369. }}
  370. />
  371. </div>
  372. <div
  373. className={cn(
  374. 'space-y-4 transition-all duration-300 overflow-hidden',
  375. ttsEnabled ? 'opacity-100' : 'opacity-40 max-h-0 pointer-events-none',
  376. )}
  377. >
  378. <p className="text-xs text-muted-foreground">{t('settings.ttsVoiceConfigHint')}</p>
  379. <div className="space-y-2">
  380. <Label className="text-sm">{t('settings.ttsProvider')}</Label>
  381. <Select
  382. value={ttsProviderId}
  383. onValueChange={(value) => handleTTSProviderChange(value as TTSProviderId)}
  384. >
  385. <SelectTrigger>
  386. <SelectValue />
  387. </SelectTrigger>
  388. <SelectContent>
  389. {Object.values(TTS_PROVIDERS).map((provider) => (
  390. <SelectItem key={provider.id} value={provider.id}>
  391. <div className="flex items-center gap-2">
  392. {provider.icon && (
  393. <img src={provider.icon} alt={provider.name} className="w-4 h-4" />
  394. )}
  395. {getTTSProviderName(provider.id, t)}
  396. {ttsProvidersConfig[provider.id]?.isServerConfigured && (
  397. <span className="text-[10px] px-1 py-0.5 rounded border text-muted-foreground">
  398. {t('settings.serverConfigured')}
  399. </span>
  400. )}
  401. </div>
  402. </SelectItem>
  403. ))}
  404. </SelectContent>
  405. </Select>
  406. </div>
  407. {(ttsProvider.requiresApiKey ||
  408. ttsProvidersConfig[ttsProviderId]?.isServerConfigured) && (
  409. <>
  410. <div className="grid grid-cols-2 gap-4">
  411. <div className="space-y-2">
  412. <Label className="text-sm">{t('settings.ttsApiKey')}</Label>
  413. <div className="relative">
  414. <Input
  415. type={showTTSApiKey ? 'text' : 'password'}
  416. placeholder={
  417. ttsProvidersConfig[ttsProviderId]?.isServerConfigured
  418. ? t('settings.optionalOverride')
  419. : t('settings.enterApiKey')
  420. }
  421. value={ttsProvidersConfig[ttsProviderId]?.apiKey || ''}
  422. onChange={(e) =>
  423. handleTTSProviderConfigChange(ttsProviderId, {
  424. apiKey: e.target.value,
  425. })
  426. }
  427. className="font-mono text-sm pr-10"
  428. />
  429. <button
  430. type="button"
  431. onClick={() => setShowTTSApiKey(!showTTSApiKey)}
  432. className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
  433. >
  434. {showTTSApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
  435. </button>
  436. </div>
  437. </div>
  438. <div className="space-y-2">
  439. <Label className="text-sm">{t('settings.ttsBaseUrl')}</Label>
  440. <Input
  441. placeholder={ttsProvider.defaultBaseUrl || t('settings.enterCustomBaseUrl')}
  442. value={ttsProvidersConfig[ttsProviderId]?.baseUrl || ''}
  443. onChange={(e) =>
  444. handleTTSProviderConfigChange(ttsProviderId, {
  445. baseUrl: e.target.value,
  446. })
  447. }
  448. className="text-sm"
  449. />
  450. </div>
  451. </div>
  452. </>
  453. )}
  454. </div>
  455. </div>
  456. {/* ASR Section */}
  457. <div className="space-y-4 pt-4 border-t">
  458. <div
  459. className={cn(
  460. 'relative flex items-center gap-3 rounded-lg border px-4 py-3 transition-all duration-300',
  461. asrEnabled ? 'bg-background border-border' : 'bg-muted/30 border-transparent',
  462. )}
  463. >
  464. <div
  465. className={cn(
  466. 'absolute left-0 top-2.5 bottom-2.5 w-[3px] rounded-full transition-colors duration-300',
  467. asrEnabled ? 'bg-primary' : 'bg-muted-foreground/20',
  468. )}
  469. />
  470. <div
  471. className={cn(
  472. 'flex h-8 w-8 shrink-0 items-center justify-center rounded-md transition-colors duration-300',
  473. asrEnabled ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground',
  474. )}
  475. >
  476. <Mic className="h-4 w-4" />
  477. </div>
  478. <div className="flex-1 min-w-0">
  479. <h3
  480. className={cn(
  481. 'text-sm font-medium transition-colors duration-300',
  482. !asrEnabled && 'text-muted-foreground',
  483. )}
  484. >
  485. {t('settings.asrSection')}
  486. </h3>
  487. <p className="text-xs text-muted-foreground">{t('settings.asrEnabledDescription')}</p>
  488. </div>
  489. <Switch
  490. checked={asrEnabled}
  491. onCheckedChange={(checked) => {
  492. setASREnabled(checked);
  493. onSave?.();
  494. }}
  495. />
  496. </div>
  497. <div
  498. className={cn(
  499. 'space-y-4 transition-all duration-300 overflow-hidden',
  500. asrEnabled ? 'opacity-100' : 'opacity-40 max-h-0 pointer-events-none',
  501. )}
  502. >
  503. <div className="space-y-2">
  504. <Label className="text-sm">{t('settings.asrProvider')}</Label>
  505. <Select
  506. value={asrProviderId}
  507. onValueChange={(value) => handleASRProviderChange(value as ASRProviderId)}
  508. >
  509. <SelectTrigger>
  510. <SelectValue />
  511. </SelectTrigger>
  512. <SelectContent>
  513. {Object.values(ASR_PROVIDERS).map((provider) => (
  514. <SelectItem key={provider.id} value={provider.id}>
  515. <div className="flex items-center gap-2">
  516. {provider.icon && (
  517. <img src={provider.icon} alt={provider.name} className="w-4 h-4" />
  518. )}
  519. {getASRProviderName(provider.id, t)}
  520. {asrProvidersConfig[provider.id]?.isServerConfigured && (
  521. <span className="text-[10px] px-1 py-0.5 rounded border text-muted-foreground">
  522. {t('settings.serverConfigured')}
  523. </span>
  524. )}
  525. </div>
  526. </SelectItem>
  527. ))}
  528. </SelectContent>
  529. </Select>
  530. </div>
  531. {(asrProvider.requiresApiKey ||
  532. asrProvidersConfig[asrProviderId]?.isServerConfigured) && (
  533. <>
  534. <div className="grid grid-cols-2 gap-4">
  535. <div className="space-y-2">
  536. <Label className="text-sm">{t('settings.asrApiKey')}</Label>
  537. <div className="relative">
  538. <Input
  539. type={showASRApiKey ? 'text' : 'password'}
  540. placeholder={
  541. asrProvidersConfig[asrProviderId]?.isServerConfigured
  542. ? t('settings.optionalOverride')
  543. : t('settings.enterApiKey')
  544. }
  545. value={asrProvidersConfig[asrProviderId]?.apiKey || ''}
  546. onChange={(e) =>
  547. handleASRProviderConfigChange(asrProviderId, {
  548. apiKey: e.target.value,
  549. })
  550. }
  551. className="font-mono text-sm pr-10"
  552. />
  553. <button
  554. type="button"
  555. onClick={() => setShowASRApiKey(!showASRApiKey)}
  556. className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
  557. >
  558. {showASRApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
  559. </button>
  560. </div>
  561. </div>
  562. <div className="space-y-2">
  563. <Label className="text-sm">{t('settings.asrBaseUrl')}</Label>
  564. <Input
  565. placeholder={asrProvider.defaultBaseUrl || t('settings.enterCustomBaseUrl')}
  566. value={asrProvidersConfig[asrProviderId]?.baseUrl || ''}
  567. onChange={(e) =>
  568. handleASRProviderConfigChange(asrProviderId, {
  569. baseUrl: e.target.value,
  570. })
  571. }
  572. className="text-sm"
  573. />
  574. </div>
  575. </div>
  576. {(() => {
  577. const effectiveBaseUrl =
  578. asrProvidersConfig[asrProviderId]?.baseUrl || asrProvider.defaultBaseUrl || '';
  579. if (!effectiveBaseUrl) return null;
  580. // Get endpoint path based on provider
  581. let endpointPath = '';
  582. switch (asrProviderId) {
  583. case 'openai-whisper':
  584. endpointPath = '/audio/transcriptions';
  585. break;
  586. case 'qwen-asr':
  587. endpointPath = '/services/aigc/multimodal-generation/generation';
  588. break;
  589. default:
  590. endpointPath = '';
  591. }
  592. if (!endpointPath) return null;
  593. const fullUrl = effectiveBaseUrl + endpointPath;
  594. return (
  595. <p className="text-xs text-muted-foreground break-all">
  596. {t('settings.requestUrl')}: {fullUrl}
  597. </p>
  598. );
  599. })()}
  600. </>
  601. )}
  602. {(() => {
  603. const supportedLanguages = getASRSupportedLanguages(asrProviderId);
  604. const hasLanguageSelection = supportedLanguages.length > 0;
  605. return (
  606. <div
  607. className="grid gap-4"
  608. style={{
  609. gridTemplateColumns: hasLanguageSelection ? '160px 1fr' : '1fr',
  610. }}
  611. >
  612. {hasLanguageSelection && (
  613. <div className="space-y-2">
  614. <Label className="text-sm">{t('settings.asrLanguage')}</Label>
  615. <Select value={asrLanguage} onValueChange={handleASRLanguageChange}>
  616. <SelectTrigger className="w-full">
  617. <SelectValue />
  618. </SelectTrigger>
  619. <SelectContent>
  620. {supportedLanguages.map((lang) => (
  621. <SelectItem key={lang} value={lang}>
  622. {getLanguageName(lang, t)}
  623. </SelectItem>
  624. ))}
  625. </SelectContent>
  626. </Select>
  627. </div>
  628. )}
  629. <div className="space-y-2">
  630. <Label className="text-sm">{t('settings.testASR')}</Label>
  631. <div className="flex gap-2">
  632. <Input
  633. value={asrResult}
  634. readOnly
  635. placeholder={t('settings.asrResultPlaceholder')}
  636. className="flex-1 bg-muted/50"
  637. />
  638. <Button
  639. onClick={handleToggleASRRecording}
  640. disabled={
  641. asrProvider.requiresApiKey &&
  642. !asrProvidersConfig[asrProviderId]?.apiKey?.trim() &&
  643. !asrProvidersConfig[asrProviderId]?.isServerConfigured
  644. }
  645. className="gap-2 w-[140px]"
  646. >
  647. {isRecording ? (
  648. <>
  649. <MicOff className="h-4 w-4" />
  650. {t('settings.stopRecording')}
  651. </>
  652. ) : (
  653. <>
  654. <Mic className="h-4 w-4" />
  655. {t('settings.startRecording')}
  656. </>
  657. )}
  658. </Button>
  659. </div>
  660. </div>
  661. </div>
  662. );
  663. })()}
  664. {asrTestMessage && (
  665. <div
  666. className={cn(
  667. 'rounded-lg p-3 text-sm overflow-hidden',
  668. asrTestStatus === 'success' &&
  669. 'bg-green-50 text-green-700 border border-green-200 dark:bg-green-950/50 dark:text-green-400 dark:border-green-800',
  670. asrTestStatus === 'error' &&
  671. 'bg-red-50 text-red-700 border border-red-200 dark:bg-red-950/50 dark:text-red-400 dark:border-red-800',
  672. )}
  673. >
  674. <div className="flex items-start gap-2 min-w-0">
  675. {asrTestStatus === 'success' && (
  676. <CheckCircle2 className="h-4 w-4 mt-0.5 shrink-0" />
  677. )}
  678. {asrTestStatus === 'error' && <XCircle className="h-4 w-4 mt-0.5 shrink-0" />}
  679. <p className="flex-1 min-w-0 break-all">{asrTestMessage}</p>
  680. </div>
  681. </div>
  682. )}
  683. </div>
  684. </div>
  685. </div>
  686. );
  687. }