tts-settings.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337
  1. 'use client';
  2. import { useState, useEffect } from 'react';
  3. import { Label } from '@/components/ui/label';
  4. import { Input } from '@/components/ui/input';
  5. import { Button } from '@/components/ui/button';
  6. import { useI18n } from '@/lib/hooks/use-i18n';
  7. import { useSettingsStore } from '@/lib/store/settings';
  8. import { TTS_PROVIDERS, DEFAULT_TTS_VOICES } from '@/lib/audio/constants';
  9. import type { TTSProviderId } from '@/lib/audio/types';
  10. import { Volume2, Loader2, CheckCircle2, XCircle, Eye, EyeOff } from 'lucide-react';
  11. import { cn } from '@/lib/utils';
  12. import { createLogger } from '@/lib/logger';
  13. import { useTTSPreview } from '@/lib/audio/use-tts-preview';
  14. const log = createLogger('TTSSettings');
  15. interface TTSSettingsProps {
  16. selectedProviderId: TTSProviderId;
  17. }
  18. export function TTSSettings({ selectedProviderId }: TTSSettingsProps) {
  19. const { t } = useI18n();
  20. const ttsVoice = useSettingsStore((state) => state.ttsVoice);
  21. const ttsSpeed = useSettingsStore((state) => state.ttsSpeed);
  22. const ttsProvidersConfig = useSettingsStore((state) => state.ttsProvidersConfig);
  23. const setTTSProviderConfig = useSettingsStore((state) => state.setTTSProviderConfig);
  24. const activeProviderId = useSettingsStore((state) => state.ttsProviderId);
  25. // When testing a non-active provider, use that provider's default voice
  26. // instead of the active provider's voice (which may be incompatible).
  27. const effectiveVoice =
  28. selectedProviderId === activeProviderId
  29. ? ttsVoice
  30. : DEFAULT_TTS_VOICES[selectedProviderId] || 'default';
  31. const ttsProvider = TTS_PROVIDERS[selectedProviderId] ?? TTS_PROVIDERS['openai-tts'];
  32. const isServerConfigured = !!ttsProvidersConfig[selectedProviderId]?.isServerConfigured;
  33. const [showApiKey, setShowApiKey] = useState(false);
  34. const [testText, setTestText] = useState(t('settings.ttsTestTextDefault'));
  35. const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'success' | 'error'>('idle');
  36. const [testMessage, setTestMessage] = useState('');
  37. const { previewing: testingTTS, startPreview, stopPreview } = useTTSPreview();
  38. // Doubao TTS uses compound "appId:accessKey" — split for separate UI fields
  39. const isDoubao = selectedProviderId === 'doubao-tts';
  40. const rawApiKey = ttsProvidersConfig[selectedProviderId]?.apiKey || '';
  41. const doubaoColonIdx = rawApiKey.indexOf(':');
  42. const doubaoAppId = isDoubao && doubaoColonIdx > 0 ? rawApiKey.slice(0, doubaoColonIdx) : '';
  43. const doubaoAccessKey =
  44. isDoubao && doubaoColonIdx > 0
  45. ? rawApiKey.slice(doubaoColonIdx + 1)
  46. : isDoubao
  47. ? rawApiKey
  48. : '';
  49. const setDoubaoCompoundKey = (appId: string, accessKey: string) => {
  50. const combined = appId && accessKey ? `${appId}:${accessKey}` : appId || accessKey;
  51. setTTSProviderConfig(selectedProviderId, { apiKey: combined });
  52. };
  53. // Keep the sample text in sync with locale changes.
  54. useEffect(() => {
  55. setTestText(t('settings.ttsTestTextDefault'));
  56. }, [t]);
  57. // Reset transient UI state when switching providers.
  58. useEffect(() => {
  59. stopPreview();
  60. setShowApiKey(false);
  61. setTestStatus('idle');
  62. setTestMessage('');
  63. }, [selectedProviderId, stopPreview]);
  64. const handleTestTTS = async () => {
  65. if (!testText.trim()) return;
  66. setTestStatus('testing');
  67. setTestMessage('');
  68. try {
  69. await startPreview({
  70. text: testText,
  71. providerId: selectedProviderId,
  72. modelId: ttsProvidersConfig[selectedProviderId]?.modelId || ttsProvider.defaultModelId,
  73. voice: effectiveVoice,
  74. speed: ttsSpeed,
  75. apiKey: ttsProvidersConfig[selectedProviderId]?.apiKey,
  76. baseUrl: ttsProvidersConfig[selectedProviderId]?.baseUrl,
  77. });
  78. setTestStatus('success');
  79. setTestMessage(t('settings.ttsTestSuccess'));
  80. } catch (error) {
  81. log.error('TTS test failed:', error);
  82. setTestStatus('error');
  83. setTestMessage(
  84. error instanceof Error && error.message
  85. ? `${t('settings.ttsTestFailed')}: ${error.message}`
  86. : t('settings.ttsTestFailed'),
  87. );
  88. }
  89. };
  90. return (
  91. <div className="space-y-6 max-w-3xl">
  92. {/* Server-configured notice */}
  93. {isServerConfigured && (
  94. <div className="rounded-lg border border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/30 p-3 text-sm text-blue-700 dark:text-blue-300">
  95. {t('settings.serverConfiguredNotice')}
  96. </div>
  97. )}
  98. {/* API Key & Base URL */}
  99. {(ttsProvider.requiresApiKey || isServerConfigured) && (
  100. <>
  101. <div className={cn('grid gap-4', isDoubao ? 'grid-cols-3' : 'grid-cols-2')}>
  102. {isDoubao ? (
  103. <>
  104. <div className="space-y-2">
  105. <Label className="text-sm">{t('settings.doubaoAppId')}</Label>
  106. <div className="relative">
  107. <Input
  108. name={`tts-app-id-${selectedProviderId}`}
  109. type={showApiKey ? 'text' : 'password'}
  110. autoComplete="new-password"
  111. autoCapitalize="none"
  112. autoCorrect="off"
  113. spellCheck={false}
  114. placeholder={
  115. isServerConfigured
  116. ? t('settings.optionalOverride')
  117. : t('settings.enterApiKey')
  118. }
  119. value={doubaoAppId}
  120. onChange={(e) => setDoubaoCompoundKey(e.target.value, doubaoAccessKey)}
  121. className="font-mono text-sm pr-10"
  122. />
  123. <button
  124. type="button"
  125. onClick={() => setShowApiKey(!showApiKey)}
  126. className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
  127. >
  128. {showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
  129. </button>
  130. </div>
  131. </div>
  132. <div className="space-y-2">
  133. <Label className="text-sm">{t('settings.doubaoAccessKey')}</Label>
  134. <div className="relative">
  135. <Input
  136. name={`tts-access-key-${selectedProviderId}`}
  137. type={showApiKey ? 'text' : 'password'}
  138. autoComplete="new-password"
  139. autoCapitalize="none"
  140. autoCorrect="off"
  141. spellCheck={false}
  142. placeholder={
  143. isServerConfigured
  144. ? t('settings.optionalOverride')
  145. : t('settings.enterApiKey')
  146. }
  147. value={doubaoAccessKey}
  148. onChange={(e) => setDoubaoCompoundKey(doubaoAppId, e.target.value)}
  149. className="font-mono text-sm pr-10"
  150. />
  151. <button
  152. type="button"
  153. onClick={() => setShowApiKey(!showApiKey)}
  154. className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
  155. >
  156. {showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
  157. </button>
  158. </div>
  159. </div>
  160. </>
  161. ) : (
  162. <div className="space-y-2">
  163. <Label className="text-sm">{t('settings.ttsApiKey')}</Label>
  164. <div className="relative">
  165. <Input
  166. name={`tts-api-key-${selectedProviderId}`}
  167. type={showApiKey ? 'text' : 'password'}
  168. autoComplete="new-password"
  169. autoCapitalize="none"
  170. autoCorrect="off"
  171. spellCheck={false}
  172. placeholder={
  173. isServerConfigured
  174. ? t('settings.optionalOverride')
  175. : t('settings.enterApiKey')
  176. }
  177. value={ttsProvidersConfig[selectedProviderId]?.apiKey || ''}
  178. onChange={(e) =>
  179. setTTSProviderConfig(selectedProviderId, {
  180. apiKey: e.target.value,
  181. })
  182. }
  183. className="font-mono text-sm pr-10"
  184. />
  185. <button
  186. type="button"
  187. onClick={() => setShowApiKey(!showApiKey)}
  188. className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
  189. >
  190. {showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
  191. </button>
  192. </div>
  193. </div>
  194. )}
  195. <div className="space-y-2">
  196. <Label className="text-sm">{t('settings.ttsBaseUrl')}</Label>
  197. <Input
  198. name={`tts-base-url-${selectedProviderId}`}
  199. autoComplete="off"
  200. autoCapitalize="none"
  201. autoCorrect="off"
  202. spellCheck={false}
  203. placeholder={ttsProvider.defaultBaseUrl || t('settings.enterCustomBaseUrl')}
  204. value={ttsProvidersConfig[selectedProviderId]?.baseUrl || ''}
  205. onChange={(e) =>
  206. setTTSProviderConfig(selectedProviderId, {
  207. baseUrl: e.target.value,
  208. })
  209. }
  210. className="text-sm"
  211. />
  212. </div>
  213. </div>
  214. {/* Request URL Preview */}
  215. {(() => {
  216. const effectiveBaseUrl =
  217. ttsProvidersConfig[selectedProviderId]?.baseUrl || ttsProvider.defaultBaseUrl || '';
  218. if (!effectiveBaseUrl) return null;
  219. let endpointPath = '';
  220. switch (selectedProviderId) {
  221. case 'openai-tts':
  222. case 'glm-tts':
  223. endpointPath = '/audio/speech';
  224. break;
  225. case 'azure-tts':
  226. endpointPath = '/cognitiveservices/v1';
  227. break;
  228. case 'qwen-tts':
  229. endpointPath = '/services/aigc/multimodal-generation/generation';
  230. break;
  231. case 'elevenlabs-tts':
  232. endpointPath = '/text-to-speech';
  233. break;
  234. case 'doubao-tts':
  235. endpointPath = '/unidirectional';
  236. break;
  237. }
  238. if (!endpointPath) return null;
  239. return (
  240. <p className="text-xs text-muted-foreground break-all">
  241. {t('settings.requestUrl')}: {effectiveBaseUrl + endpointPath}
  242. </p>
  243. );
  244. })()}
  245. </>
  246. )}
  247. {/* Test TTS */}
  248. <div className="space-y-2">
  249. <Label className="text-sm">{t('settings.testTTS')}</Label>
  250. <div className="flex gap-2">
  251. <Input
  252. placeholder={t('settings.ttsTestTextPlaceholder')}
  253. value={testText}
  254. onChange={(e) => setTestText(e.target.value)}
  255. className="flex-1"
  256. />
  257. <Button
  258. onClick={handleTestTTS}
  259. disabled={
  260. testingTTS ||
  261. !testText.trim() ||
  262. (ttsProvider.requiresApiKey &&
  263. !ttsProvidersConfig[selectedProviderId]?.apiKey?.trim() &&
  264. !isServerConfigured)
  265. }
  266. size="default"
  267. className="gap-2 w-32"
  268. >
  269. {testingTTS ? (
  270. <Loader2 className="h-4 w-4 animate-spin" />
  271. ) : (
  272. <Volume2 className="h-4 w-4" />
  273. )}
  274. {t('settings.testTTS')}
  275. </Button>
  276. </div>
  277. </div>
  278. {testMessage && (
  279. <div
  280. className={cn(
  281. 'rounded-lg p-3 text-sm overflow-hidden',
  282. testStatus === 'success' &&
  283. 'bg-green-50 text-green-700 border border-green-200 dark:bg-green-950/50 dark:text-green-400 dark:border-green-800',
  284. testStatus === 'error' &&
  285. 'bg-red-50 text-red-700 border border-red-200 dark:bg-red-950/50 dark:text-red-400 dark:border-red-800',
  286. )}
  287. >
  288. <div className="flex items-start gap-2 min-w-0">
  289. {testStatus === 'success' && <CheckCircle2 className="h-4 w-4 mt-0.5 shrink-0" />}
  290. {testStatus === 'error' && <XCircle className="h-4 w-4 mt-0.5 shrink-0" />}
  291. <p className="flex-1 min-w-0 break-all">{testMessage}</p>
  292. </div>
  293. </div>
  294. )}
  295. {/* Available Models */}
  296. {ttsProvider.models.length > 0 && (
  297. <div className="space-y-2">
  298. <Label className="text-sm text-muted-foreground">{t('settings.availableModels')}</Label>
  299. <div className="flex flex-wrap gap-2">
  300. {ttsProvider.models.map((model) => (
  301. <div
  302. key={model.id}
  303. className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md bg-muted/50 border border-border/40 text-xs font-mono text-muted-foreground"
  304. >
  305. <span className="size-1.5 rounded-full bg-emerald-500/70" />
  306. {model.name}
  307. </div>
  308. ))}
  309. </div>
  310. <p className="text-[11px] text-muted-foreground/60">
  311. {t('settings.modelSelectedViaVoice')}
  312. </p>
  313. </div>
  314. )}
  315. </div>
  316. );
  317. }