mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-27 04:31:27 +08:00
Add files via upload
TTS
This commit is contained in:
parent
1fcee6c829
commit
b48af7e27b
64
src/renderer/src/components/TTSButton.tsx
Normal file
64
src/renderer/src/components/TTSButton.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
import { SoundOutlined } from '@ant-design/icons'
|
||||
import { Button, Tooltip } from 'antd'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import TTSService from '@renderer/services/TTSService'
|
||||
import { Message } from '@renderer/types'
|
||||
|
||||
interface TTSButtonProps {
|
||||
message: Message
|
||||
className?: string
|
||||
}
|
||||
|
||||
const TTSButton: React.FC<TTSButtonProps> = ({ message, className }) => {
|
||||
const { t } = useTranslation()
|
||||
const [isSpeaking, setIsSpeaking] = useState(false)
|
||||
|
||||
const handleTTS = useCallback(async () => {
|
||||
if (isSpeaking) {
|
||||
TTSService.stop()
|
||||
setIsSpeaking(false)
|
||||
return
|
||||
}
|
||||
|
||||
setIsSpeaking(true)
|
||||
try {
|
||||
await TTSService.speakFromMessage(message)
|
||||
|
||||
// 监听播放结束
|
||||
const checkPlayingStatus = () => {
|
||||
if (!TTSService.isCurrentlyPlaying()) {
|
||||
setIsSpeaking(false)
|
||||
clearInterval(checkInterval)
|
||||
}
|
||||
}
|
||||
|
||||
const checkInterval = setInterval(checkPlayingStatus, 500)
|
||||
|
||||
// 安全机制,确保即使出错也会重置状态
|
||||
setTimeout(() => {
|
||||
if (isSpeaking) {
|
||||
TTSService.stop()
|
||||
setIsSpeaking(false)
|
||||
clearInterval(checkInterval)
|
||||
}
|
||||
}, 30000) // 30秒后检查
|
||||
} catch (error) {
|
||||
console.error('TTS error:', error)
|
||||
setIsSpeaking(false)
|
||||
}
|
||||
}, [isSpeaking, message])
|
||||
|
||||
return (
|
||||
<Tooltip title={isSpeaking ? t('chat.tts.stop') : t('chat.tts.play')}>
|
||||
<Button
|
||||
className={className}
|
||||
icon={<SoundOutlined />}
|
||||
onClick={handleTTS}
|
||||
type={isSpeaking ? 'primary' : 'default'}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export default TTSButton
|
||||
@ -1317,6 +1317,49 @@
|
||||
"privacy": {
|
||||
"title": "Privacy Settings",
|
||||
"enable_privacy_mode": "Anonymous reporting of errors and statistics"
|
||||
},
|
||||
"tts": {
|
||||
"title": "Text-to-Speech Settings",
|
||||
"enable": "Enable Text-to-Speech",
|
||||
"enable.help": "Enable to convert text to speech",
|
||||
"reset": "Reset",
|
||||
"reset_title": "Reset Custom Voices and Models",
|
||||
"reset_confirm": "Are you sure you want to reset all custom voices and models? This will delete all custom items you've added.",
|
||||
"reset_success": "Reset successful",
|
||||
"reset_help": "If voices or models display abnormally, try resetting all custom items",
|
||||
"api_settings": "API Settings",
|
||||
"service_type": "Service Type",
|
||||
"service_type.openai": "OpenAI",
|
||||
"service_type.edge": "Edge TTS",
|
||||
"service_type.refresh": "Refresh TTS service type settings",
|
||||
"service_type.refreshed": "TTS service type settings refreshed",
|
||||
"api_key": "API Key",
|
||||
"api_key.placeholder": "Enter OpenAI API key",
|
||||
"api_url": "API URL",
|
||||
"api_url.placeholder": "Example: https://api.openai.com/v1/audio/speech",
|
||||
"edge_voice": "Edge TTS Voice",
|
||||
"edge_voice.loading": "Loading...",
|
||||
"edge_voice.refresh": "Refresh available voices",
|
||||
"edge_voice.not_found": "No matching voices found",
|
||||
"voice": "Voice",
|
||||
"voice.placeholder": "Select a voice",
|
||||
"voice_input_placeholder": "Enter voice",
|
||||
"voice_add": "Add",
|
||||
"voice_empty": "No custom voices yet, please add below",
|
||||
"model": "Model",
|
||||
"model.placeholder": "Select a model",
|
||||
"model_input_placeholder": "Enter model",
|
||||
"model_add": "Add",
|
||||
"model_empty": "No custom models yet, please add below",
|
||||
"filter_options": "Filter Options",
|
||||
"filter.thinking_process": "Filter thinking process",
|
||||
"filter.markdown": "Filter Markdown",
|
||||
"filter.code_blocks": "Filter code blocks",
|
||||
"filter.html_tags": "Filter HTML tags",
|
||||
"max_text_length": "Maximum text length",
|
||||
"test": "Test Speech",
|
||||
"help": "Text-to-speech functionality supports converting text to natural-sounding speech.",
|
||||
"learn_more": "Learn more"
|
||||
}
|
||||
},
|
||||
"translate": {
|
||||
|
||||
@ -1317,6 +1317,49 @@
|
||||
"privacy": {
|
||||
"title": "隐私设置",
|
||||
"enable_privacy_mode": "匿名发送错误报告和数据统计"
|
||||
},
|
||||
"tts": {
|
||||
"title": "语音合成设置",
|
||||
"enable": "启用语音合成",
|
||||
"enable.help": "启用后可以将文本转换为语音",
|
||||
"reset": "重置",
|
||||
"reset_title": "重置自定义音色和模型",
|
||||
"reset_confirm": "确定要重置所有自定义音色和模型吗?这将删除所有已添加的自定义项。",
|
||||
"reset_success": "重置成功",
|
||||
"reset_help": "如果音色或模型显示异常,可以尝试重置所有自定义项",
|
||||
"api_settings": "API设置",
|
||||
"service_type": "服务类型",
|
||||
"service_type.openai": "OpenAI",
|
||||
"service_type.edge": "Edge TTS",
|
||||
"service_type.refresh": "刷新TTS服务类型设置",
|
||||
"service_type.refreshed": "已刷新TTS服务类型设置",
|
||||
"api_key": "API密钥",
|
||||
"api_key.placeholder": "请输入OpenAI API密钥",
|
||||
"api_url": "API地址",
|
||||
"api_url.placeholder": "例如:https://api.openai.com/v1/audio/speech",
|
||||
"edge_voice": "Edge TTS音色",
|
||||
"edge_voice.loading": "加载中...",
|
||||
"edge_voice.refresh": "刷新可用音色列表",
|
||||
"edge_voice.not_found": "未找到匹配的音色",
|
||||
"voice": "音色",
|
||||
"voice.placeholder": "请选择音色",
|
||||
"voice_input_placeholder": "输入音色",
|
||||
"voice_add": "添加",
|
||||
"voice_empty": "暂无自定义音色,请在下方添加",
|
||||
"model": "模型",
|
||||
"model.placeholder": "请选择模型",
|
||||
"model_input_placeholder": "输入模型",
|
||||
"model_add": "添加",
|
||||
"model_empty": "暂无自定义模型,请在下方添加",
|
||||
"filter_options": "过滤选项",
|
||||
"filter.thinking_process": "过滤思考过程",
|
||||
"filter.markdown": "过滤Markdown标记",
|
||||
"filter.code_blocks": "过滤代码块",
|
||||
"filter.html_tags": "过滤HTML标签",
|
||||
"max_text_length": "最大文本长度",
|
||||
"test": "测试语音",
|
||||
"help": "语音合成功能支持将文本转换为自然语音。",
|
||||
"learn_more": "了解更多"
|
||||
}
|
||||
},
|
||||
"translate": {
|
||||
|
||||
@ -8,6 +8,7 @@ import {
|
||||
MenuOutlined,
|
||||
QuestionCircleOutlined,
|
||||
SaveOutlined,
|
||||
SoundOutlined,
|
||||
SyncOutlined,
|
||||
TranslationOutlined
|
||||
} from '@ant-design/icons'
|
||||
@ -15,11 +16,13 @@ import { UploadOutlined } from '@ant-design/icons'
|
||||
import ObsidianExportPopup from '@renderer/components/Popups/ObsidianExportPopup'
|
||||
import SelectModelPopup from '@renderer/components/Popups/SelectModelPopup'
|
||||
import TextEditPopup from '@renderer/components/Popups/TextEditPopup'
|
||||
import TTSButton from '@renderer/components/TTSButton'
|
||||
import { isReasoningModel } from '@renderer/config/models'
|
||||
import { TranslateLanguageOptions } from '@renderer/config/translate'
|
||||
import { useMessageOperations, useTopicLoading } from '@renderer/hooks/useMessageOperations'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import { getMessageTitle, resetAssistantMessage } from '@renderer/services/MessagesService'
|
||||
import TTSService from '@renderer/services/TTSService'
|
||||
import { translateText } from '@renderer/services/TranslateService'
|
||||
import { RootState } from '@renderer/store'
|
||||
import type { Message, Model } from '@renderer/types'
|
||||
@ -61,6 +64,7 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
const { t } = useTranslation()
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [isTranslating, setIsTranslating] = useState(false)
|
||||
const [isSpeaking, setIsSpeaking] = useState(false)
|
||||
const [showRegenerateTooltip, setShowRegenerateTooltip] = useState(false)
|
||||
const [showDeleteTooltip, setShowDeleteTooltip] = useState(false)
|
||||
const assistantModel = assistant?.model
|
||||
@ -85,6 +89,9 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
}
|
||||
)
|
||||
|
||||
// 获取TTS设置
|
||||
const ttsEnabled = useSelector((state: RootState) => state.settings.ttsEnabled)
|
||||
|
||||
const onCopy = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
@ -181,6 +188,64 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
[isTranslating, message, editMessage, setStreamMessage, commitStreamMessage, clearStreamMessage, t]
|
||||
)
|
||||
|
||||
// 处理TTS功能
|
||||
const handleTTS = useCallback(async () => {
|
||||
console.log('点击TTS按钮,当前状态:', isSpeaking ? '正在播放' : '未播放')
|
||||
|
||||
if (isSpeaking) {
|
||||
// 如果正在播放,则停止
|
||||
console.log('正在播放,执行停止操作')
|
||||
TTSService.stop()
|
||||
setIsSpeaking(false)
|
||||
return
|
||||
}
|
||||
|
||||
// 先停止所有正在进行的TTS播放
|
||||
console.log('开始新的播放,先停止现有播放')
|
||||
TTSService.stop()
|
||||
|
||||
// 等待一下,确保之前的播放已经完全停止
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
|
||||
setIsSpeaking(true)
|
||||
try {
|
||||
console.log('开始播放消息:', message.id)
|
||||
await TTSService.speakFromMessage(message)
|
||||
|
||||
// 监听播放结束
|
||||
let checkPlayingStatusInterval: number | null = null
|
||||
|
||||
const checkPlayingStatus = () => {
|
||||
if (!TTSService.isCurrentlyPlaying()) {
|
||||
console.log('TTS播放已结束,重置状态')
|
||||
setIsSpeaking(false)
|
||||
if (checkPlayingStatusInterval !== null) {
|
||||
clearInterval(checkPlayingStatusInterval)
|
||||
checkPlayingStatusInterval = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
checkPlayingStatusInterval = window.setInterval(checkPlayingStatus, 500) as unknown as number
|
||||
|
||||
// 添加一个安全机制,确保即使出错也会重置状态
|
||||
setTimeout(() => {
|
||||
if (isSpeaking) {
|
||||
console.log('TTS播放超时,强制重置状态')
|
||||
TTSService.stop()
|
||||
setIsSpeaking(false)
|
||||
if (checkPlayingStatusInterval !== null) {
|
||||
clearInterval(checkPlayingStatusInterval)
|
||||
checkPlayingStatusInterval = null
|
||||
}
|
||||
}
|
||||
}, 30000) // 30秒后检查
|
||||
} catch (error) {
|
||||
console.error('TTS error:', error)
|
||||
setIsSpeaking(false)
|
||||
}
|
||||
}, [isSpeaking, message])
|
||||
|
||||
const dropdownItems = useMemo(
|
||||
() => [
|
||||
{
|
||||
@ -363,6 +428,9 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isAssistantMessage && (
|
||||
<TTSButton message={message} className="message-action-button" />
|
||||
)}
|
||||
{!isUserMessage && (
|
||||
<Dropdown
|
||||
menu={{
|
||||
@ -390,6 +458,13 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
</Tooltip>
|
||||
</Dropdown>
|
||||
)}
|
||||
{!isUserMessage && ttsEnabled && (
|
||||
<Tooltip title={isSpeaking ? t('chat.tts.stop') : t('chat.tts.speak')} mouseEnterDelay={0.8}>
|
||||
<ActionButton className="message-action-button" onClick={handleTTS}>
|
||||
<SoundOutlined style={isSpeaking ? { color: 'var(--color-primary)' } : undefined} />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isAssistantMessage && isGrouped && (
|
||||
<Tooltip title={t('chat.message.useful')} mouseEnterDelay={0.8}>
|
||||
<ActionButton className="message-action-button" onClick={onUseful}>
|
||||
|
||||
@ -3,6 +3,7 @@ import { LOAD_MORE_COUNT } from '@renderer/config/constant'
|
||||
import db from '@renderer/databases'
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { useMessageOperations, useTopicLoading, useTopicMessages } from '@renderer/hooks/useMessageOperations'
|
||||
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useShortcut } from '@renderer/hooks/useShortcuts'
|
||||
import { autoRenameTopic, getTopic } from '@renderer/hooks/useTopic'
|
||||
@ -31,6 +32,7 @@ import MessageGroup from './MessageGroup'
|
||||
import NarrowLayout from './NarrowLayout'
|
||||
import NewTopicButton from './NewTopicButton'
|
||||
import Prompt from './Prompt'
|
||||
import TTSStopButton from './TTSStopButton'
|
||||
|
||||
interface MessagesProps {
|
||||
assistant: Assistant
|
||||
@ -276,6 +278,7 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic })
|
||||
</NarrowLayout>
|
||||
{messageNavigation === 'anchor' && <MessageAnchorLine messages={displayMessages} />}
|
||||
{messageNavigation === 'buttons' && <ChatNavigation containerId="messages" />}
|
||||
<TTSStopButton />
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
73
src/renderer/src/pages/home/Messages/TTSStopButton.tsx
Normal file
73
src/renderer/src/pages/home/Messages/TTSStopButton.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SoundOutlined } from '@ant-design/icons'
|
||||
import { Button, Tooltip } from 'antd'
|
||||
import styled from 'styled-components'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import TTSService from '@renderer/services/TTSService'
|
||||
|
||||
const TTSStopButton: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
|
||||
// 检查是否正在播放TTS
|
||||
useEffect(() => {
|
||||
const checkPlayingStatus = setInterval(() => {
|
||||
const isPlaying = TTSService.isCurrentlyPlaying()
|
||||
setIsVisible(isPlaying)
|
||||
}, 500)
|
||||
|
||||
return () => clearInterval(checkPlayingStatus)
|
||||
}, [])
|
||||
|
||||
// 停止TTS播放
|
||||
const handleStopTTS = useCallback(async () => {
|
||||
console.log('点击全局停止TTS按钮')
|
||||
|
||||
// 强制停止所有TTS播放
|
||||
TTSService.stop()
|
||||
|
||||
// 等待一下,确保播放已经完全停止
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
|
||||
// 再次检查并停止,确保强制停止
|
||||
if (TTSService.isCurrentlyPlaying()) {
|
||||
console.log('第一次停止未成功,再次尝试')
|
||||
TTSService.stop()
|
||||
}
|
||||
|
||||
// 立即隐藏按钮
|
||||
setIsVisible(false)
|
||||
|
||||
// 显示停止消息
|
||||
window.message.success({ content: t('chat.tts.stopped', { defaultValue: '已停止语音播放' }), key: 'tts-stopped' })
|
||||
}, [t])
|
||||
|
||||
if (!isVisible) return null
|
||||
|
||||
return (
|
||||
<StopButtonContainer>
|
||||
<Tooltip title={t('chat.tts.stop_global')}>
|
||||
<StyledButton
|
||||
type="primary"
|
||||
shape="circle"
|
||||
icon={<SoundOutlined />}
|
||||
onClick={handleStopTTS}
|
||||
size="large"
|
||||
/>
|
||||
</Tooltip>
|
||||
</StopButtonContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const StopButtonContainer = styled.div`
|
||||
position: fixed;
|
||||
bottom: 100px;
|
||||
right: 20px;
|
||||
z-index: 1000;
|
||||
`
|
||||
|
||||
const StyledButton = styled(Button)`
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
`
|
||||
|
||||
export default TTSStopButton
|
||||
705
src/renderer/src/pages/settings/TTSSettings/TTSSettings.tsx
Normal file
705
src/renderer/src/pages/settings/TTSSettings/TTSSettings.tsx
Normal file
@ -0,0 +1,705 @@
|
||||
import { PlusOutlined, ReloadOutlined, SoundOutlined } from '@ant-design/icons'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import store, { useAppDispatch } from '@renderer/store'
|
||||
import {
|
||||
addTtsCustomModel,
|
||||
addTtsCustomVoice,
|
||||
removeTtsCustomModel,
|
||||
removeTtsCustomVoice,
|
||||
resetTtsCustomValues,
|
||||
setTtsApiKey,
|
||||
setTtsApiUrl,
|
||||
setTtsEdgeVoice,
|
||||
setTtsEnabled,
|
||||
setTtsFilterOptions,
|
||||
setTtsModel,
|
||||
setTtsServiceType,
|
||||
setTtsVoice
|
||||
} from '@renderer/store/settings'
|
||||
import { Button, Form, Input, Select, Space, Switch, Tag, message } from 'antd'
|
||||
import { FC, useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSelector } from 'react-redux'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { SettingContainer, SettingDivider, SettingGroup, SettingHelpText, SettingRow, SettingRowTitle, SettingTitle } from '..'
|
||||
import TTSService from '@renderer/services/TTSService'
|
||||
|
||||
const CustomVoiceInput = styled.div`
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
`
|
||||
|
||||
const TagsContainer = styled.div`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 16px;
|
||||
`
|
||||
|
||||
const EmptyText = styled.div`
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
padding: 4px 0;
|
||||
`
|
||||
|
||||
const InputGroup = styled.div`
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
`
|
||||
|
||||
const FlexContainer = styled.div`
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
`
|
||||
|
||||
const FilterOptionItem = styled.div`
|
||||
margin-bottom: 16px;
|
||||
`
|
||||
|
||||
const LengthLabel = styled.span`
|
||||
margin-right: 8px;
|
||||
`
|
||||
|
||||
const LoadingText = styled.div`
|
||||
margin-top: 8px;
|
||||
color: #999;
|
||||
`
|
||||
|
||||
const VoiceSelectContainer = styled.div`
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
`
|
||||
|
||||
const TTSSettings: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { theme } = useTheme()
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
// 从Redux获取TTS设置
|
||||
const ttsEnabled = useSelector((state: any) => state.settings.ttsEnabled)
|
||||
const ttsServiceType = useSelector((state: any) => state.settings.ttsServiceType || 'openai')
|
||||
const ttsApiKey = useSelector((state: any) => state.settings.ttsApiKey)
|
||||
const ttsApiUrl = useSelector((state: any) => state.settings.ttsApiUrl)
|
||||
const ttsVoice = useSelector((state: any) => state.settings.ttsVoice)
|
||||
const ttsModel = useSelector((state: any) => state.settings.ttsModel)
|
||||
const ttsEdgeVoice = useSelector((state: any) => state.settings.ttsEdgeVoice || 'zh-CN-XiaoxiaoNeural')
|
||||
const ttsCustomVoices = useSelector((state: any) => state.settings.ttsCustomVoices || [])
|
||||
const ttsCustomModels = useSelector((state: any) => state.settings.ttsCustomModels || [])
|
||||
const ttsFilterOptions = useSelector((state: any) => state.settings.ttsFilterOptions || {
|
||||
filterThinkingProcess: true,
|
||||
filterMarkdown: true,
|
||||
filterCodeBlocks: true,
|
||||
filterHtmlTags: true,
|
||||
maxTextLength: 4000
|
||||
})
|
||||
|
||||
// 新增自定义音色和模型的状态
|
||||
const [newVoice, setNewVoice] = useState('')
|
||||
const [newModel, setNewModel] = useState('')
|
||||
|
||||
// 浏览器可用的语音列表
|
||||
const [availableVoices, setAvailableVoices] = useState<{ label: string; value: string }[]>([])
|
||||
|
||||
// 预定义的Edge TTS音色列表
|
||||
const predefinedVoices = [
|
||||
{ label: '小晓 (女声, 中文)', value: 'zh-CN-XiaoxiaoNeural' },
|
||||
{ label: '云扬 (男声, 中文)', value: 'zh-CN-YunyangNeural' },
|
||||
{ label: '晓晓 (女声, 中文)', value: 'zh-CN-XiaoxiaoNeural' },
|
||||
{ label: '晓涵 (女声, 中文)', value: 'zh-CN-XiaohanNeural' },
|
||||
{ label: '晓诗 (女声, 中文)', value: 'zh-CN-XiaoshuangNeural' },
|
||||
{ label: '晓瑞 (女声, 中文)', value: 'zh-CN-XiaoruiNeural' },
|
||||
{ label: '晓墨 (女声, 中文)', value: 'zh-CN-XiaomoNeural' },
|
||||
{ label: '晓然 (男声, 中文)', value: 'zh-CN-XiaoranNeural' },
|
||||
{ label: '晓坤 (男声, 中文)', value: 'zh-CN-XiaokunNeural' },
|
||||
{ label: 'Aria (Female, English)', value: 'en-US-AriaNeural' },
|
||||
{ label: 'Guy (Male, English)', value: 'en-US-GuyNeural' },
|
||||
{ label: 'Jenny (Female, English)', value: 'en-US-JennyNeural' },
|
||||
{ label: 'Ana (Female, Spanish)', value: 'es-ES-ElviraNeural' },
|
||||
{ label: 'Ichiro (Male, Japanese)', value: 'ja-JP-KeitaNeural' },
|
||||
{ label: 'Nanami (Female, Japanese)', value: 'ja-JP-NanamiNeural' },
|
||||
// 添加更多常用的语音
|
||||
{ label: 'Microsoft David (en-US)', value: 'Microsoft David Desktop - English (United States)' },
|
||||
{ label: 'Microsoft Zira (en-US)', value: 'Microsoft Zira Desktop - English (United States)' },
|
||||
{ label: 'Microsoft Mark (en-US)', value: 'Microsoft Mark Online (Natural) - English (United States)' },
|
||||
{ label: 'Microsoft Aria (en-US)', value: 'Microsoft Aria Online (Natural) - English (United States)' },
|
||||
{ label: 'Google US English', value: 'Google US English' },
|
||||
{ label: 'Google UK English Female', value: 'Google UK English Female' },
|
||||
{ label: 'Google UK English Male', value: 'Google UK English Male' },
|
||||
{ label: 'Google 日本語', value: 'Google 日本語' },
|
||||
{ label: 'Google 普通话(中国大陆)', value: 'Google 普通话(中国大陆)' },
|
||||
{ label: 'Google 粤語(香港)', value: 'Google 粤語(香港)' }
|
||||
]
|
||||
|
||||
// 获取浏览器可用的语音列表
|
||||
const getVoices = () => {
|
||||
if (typeof window !== 'undefined' && 'speechSynthesis' in window) {
|
||||
// 先触发一下语音合成引擎,确保它已经初始化
|
||||
window.speechSynthesis.cancel()
|
||||
|
||||
// 获取浏览器可用的语音列表
|
||||
const voices = window.speechSynthesis.getVoices()
|
||||
console.log('获取到的语音列表:', voices)
|
||||
console.log('语音列表长度:', voices.length)
|
||||
|
||||
// 转换浏览器语音列表为选项格式
|
||||
const browserVoices = voices.map(voice => ({
|
||||
label: `${voice.name} (${voice.lang})${voice.default ? ' - 默认' : ''}`,
|
||||
value: voice.name,
|
||||
lang: voice.lang,
|
||||
isNative: true // 标记为浏览器原生语音
|
||||
}))
|
||||
|
||||
// 添加语言信息到预定义语音
|
||||
const enhancedPredefinedVoices = predefinedVoices.map(voice => ({
|
||||
...voice,
|
||||
lang: voice.value.split('-').slice(0, 2).join('-'),
|
||||
isNative: false // 标记为非浏览器原生语音
|
||||
}))
|
||||
|
||||
// 合并所有语音列表
|
||||
let allVoices = [...browserVoices]
|
||||
|
||||
// 如果浏览器语音少于5个,添加预定义语音
|
||||
if (browserVoices.length < 5) {
|
||||
allVoices = [...browserVoices, ...enhancedPredefinedVoices]
|
||||
}
|
||||
|
||||
// 去除重复项,优先保留浏览器原生语音
|
||||
const uniqueVoices = allVoices.filter((voice, index, self) => {
|
||||
const firstIndex = self.findIndex(v => v.value === voice.value)
|
||||
// 如果是原生语音或者是第一次出现,则保留
|
||||
return voice.isNative || firstIndex === index
|
||||
})
|
||||
|
||||
// 按语言分组并排序
|
||||
const groupedVoices = uniqueVoices.sort((a, b) => {
|
||||
// 先按语言排序
|
||||
if (a.lang !== b.lang) {
|
||||
return a.lang.localeCompare(b.lang)
|
||||
}
|
||||
// 同语言下,原生语音优先
|
||||
if (a.isNative !== b.isNative) {
|
||||
return a.isNative ? -1 : 1
|
||||
}
|
||||
// 最后按名称排序
|
||||
return a.label.localeCompare(b.label)
|
||||
})
|
||||
|
||||
setAvailableVoices(groupedVoices)
|
||||
console.log('设置可用语音列表:', groupedVoices)
|
||||
} else {
|
||||
// 如果浏览器不支持Web Speech API,使用预定义的语音列表
|
||||
console.log('浏览器不支持Web Speech API,使用预定义的语音列表')
|
||||
setAvailableVoices(predefinedVoices)
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新语音列表
|
||||
const refreshVoices = () => {
|
||||
console.log('手动刷新语音列表')
|
||||
message.loading({ content: t('settings.tts.edge_voice.refreshing', { defaultValue: '正在刷新语音列表...' }), key: 'refresh-voices' })
|
||||
|
||||
// 先清空当前列表
|
||||
setAvailableVoices([])
|
||||
|
||||
// 强制重新加载语音列表
|
||||
if (typeof window !== 'undefined' && 'speechSynthesis' in window) {
|
||||
window.speechSynthesis.cancel()
|
||||
|
||||
// 尝试多次获取语音列表
|
||||
setTimeout(() => {
|
||||
getVoices()
|
||||
setTimeout(() => {
|
||||
getVoices()
|
||||
message.success({ content: t('settings.tts.edge_voice.refreshed', { defaultValue: '语音列表已刷新' }), key: 'refresh-voices' })
|
||||
}, 1000)
|
||||
}, 500)
|
||||
} else {
|
||||
// 如果浏览器不支持Web Speech API,使用预定义的语音列表
|
||||
setAvailableVoices(predefinedVoices)
|
||||
message.success({ content: t('settings.tts.edge_voice.refreshed', { defaultValue: '语音列表已刷新' }), key: 'refresh-voices' })
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// 初始化语音合成引擎
|
||||
if (typeof window !== 'undefined' && 'speechSynthesis' in window) {
|
||||
// 触发语音合成引擎初始化
|
||||
window.speechSynthesis.cancel()
|
||||
|
||||
// 设置voiceschanged事件处理程序
|
||||
const voicesChangedHandler = () => {
|
||||
console.log('检测到voiceschanged事件,重新获取语音列表')
|
||||
getVoices()
|
||||
}
|
||||
|
||||
// 添加事件监听器
|
||||
window.speechSynthesis.onvoiceschanged = voicesChangedHandler
|
||||
|
||||
// 立即获取可用的语音
|
||||
getVoices()
|
||||
|
||||
// 创建多个定时器,在不同时间点尝试获取语音列表
|
||||
// 这是因为不同浏览器加载语音列表的时间不同
|
||||
const timers = [
|
||||
setTimeout(() => getVoices(), 500),
|
||||
setTimeout(() => getVoices(), 1000),
|
||||
setTimeout(() => getVoices(), 2000)
|
||||
]
|
||||
|
||||
return () => {
|
||||
// 清理事件监听器和定时器
|
||||
window.speechSynthesis.onvoiceschanged = null
|
||||
timers.forEach(timer => clearTimeout(timer))
|
||||
}
|
||||
} else {
|
||||
// 如果浏览器不支持Web Speech API,使用预定义的语音列表
|
||||
setAvailableVoices(predefinedVoices)
|
||||
return () => {}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 测试TTS功能
|
||||
const testTTS = async () => {
|
||||
if (!ttsEnabled) {
|
||||
window.message.error({ content: t('settings.tts.error.not_enabled'), key: 'tts-test' })
|
||||
return
|
||||
}
|
||||
|
||||
// 获取最新的服务类型设置
|
||||
const latestSettings = store.getState().settings
|
||||
const currentServiceType = latestSettings.ttsServiceType || 'openai'
|
||||
console.log('测试TTS时使用的服务类型:', currentServiceType)
|
||||
console.log('测试时完整TTS设置:', {
|
||||
ttsEnabled: latestSettings.ttsEnabled,
|
||||
ttsServiceType: latestSettings.ttsServiceType,
|
||||
ttsApiKey: latestSettings.ttsApiKey ? '已设置' : '未设置',
|
||||
ttsVoice: latestSettings.ttsVoice,
|
||||
ttsModel: latestSettings.ttsModel,
|
||||
ttsEdgeVoice: latestSettings.ttsEdgeVoice
|
||||
})
|
||||
|
||||
// 根据服务类型检查必要的参数
|
||||
if (currentServiceType === 'openai') {
|
||||
if (!ttsApiKey) {
|
||||
window.message.error({ content: t('settings.tts.error.no_api_key'), key: 'tts-test' })
|
||||
return
|
||||
}
|
||||
|
||||
if (!ttsVoice) {
|
||||
window.message.error({ content: t('settings.tts.error.no_voice'), key: 'tts-test' })
|
||||
return
|
||||
}
|
||||
|
||||
if (!ttsModel) {
|
||||
window.message.error({ content: t('settings.tts.error.no_model'), key: 'tts-test' })
|
||||
return
|
||||
}
|
||||
} else if (currentServiceType === 'edge') {
|
||||
if (!ttsEdgeVoice) {
|
||||
window.message.error({ content: t('settings.tts.error.no_edge_voice'), key: 'tts-test' })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
await TTSService.speak('这是一段测试语音,用于测试TTS功能是否正常工作。')
|
||||
}
|
||||
|
||||
// 添加自定义音色
|
||||
const handleAddVoice = () => {
|
||||
if (!newVoice) {
|
||||
window.message.error({ content: '请输入音色', key: 'add-voice' })
|
||||
return
|
||||
}
|
||||
|
||||
// 确保添加的是字符串
|
||||
const voiceStr = typeof newVoice === 'string' ? newVoice : String(newVoice);
|
||||
dispatch(addTtsCustomVoice(voiceStr))
|
||||
setNewVoice('')
|
||||
}
|
||||
|
||||
// 添加自定义模型
|
||||
const handleAddModel = () => {
|
||||
if (!newModel) {
|
||||
window.message.error({ content: '请输入模型', key: 'add-model' })
|
||||
return
|
||||
}
|
||||
|
||||
// 确保添加的是字符串
|
||||
const modelStr = typeof newModel === 'string' ? newModel : String(newModel);
|
||||
dispatch(addTtsCustomModel(modelStr))
|
||||
setNewModel('')
|
||||
}
|
||||
|
||||
// 删除自定义音色
|
||||
const handleRemoveVoice = (voice: string) => {
|
||||
// 确保删除的是字符串
|
||||
const voiceStr = typeof voice === 'string' ? voice : String(voice);
|
||||
dispatch(removeTtsCustomVoice(voiceStr))
|
||||
}
|
||||
|
||||
// 删除自定义模型
|
||||
const handleRemoveModel = (model: string) => {
|
||||
// 确保删除的是字符串
|
||||
const modelStr = typeof model === 'string' ? model : String(model);
|
||||
dispatch(removeTtsCustomModel(modelStr))
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingContainer theme={theme}>
|
||||
<SettingTitle>
|
||||
<Space>
|
||||
<SoundOutlined />
|
||||
{t('settings.tts.title')}
|
||||
</Space>
|
||||
</SettingTitle>
|
||||
|
||||
<SettingDivider />
|
||||
|
||||
<SettingGroup>
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.tts.enable')}</SettingRowTitle>
|
||||
<Switch checked={ttsEnabled} onChange={(checked) => dispatch(setTtsEnabled(checked))} />
|
||||
</SettingRow>
|
||||
<SettingHelpText>{t('settings.tts.enable.help')}</SettingHelpText>
|
||||
</SettingGroup>
|
||||
|
||||
{/* 重置按钮 */}
|
||||
<SettingGroup>
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.tts.reset_title')}</SettingRowTitle>
|
||||
<Button
|
||||
danger
|
||||
onClick={() => {
|
||||
if (window.confirm(t('settings.tts.reset_confirm'))) {
|
||||
dispatch(resetTtsCustomValues());
|
||||
window.message.success({ content: t('settings.tts.reset_success'), key: 'reset-tts' });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('settings.tts.reset')}
|
||||
</Button>
|
||||
</SettingRow>
|
||||
<SettingHelpText>{t('settings.tts.reset_help')}</SettingHelpText>
|
||||
</SettingGroup>
|
||||
|
||||
<SettingDivider />
|
||||
|
||||
<SettingGroup>
|
||||
<SettingRowTitle>{t('settings.tts.api_settings')}</SettingRowTitle>
|
||||
<Form layout="vertical" style={{ width: '100%' }}>
|
||||
{/* TTS服务类型选择 */}
|
||||
<Form.Item label={t('settings.tts.service_type')} style={{ marginBottom: 16 }}>
|
||||
<FlexContainer>
|
||||
<Select
|
||||
value={ttsServiceType}
|
||||
onChange={(value: string) => {
|
||||
console.log('切换TTS服务类型为:', value)
|
||||
// 先将新的服务类型写入Redux状态
|
||||
dispatch(setTtsServiceType(value))
|
||||
|
||||
// 等待一下,确保状态已更新
|
||||
setTimeout(() => {
|
||||
// 验证状态是否正确更新
|
||||
const currentType = store.getState().settings.ttsServiceType
|
||||
console.log('更新后的TTS服务类型:', currentType)
|
||||
|
||||
// 如果状态没有正确更新,再次尝试
|
||||
if (currentType !== value) {
|
||||
console.log('状态未正确更新,再次尝试')
|
||||
dispatch(setTtsServiceType(value))
|
||||
}
|
||||
}, 100)
|
||||
}}
|
||||
options={[
|
||||
{ label: t('settings.tts.service_type.openai'), value: 'openai' },
|
||||
{ label: t('settings.tts.service_type.edge'), value: 'edge' }
|
||||
]}
|
||||
disabled={!ttsEnabled}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => {
|
||||
// 强制刷新当前服务类型设置
|
||||
const currentType = store.getState().settings.ttsServiceType
|
||||
console.log('强制刷新TTS服务类型:', currentType)
|
||||
dispatch(setTtsServiceType(currentType))
|
||||
window.message.success({ content: t('settings.tts.service_type.refreshed', { defaultValue: '已刷新TTS服务类型设置' }), key: 'tts-refresh' })
|
||||
}}
|
||||
disabled={!ttsEnabled}
|
||||
title={t('settings.tts.service_type.refresh', { defaultValue: '刷新TTS服务类型设置' })}
|
||||
/>
|
||||
</FlexContainer>
|
||||
</Form.Item>
|
||||
|
||||
{/* OpenAI TTS设置 */}
|
||||
{ttsServiceType === 'openai' && (
|
||||
<>
|
||||
<Form.Item label={t('settings.tts.api_key')} style={{ marginBottom: 16 }}>
|
||||
<Input.Password
|
||||
value={ttsApiKey}
|
||||
onChange={(e) => dispatch(setTtsApiKey(e.target.value))}
|
||||
placeholder={t('settings.tts.api_key.placeholder')}
|
||||
disabled={!ttsEnabled}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label={t('settings.tts.api_url')} style={{ marginBottom: 16 }}>
|
||||
<Input
|
||||
value={ttsApiUrl}
|
||||
onChange={(e) => dispatch(setTtsApiUrl(e.target.value))}
|
||||
placeholder={t('settings.tts.api_url.placeholder')}
|
||||
disabled={!ttsEnabled}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Edge TTS设置 */}
|
||||
{ttsServiceType === 'edge' && (
|
||||
<Form.Item label={t('settings.tts.edge_voice')} style={{ marginBottom: 16 }}>
|
||||
<VoiceSelectContainer>
|
||||
<Select
|
||||
value={ttsEdgeVoice}
|
||||
onChange={(value) => dispatch(setTtsEdgeVoice(value))}
|
||||
options={availableVoices.length > 0 ? availableVoices : [
|
||||
{ label: t('settings.tts.edge_voice.loading'), value: '' }
|
||||
]}
|
||||
disabled={!ttsEnabled}
|
||||
style={{ flex: 1 }}
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder={availableVoices.length === 0 ? t('settings.tts.edge_voice.loading') : t('settings.tts.voice.placeholder')}
|
||||
notFoundContent={availableVoices.length === 0 ? t('settings.tts.edge_voice.loading') : t('settings.tts.edge_voice.not_found')}
|
||||
/>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={refreshVoices}
|
||||
disabled={!ttsEnabled}
|
||||
title={t('settings.tts.edge_voice.refresh')}
|
||||
/>
|
||||
</VoiceSelectContainer>
|
||||
{availableVoices.length === 0 && (
|
||||
<LoadingText>
|
||||
{t('settings.tts.edge_voice.loading')}
|
||||
</LoadingText>
|
||||
)}
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{/* OpenAI TTS的音色和模型设置 */}
|
||||
{ttsServiceType === 'openai' && (
|
||||
<>
|
||||
{/* 音色选择 */}
|
||||
<Form.Item label={t('settings.tts.voice')} style={{ marginBottom: 8 }}>
|
||||
<Select
|
||||
value={ttsVoice}
|
||||
onChange={(value) => dispatch(setTtsVoice(value))}
|
||||
options={ttsCustomVoices.map((voice: any) => {
|
||||
// 确保voice是字符串
|
||||
const voiceStr = typeof voice === 'string' ? voice : String(voice);
|
||||
return { label: voiceStr, value: voiceStr };
|
||||
})}
|
||||
disabled={!ttsEnabled}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('settings.tts.voice.placeholder')}
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
allowClear
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{/* 自定义音色列表 */}
|
||||
<TagsContainer>
|
||||
{ttsCustomVoices && ttsCustomVoices.length > 0 ? (
|
||||
ttsCustomVoices.map((voice: any, index: number) => {
|
||||
// 确保voice是字符串
|
||||
const voiceStr = typeof voice === 'string' ? voice : String(voice);
|
||||
return (
|
||||
<Tag
|
||||
key={`${voiceStr}-${index}`}
|
||||
closable
|
||||
onClose={() => handleRemoveVoice(voiceStr)}
|
||||
style={{ padding: '4px 8px' }}
|
||||
>
|
||||
{voiceStr}
|
||||
</Tag>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<EmptyText>
|
||||
{t('settings.tts.voice_empty')}
|
||||
</EmptyText>
|
||||
)}
|
||||
</TagsContainer>
|
||||
|
||||
{/* 添加自定义音色 */}
|
||||
<CustomVoiceInput>
|
||||
<InputGroup>
|
||||
<Input
|
||||
placeholder={t('settings.tts.voice_input_placeholder')}
|
||||
value={newVoice}
|
||||
onChange={(e) => setNewVoice(e.target.value)}
|
||||
disabled={!ttsEnabled}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleAddVoice}
|
||||
disabled={!ttsEnabled || !newVoice}
|
||||
>
|
||||
{t('settings.tts.voice_add')}
|
||||
</Button>
|
||||
</InputGroup>
|
||||
</CustomVoiceInput>
|
||||
|
||||
{/* 模型选择 */}
|
||||
<Form.Item label={t('settings.tts.model')} style={{ marginBottom: 8, marginTop: 16 }}>
|
||||
<Select
|
||||
value={ttsModel}
|
||||
onChange={(value) => dispatch(setTtsModel(value))}
|
||||
options={ttsCustomModels.map((model: any) => {
|
||||
// 确保model是字符串
|
||||
const modelStr = typeof model === 'string' ? model : String(model);
|
||||
return { label: modelStr, value: modelStr };
|
||||
})}
|
||||
disabled={!ttsEnabled}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('settings.tts.model.placeholder')}
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
allowClear
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{/* 自定义模型列表 */}
|
||||
<TagsContainer>
|
||||
{ttsCustomModels && ttsCustomModels.length > 0 ? (
|
||||
ttsCustomModels.map((model: any, index: number) => {
|
||||
// 确保model是字符串
|
||||
const modelStr = typeof model === 'string' ? model : String(model);
|
||||
return (
|
||||
<Tag
|
||||
key={`${modelStr}-${index}`}
|
||||
closable
|
||||
onClose={() => handleRemoveModel(modelStr)}
|
||||
style={{ padding: '4px 8px' }}
|
||||
>
|
||||
{modelStr}
|
||||
</Tag>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<EmptyText>
|
||||
{t('settings.tts.model_empty')}
|
||||
</EmptyText>
|
||||
)}
|
||||
</TagsContainer>
|
||||
|
||||
{/* 添加自定义模型 */}
|
||||
<CustomVoiceInput>
|
||||
<InputGroup>
|
||||
<Input
|
||||
placeholder={t('settings.tts.model_input_placeholder')}
|
||||
value={newModel}
|
||||
onChange={(e) => setNewModel(e.target.value)}
|
||||
disabled={!ttsEnabled}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleAddModel}
|
||||
disabled={!ttsEnabled || !newModel}
|
||||
>
|
||||
{t('settings.tts.model_add')}
|
||||
</Button>
|
||||
</InputGroup>
|
||||
</CustomVoiceInput>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* TTS过滤选项 */}
|
||||
<Form.Item label={t('settings.tts.filter_options')} style={{ marginTop: 24, marginBottom: 8 }}>
|
||||
<FilterOptionItem>
|
||||
<Switch
|
||||
checked={ttsFilterOptions.filterThinkingProcess}
|
||||
onChange={(checked) => dispatch(setTtsFilterOptions({ filterThinkingProcess: checked }))}
|
||||
disabled={!ttsEnabled}
|
||||
/> {t('settings.tts.filter.thinking_process')}
|
||||
</FilterOptionItem>
|
||||
<FilterOptionItem>
|
||||
<Switch
|
||||
checked={ttsFilterOptions.filterMarkdown}
|
||||
onChange={(checked) => dispatch(setTtsFilterOptions({ filterMarkdown: checked }))}
|
||||
disabled={!ttsEnabled}
|
||||
/> {t('settings.tts.filter.markdown')}
|
||||
</FilterOptionItem>
|
||||
<FilterOptionItem>
|
||||
<Switch
|
||||
checked={ttsFilterOptions.filterCodeBlocks}
|
||||
onChange={(checked) => dispatch(setTtsFilterOptions({ filterCodeBlocks: checked }))}
|
||||
disabled={!ttsEnabled}
|
||||
/> {t('settings.tts.filter.code_blocks')}
|
||||
</FilterOptionItem>
|
||||
<FilterOptionItem>
|
||||
<Switch
|
||||
checked={ttsFilterOptions.filterHtmlTags}
|
||||
onChange={(checked) => dispatch(setTtsFilterOptions({ filterHtmlTags: checked }))}
|
||||
disabled={!ttsEnabled}
|
||||
/> {t('settings.tts.filter.html_tags')}
|
||||
</FilterOptionItem>
|
||||
<FilterOptionItem>
|
||||
<LengthLabel>{t('settings.tts.max_text_length')}:</LengthLabel>
|
||||
<Select
|
||||
value={ttsFilterOptions.maxTextLength}
|
||||
onChange={(value) => dispatch(setTtsFilterOptions({ maxTextLength: value }))}
|
||||
disabled={!ttsEnabled}
|
||||
style={{ width: 120 }}
|
||||
options={[
|
||||
{ label: '1000', value: 1000 },
|
||||
{ label: '2000', value: 2000 },
|
||||
{ label: '4000', value: 4000 },
|
||||
{ label: '8000', value: 8000 },
|
||||
{ label: '16000', value: 16000 },
|
||||
]}
|
||||
/>
|
||||
</FilterOptionItem>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item style={{ marginTop: 16 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={testTTS}
|
||||
disabled={
|
||||
!ttsEnabled ||
|
||||
(ttsServiceType === 'openai' && (!ttsApiKey || !ttsVoice || !ttsModel)) ||
|
||||
(ttsServiceType === 'edge' && !ttsEdgeVoice)
|
||||
}
|
||||
>
|
||||
{t('settings.tts.test')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</SettingGroup>
|
||||
|
||||
<SettingDivider />
|
||||
|
||||
<SettingHelpText>
|
||||
{t('settings.tts.help')}
|
||||
<br />
|
||||
<a href="https://platform.openai.com/docs/guides/text-to-speech" target="_blank" rel="noopener noreferrer">
|
||||
{t('settings.tts.learn_more')}
|
||||
</a>
|
||||
</SettingHelpText>
|
||||
</SettingContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export default TTSSettings
|
||||
1102
src/renderer/src/services/TTSService.ts
Normal file
1102
src/renderer/src/services/TTSService.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -110,6 +110,25 @@ export interface SettingsState {
|
||||
showOpenedMinappsInSidebar: boolean
|
||||
// 隐私设置
|
||||
enableDataCollection: boolean
|
||||
// TTS配置
|
||||
ttsEnabled: boolean
|
||||
ttsServiceType: string // TTS服务类型:openai或edge
|
||||
ttsApiKey: string
|
||||
ttsApiUrl: string
|
||||
ttsVoice: string
|
||||
ttsModel: string
|
||||
ttsCustomVoices: string[]
|
||||
ttsCustomModels: string[]
|
||||
// Edge TTS配置
|
||||
ttsEdgeVoice: string
|
||||
// TTS过滤选项
|
||||
ttsFilterOptions: {
|
||||
filterThinkingProcess: boolean // 过滤思考过程
|
||||
filterMarkdown: boolean // 过滤Markdown标记
|
||||
filterCodeBlocks: boolean // 过滤代码块
|
||||
filterHtmlTags: boolean // 过滤HTML标签
|
||||
maxTextLength: number // 最大文本长度
|
||||
}
|
||||
exportMenuOptions: {
|
||||
image: boolean
|
||||
markdown: boolean
|
||||
@ -208,6 +227,24 @@ const initialState: SettingsState = {
|
||||
maxKeepAliveMinapps: 3,
|
||||
showOpenedMinappsInSidebar: true,
|
||||
enableDataCollection: false,
|
||||
// TTS配置
|
||||
ttsEnabled: false,
|
||||
ttsServiceType: 'openai', // 默认使用OpenAI TTS
|
||||
ttsApiKey: '',
|
||||
ttsApiUrl: 'https://api.openai.com/v1/audio/speech',
|
||||
ttsVoice: '',
|
||||
ttsModel: '',
|
||||
ttsCustomVoices: [],
|
||||
ttsCustomModels: [],
|
||||
// Edge TTS配置
|
||||
ttsEdgeVoice: 'zh-CN-XiaoxiaoNeural', // 默认使用小小的声音
|
||||
ttsFilterOptions: {
|
||||
filterThinkingProcess: true, // 默认过滤思考过程
|
||||
filterMarkdown: true, // 默认过滤Markdown标记
|
||||
filterCodeBlocks: true, // 默认过滤代码块
|
||||
filterHtmlTags: true, // 默认过滤HTML标签
|
||||
maxTextLength: 4000 // 默认最大文本长度
|
||||
},
|
||||
exportMenuOptions: {
|
||||
image: true,
|
||||
markdown: true,
|
||||
@ -476,10 +513,120 @@ const settingsSlice = createSlice({
|
||||
},
|
||||
setExportMenuOptions: (state, action: PayloadAction<typeof initialState.exportMenuOptions>) => {
|
||||
state.exportMenuOptions = action.payload
|
||||
},
|
||||
// TTS相关的action
|
||||
setTtsEnabled: (state, action: PayloadAction<boolean>) => {
|
||||
state.ttsEnabled = action.payload
|
||||
},
|
||||
setTtsServiceType: (state, action: PayloadAction<string>) => {
|
||||
state.ttsServiceType = action.payload
|
||||
},
|
||||
setTtsApiKey: (state, action: PayloadAction<string>) => {
|
||||
state.ttsApiKey = action.payload
|
||||
},
|
||||
setTtsApiUrl: (state, action: PayloadAction<string>) => {
|
||||
state.ttsApiUrl = action.payload
|
||||
},
|
||||
setTtsEdgeVoice: (state, action: PayloadAction<string>) => {
|
||||
state.ttsEdgeVoice = action.payload
|
||||
},
|
||||
setTtsVoice: (state, action: PayloadAction<string>) => {
|
||||
state.ttsVoice = action.payload
|
||||
},
|
||||
setTtsModel: (state, action: PayloadAction<string>) => {
|
||||
state.ttsModel = action.payload
|
||||
},
|
||||
setTtsCustomVoices: (state, action: PayloadAction<string[]>) => {
|
||||
// 确保所有值都是字符串
|
||||
state.ttsCustomVoices = action.payload
|
||||
.filter(voice => voice !== null && voice !== undefined)
|
||||
.map(voice => typeof voice === 'string' ? voice : String(voice))
|
||||
},
|
||||
setTtsCustomModels: (state, action: PayloadAction<string[]>) => {
|
||||
// 确保所有值都是字符串
|
||||
state.ttsCustomModels = action.payload
|
||||
.filter(model => model !== null && model !== undefined)
|
||||
.map(model => typeof model === 'string' ? model : String(model))
|
||||
},
|
||||
resetTtsCustomValues: (state) => {
|
||||
// 重置所有自定义音色和模型
|
||||
state.ttsCustomVoices = [];
|
||||
state.ttsCustomModels = [];
|
||||
},
|
||||
addTtsCustomVoice: (state, action: PayloadAction<string>) => {
|
||||
// 确保添加的是字符串
|
||||
const voiceStr = typeof action.payload === 'string' ? action.payload : String(action.payload);
|
||||
|
||||
// 检查是否已存在相同的音色
|
||||
const exists = state.ttsCustomVoices.some(voice => {
|
||||
if (typeof voice === 'string') {
|
||||
return voice === voiceStr;
|
||||
}
|
||||
return String(voice) === voiceStr;
|
||||
});
|
||||
|
||||
if (!exists) {
|
||||
state.ttsCustomVoices.push(voiceStr);
|
||||
}
|
||||
},
|
||||
addTtsCustomModel: (state, action: PayloadAction<string>) => {
|
||||
// 确保添加的是字符串
|
||||
const modelStr = typeof action.payload === 'string' ? action.payload : String(action.payload);
|
||||
|
||||
// 检查是否已存在相同的模型
|
||||
const exists = state.ttsCustomModels.some(model => {
|
||||
if (typeof model === 'string') {
|
||||
return model === modelStr;
|
||||
}
|
||||
return String(model) === modelStr;
|
||||
});
|
||||
|
||||
if (!exists) {
|
||||
state.ttsCustomModels.push(modelStr);
|
||||
}
|
||||
},
|
||||
removeTtsCustomVoice: (state, action: PayloadAction<string>) => {
|
||||
// 确保删除的是字符串
|
||||
const voiceStr = typeof action.payload === 'string' ? action.payload : String(action.payload);
|
||||
|
||||
// 过滤掉要删除的音色
|
||||
state.ttsCustomVoices = state.ttsCustomVoices.filter(voice => {
|
||||
if (typeof voice === 'string') {
|
||||
return voice !== voiceStr;
|
||||
}
|
||||
return String(voice) !== voiceStr;
|
||||
});
|
||||
},
|
||||
removeTtsCustomModel: (state, action: PayloadAction<string>) => {
|
||||
// 确保删除的是字符串
|
||||
const modelStr = typeof action.payload === 'string' ? action.payload : String(action.payload);
|
||||
|
||||
// 过滤掉要删除的模型
|
||||
state.ttsCustomModels = state.ttsCustomModels.filter(model => {
|
||||
if (typeof model === 'string') {
|
||||
return model !== modelStr;
|
||||
}
|
||||
return String(model) !== modelStr;
|
||||
});
|
||||
},
|
||||
// TTS过滤选项的action
|
||||
setTtsFilterOptions: (state, action: PayloadAction<{
|
||||
filterThinkingProcess?: boolean
|
||||
filterMarkdown?: boolean
|
||||
filterCodeBlocks?: boolean
|
||||
filterHtmlTags?: boolean
|
||||
maxTextLength?: number
|
||||
}>) => {
|
||||
state.ttsFilterOptions = {
|
||||
...state.ttsFilterOptions,
|
||||
...action.payload
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const settingsActions = settingsSlice.actions
|
||||
|
||||
export const {
|
||||
setShowAssistants,
|
||||
toggleShowAssistants,
|
||||
@ -562,7 +709,23 @@ export const {
|
||||
setMaxKeepAliveMinapps,
|
||||
setShowOpenedMinappsInSidebar,
|
||||
setEnableDataCollection,
|
||||
setExportMenuOptions
|
||||
} = settingsSlice.actions
|
||||
setExportMenuOptions,
|
||||
// TTS相关的action
|
||||
setTtsEnabled,
|
||||
setTtsServiceType,
|
||||
setTtsApiKey,
|
||||
setTtsApiUrl,
|
||||
setTtsEdgeVoice,
|
||||
setTtsVoice,
|
||||
setTtsModel,
|
||||
setTtsCustomVoices,
|
||||
setTtsCustomModels,
|
||||
resetTtsCustomValues,
|
||||
addTtsCustomVoice,
|
||||
addTtsCustomModel,
|
||||
removeTtsCustomVoice,
|
||||
removeTtsCustomModel,
|
||||
setTtsFilterOptions
|
||||
} = settingsActions
|
||||
|
||||
export default settingsSlice.reducer
|
||||
|
||||
@ -417,3 +417,12 @@ export interface QuickPhrase {
|
||||
updatedAt: number
|
||||
order?: number
|
||||
}
|
||||
|
||||
export type TTSProvider = {
|
||||
id: string
|
||||
name: string
|
||||
apiKey?: string
|
||||
apiUrl?: string
|
||||
voice?: string
|
||||
model?: string
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user