添加了 TTS 相关服务并更新了设置

This commit is contained in:
1600822305 2025-04-12 13:46:34 +08:00
parent 42ed1a4819
commit 5d57eb18ea
19 changed files with 1185 additions and 86 deletions

View File

@ -8,7 +8,6 @@ import Logger from 'electron-log'
import { registerIpc } from './ipc'
import { configManager } from './services/ConfigManager'
import mcpService from './services/MCPService'
import { registerMsTTSIpcHandlers } from './services/MsTTSIpcHandler'
import { CHERRY_STUDIO_PROTOCOL, handleProtocolUrl, registerProtocolClient } from './services/ProtocolClient'
import { registerShortcuts } from './services/ShortcutService'
import { TrayService } from './services/TrayService'
@ -49,8 +48,8 @@ if (!app.requestSingleInstanceLock()) {
registerIpc(mainWindow, app)
// 注册MsTTS IPC处理程序
registerMsTTSIpcHandlers()
// 注意: MsTTS IPC处理程序已在ipc.ts中注册
// 不需要再次调用registerMsTTSIpcHandlers()
replaceDevtoolsFont(mainWindow)

View File

@ -210,6 +210,7 @@ const StyledButton = styled(Button)`
justify-content: center;
align-items: center;
padding: 0;
border: none; /* 移除边框 */
&.anticon,
&.iconfont {
transition: all 0.3s ease;

View File

@ -2,11 +2,13 @@ import {
AudioMutedOutlined,
AudioOutlined,
CloseOutlined,
DownOutlined,
DragOutlined,
PauseCircleOutlined,
PlayCircleOutlined,
SettingOutlined,
SoundOutlined
SoundOutlined,
UpOutlined
} from '@ant-design/icons'
import { Button, Space, Tooltip } from 'antd'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
@ -178,6 +180,7 @@ const DraggableVoiceCallWindow: React.FC<Props> = ({
const [isSettingsVisible, setIsSettingsVisible] = useState(false)
const [tempShortcutKey, setTempShortcutKey] = useState(shortcutKey)
const [isRecordingShortcut, setIsRecordingShortcut] = useState(false)
const [isCollapsed, setIsCollapsed] = useState(false)
// --- 快捷键相关状态结束 ---
const isInitializedRef = useRef(false)
@ -518,6 +521,12 @@ const DraggableVoiceCallWindow: React.FC<Props> = ({
<Header onMouseDown={handleDragStart}>
<DragOutlined className="drag-icon" /> {/* 应用样式类 */}
{t('voice_call.title')}
<Button
type="text"
icon={isCollapsed ? <DownOutlined /> : <UpOutlined />}
onClick={() => setIsCollapsed(!isCollapsed)}
className="settings-button"
/>
<Button
type="text"
icon={<SettingOutlined />}
@ -530,42 +539,46 @@ const DraggableVoiceCallWindow: React.FC<Props> = ({
</Header>
<Content>
{isSettingsVisible && (
<SettingsPanel>
{' '}
{/* 使用 styled-component */}
<SettingsTitle>{t('voice_call.shortcut_key_setting')}</SettingsTitle> {/* 使用 styled-component */}
<Space>
<ShortcutKeyButton onClick={() => setIsRecordingShortcut(true)}>
{!isCollapsed && (
<>
{isSettingsVisible && (
<SettingsPanel>
{' '}
{/* 使用 styled-component */}
{isRecordingShortcut ? t('voice_call.press_any_key') : getKeyDisplayName(tempShortcutKey)}
</ShortcutKeyButton>
<Button type="primary" onClick={saveShortcutKey}>
{t('voice_call.save')}
</Button>
<Button onClick={() => setIsSettingsVisible(false)}>{t('voice_call.cancel')}</Button>
</Space>
<SettingsTip>
{' '}
{/* 使用 styled-component */}
{t('voice_call.shortcut_key_tip')}
</SettingsTip>
</SettingsPanel>
)}
<VisualizerContainer>
<VoiceVisualizer isActive={isListening || isRecording} type="input" />
<VoiceVisualizer isActive={isSpeaking} type="output" />
</VisualizerContainer>
<SettingsTitle>{t('voice_call.shortcut_key_setting')}</SettingsTitle> {/* 使用 styled-component */}
<Space>
<ShortcutKeyButton onClick={() => setIsRecordingShortcut(true)}>
{' '}
{/* 使用 styled-component */}
{isRecordingShortcut ? t('voice_call.press_any_key') : getKeyDisplayName(tempShortcutKey)}
</ShortcutKeyButton>
<Button type="primary" onClick={saveShortcutKey}>
{t('voice_call.save')}
</Button>
<Button onClick={() => setIsSettingsVisible(false)}>{t('voice_call.cancel')}</Button>
</Space>
<SettingsTip>
{' '}
{/* 使用 styled-component */}
{t('voice_call.shortcut_key_tip')}
</SettingsTip>
</SettingsPanel>
)}
<VisualizerContainer>
<VoiceVisualizer isActive={isListening || isRecording} type="input" />
<VoiceVisualizer isActive={isSpeaking} type="output" />
</VisualizerContainer>
<TranscriptContainer>
{transcript && (
<TranscriptText>
<UserLabel>{t('voice_call.you')}:</UserLabel> {transcript}
</TranscriptText>
)}
{/* 可以在这里添加 AI 回复的显示 */}
</TranscriptContainer>
<TranscriptContainer>
{transcript && (
<TranscriptText>
<UserLabel>{t('voice_call.you')}:</UserLabel> {transcript}
</TranscriptText>
)}
{/* 可以在这里添加 AI 回复的显示 */}
</TranscriptContainer>
</>
)}
<ControlsContainer>
<Space>

View File

@ -1,18 +1,37 @@
import { SoundOutlined } from '@ant-design/icons'
import TTSService from '@renderer/services/TTSService'
import { Message } from '@renderer/types'
import { Button, Tooltip } from 'antd'
import { Tooltip } from 'antd'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface TTSButtonProps {
message: Message
className?: string
}
interface SegmentedPlaybackState {
isSegmentedPlayback: boolean
segments: {
text: string
isLoaded: boolean
isLoading: boolean
}[]
currentSegmentIndex: number
isPlaying: boolean
}
const TTSButton: React.FC<TTSButtonProps> = ({ message, className }) => {
const { t } = useTranslation()
const [isSpeaking, setIsSpeaking] = useState(false)
// 分段播放状态
const [, setSegmentedPlaybackState] = useState<SegmentedPlaybackState>({
isSegmentedPlayback: false,
segments: [],
currentSegmentIndex: 0,
isPlaying: false
})
// 添加TTS状态变化事件监听器
useEffect(() => {
@ -31,6 +50,22 @@ const TTSButton: React.FC<TTSButtonProps> = ({ message, className }) => {
}
}, [])
// 监听分段播放状态变化
useEffect(() => {
const handleSegmentedPlaybackUpdate = (event: CustomEvent) => {
console.log('检测到分段播放状态更新:', event.detail)
setSegmentedPlaybackState(event.detail)
}
// 添加事件监听器
window.addEventListener('tts-segmented-playback-update', handleSegmentedPlaybackUpdate as EventListener)
// 组件卸载时移除事件监听器
return () => {
window.removeEventListener('tts-segmented-playback-update', handleSegmentedPlaybackUpdate as EventListener)
}
}, [])
// 初始化时检查TTS状态
useEffect(() => {
// 检查当前是否正在播放
@ -38,7 +73,7 @@ const TTSButton: React.FC<TTSButtonProps> = ({ message, className }) => {
if (isCurrentlyPlaying !== isSpeaking) {
setIsSpeaking(isCurrentlyPlaying)
}
}, [])
}, [isSpeaking])
const handleTTS = useCallback(async () => {
if (isSpeaking) {
@ -57,16 +92,51 @@ const TTSButton: React.FC<TTSButtonProps> = ({ message, className }) => {
}
}, [isSpeaking, message])
// 处理分段播放按钮点击 - 暂未使用,保留供未来扩展
/* const handleSegmentedTTS = useCallback(async () => {
try {
console.log('点击分段TTS按钮开始分段播放消息')
// 使用修改后的speakFromMessage方法传入segmented=true参数
await TTSService.speakFromMessage(message, true)
} catch (error) {
console.error('Segmented TTS error:', error)
}
}, [message]) */
return (
<Tooltip title={isSpeaking ? t('chat.tts.stop') : t('chat.tts.play')}>
<Button
className={className}
icon={<SoundOutlined />}
onClick={handleTTS}
type={isSpeaking ? 'primary' : 'default'}
/>
<TTSActionButton className={className} onClick={handleTTS}>
<SoundOutlined style={{ color: isSpeaking ? 'var(--color-primary)' : 'var(--color-icon)' }} />
</TTSActionButton>
</Tooltip>
)
}
const TTSActionButton = styled.div`
cursor: pointer;
border-radius: 8px;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
width: 30px;
height: 30px;
transition: all 0.2s ease;
&:hover {
background-color: var(--color-background-mute);
.anticon {
color: var(--color-text-1);
}
}
.anticon,
.iconfont {
cursor: pointer;
font-size: 14px;
color: var(--color-icon);
}
&:hover {
color: var(--color-text-1);
}
`
export default TTSButton

View File

@ -0,0 +1,96 @@
import { TextSegmenter } from '@renderer/services/tts/TextSegmenter'
import TTSService from '@renderer/services/TTSService'
import React, { useEffect, useState } from 'react'
import styled from 'styled-components'
interface TTSHighlightedTextProps {
text: string
}
interface SegmentedPlaybackState {
isSegmentedPlayback: boolean
segments: {
text: string
isLoaded: boolean
isLoading: boolean
}[]
currentSegmentIndex: number
isPlaying: boolean
}
const TTSHighlightedText: React.FC<TTSHighlightedTextProps> = ({ text }) => {
const [segments, setSegments] = useState<string[]>([])
const [currentSegmentIndex, setCurrentSegmentIndex] = useState<number>(-1)
// 播放状态变量,用于跟踪当前是否正在播放
const [, setIsPlaying] = useState<boolean>(false)
// 初始化时分割文本
useEffect(() => {
const textSegments = TextSegmenter.splitIntoSentences(text)
setSegments(textSegments)
}, [text])
// 监听分段播放状态变化
useEffect(() => {
const handleSegmentedPlaybackUpdate = (event: CustomEvent) => {
const data = event.detail as SegmentedPlaybackState
if (data.isSegmentedPlayback) {
setCurrentSegmentIndex(data.currentSegmentIndex)
setIsPlaying(data.isPlaying)
} else {
setCurrentSegmentIndex(-1)
setIsPlaying(false)
}
}
// 添加事件监听器
window.addEventListener('tts-segmented-playback-update', handleSegmentedPlaybackUpdate as EventListener)
// 组件卸载时移除事件监听器
return () => {
window.removeEventListener('tts-segmented-playback-update', handleSegmentedPlaybackUpdate as EventListener)
}
}, [])
// 处理段落点击
const handleSegmentClick = (index: number) => {
TTSService.playFromSegment(index)
}
if (segments.length === 0) {
return <div>{text}</div>
}
return (
<TextContainer>
{segments.map((segment, index) => (
<TextSegment
key={index}
className={index === currentSegmentIndex ? 'active' : ''}
onClick={() => handleSegmentClick(index)}>
{segment}
</TextSegment>
))}
</TextContainer>
)
}
const TextContainer = styled.div`
display: inline;
`
const TextSegment = styled.span`
cursor: pointer;
transition: background-color 0.2s ease;
&:hover {
background-color: rgba(0, 0, 0, 0.05);
}
&.active {
background-color: var(--color-primary-bg);
border-radius: 2px;
}
`
export default TTSHighlightedText

View File

@ -0,0 +1,243 @@
import React, { useEffect, useState } from 'react'
import styled from 'styled-components'
interface TTSProgressBarProps {
messageId: string
}
interface TTSProgressState {
isPlaying: boolean
progress: number // 0-100
currentTime: number
duration: number
}
const TTSProgressBar: React.FC<TTSProgressBarProps> = ({ messageId }) => {
const [progressState, setProgressState] = useState<TTSProgressState>({
isPlaying: false,
progress: 0,
currentTime: 0,
duration: 0
})
// 监听TTS进度更新事件
useEffect(() => {
const handleProgressUpdate = (event: CustomEvent) => {
const { messageId: playingMessageId, isPlaying, progress, currentTime, duration } = event.detail
console.log('TTS进度更新事件:', {
playingMessageId,
currentMessageId: messageId,
isPlaying,
progress,
currentTime,
duration
})
// 只有当前消息正在播放时才更新进度
if (playingMessageId === messageId) {
// 如果收到的是重置信号duration为0则强制设置为非播放状态
if (duration === 0 && currentTime === 0 && progress === 0) {
setProgressState({
isPlaying: false,
progress: 0,
currentTime: 0,
duration: 0
})
} else {
setProgressState({ isPlaying, progress, currentTime, duration })
}
} else if (progressState.isPlaying) {
// 如果当前消息不是正在播放的消息,但状态显示正在播放,则重置状态
setProgressState({
isPlaying: false,
progress: 0,
currentTime: 0,
duration: 0
})
}
}
// 监听TTS状态变化事件
const handleStateChange = (event: CustomEvent) => {
const { isPlaying } = event.detail
// 如果停止播放,重置进度条状态
if (!isPlaying && progressState.isPlaying) {
console.log('收到TTS停止播放事件重置进度条')
setProgressState({
isPlaying: false,
progress: 0,
currentTime: 0,
duration: 0
})
}
}
// 添加事件监听器
window.addEventListener('tts-progress-update', handleProgressUpdate as EventListener)
window.addEventListener('tts-state-change', handleStateChange as EventListener)
console.log('添加TTS进度更新事件监听器消息ID:', messageId)
// 组件卸载时移除事件监听器
return () => {
window.removeEventListener('tts-progress-update', handleProgressUpdate as EventListener)
window.removeEventListener('tts-state-change', handleStateChange as EventListener)
console.log('移除TTS进度更新事件监听器消息ID:', messageId)
}
}, [messageId, progressState.isPlaying])
// 如果没有播放,不显示进度条
if (!progressState.isPlaying) {
return null
}
// 处理进度条点击
const handleTrackClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (!progressState.isPlaying) return
// 如果是拖动结束的点击事件,忽略
if (e.type === 'click' && e.detail === 0) return
const trackRect = e.currentTarget.getBoundingClientRect()
const clickPosition = e.clientX - trackRect.left
const trackWidth = trackRect.width
const seekPercentage = (clickPosition / trackWidth) * 100
const seekTime = (seekPercentage / 100) * progressState.duration
console.log(`进度条点击: ${seekPercentage.toFixed(2)}%, 时间: ${seekTime.toFixed(2)}`)
// 调用TTS服务的seek方法
import('@renderer/services/TTSService').then(({ default: TTSService }) => {
TTSService.seek(seekTime)
})
}
// 处理拖动
const handleDrag = (e: React.MouseEvent<HTMLDivElement>) => {
if (!progressState.isPlaying) return
e.preventDefault()
e.stopPropagation() // 阻止事件冒泡
// 记录开始拖动状态
let isDragging = true
const trackRect = e.currentTarget.getBoundingClientRect()
const trackWidth = trackRect.width
const handleMouseMove = (moveEvent: MouseEvent) => {
if (!isDragging) return
moveEvent.preventDefault()
const dragPosition = Math.max(0, Math.min(moveEvent.clientX - trackRect.left, trackWidth))
const seekPercentage = (dragPosition / trackWidth) * 100
const seekTime = (seekPercentage / 100) * progressState.duration
// 更新本地状态以实时反映拖动位置
setProgressState((prev) => ({
...prev,
progress: seekPercentage,
currentTime: seekTime
}))
}
const handleMouseUp = (upEvent: MouseEvent) => {
if (!isDragging) return
isDragging = false
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
const dragPosition = Math.max(0, Math.min(upEvent.clientX - trackRect.left, trackWidth))
const seekPercentage = (dragPosition / trackWidth) * 100
const seekTime = (seekPercentage / 100) * progressState.duration
console.log(`拖动结束: ${seekPercentage.toFixed(2)}%, 时间: ${seekTime.toFixed(2)}`)
// 调用TTS服务的seek方法
import('@renderer/services/TTSService').then(({ default: TTSService }) => {
TTSService.seek(seekTime)
})
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}
return (
<ProgressBarContainer>
<ProgressBarTrack onClick={handleTrackClick} onMouseDown={handleDrag}>
<ProgressBarFill style={{ width: `${progressState.progress}%` }} />
<ProgressBarHandle style={{ left: `${progressState.progress}%` }} />
</ProgressBarTrack>
<ProgressText>
{formatTime(progressState.currentTime)} / {formatTime(progressState.duration)}
</ProgressText>
</ProgressBarContainer>
)
}
// 格式化时间为 mm:ss 格式
const formatTime = (seconds: number): string => {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
const ProgressBarContainer = styled.div`
margin-top: 8px;
margin-bottom: 8px;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
`
const ProgressBarTrack = styled.div`
width: 100%;
height: 8px;
background-color: var(--color-background-mute);
border-radius: 4px;
overflow: visible;
position: relative;
cursor: pointer;
`
const ProgressBarFill = styled.div`
height: 100%;
background-color: var(--color-primary);
border-radius: 4px;
transition: width 0.1s linear;
pointer-events: none;
`
const ProgressBarHandle = styled.div`
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 12px;
height: 12px;
background-color: var(--color-primary);
border-radius: 50%;
cursor: pointer;
box-shadow: 0 0 4px rgba(0, 0, 0, 0.2);
z-index: 1;
opacity: 0;
transition:
opacity 0.2s ease,
transform 0.2s ease;
pointer-events: none;
${ProgressBarTrack}:hover & {
opacity: 1;
}
`
const ProgressText = styled.div`
margin-top: 4px;
font-size: 12px;
color: var(--color-text-2);
`
export default TTSProgressBar

View File

@ -0,0 +1,76 @@
import { Spin } from 'antd'
import React from 'react'
import styled from 'styled-components'
interface TTSSegmentedTextProps {
segments: {
text: string
isLoaded: boolean
isLoading: boolean
}[]
currentSegmentIndex: number
isPlaying: boolean
onSegmentClick: (index: number) => void
}
const TTSSegmentedText: React.FC<TTSSegmentedTextProps> = ({
segments,
currentSegmentIndex,
// isPlaying, // 未使用的参数
onSegmentClick
}) => {
if (!segments || segments.length === 0) {
return null
}
return (
<SegmentedTextContainer>
{segments.map((segment, index) => (
<Segment
key={index}
className={`${index === currentSegmentIndex ? 'active' : ''}`}
onClick={() => onSegmentClick(index)}>
<SegmentText>{segment.text}</SegmentText>
{segment.isLoading && <Spin size="small" className="segment-loading" />}
</Segment>
))}
</SegmentedTextContainer>
)
}
const SegmentedTextContainer = styled.div`
margin: 10px 0;
padding: 10px;
border: 1px solid var(--color-border);
border-radius: 4px;
max-height: 300px;
overflow-y: auto;
`
const Segment = styled.div`
padding: 5px;
margin: 2px 0;
cursor: pointer;
border-radius: 4px;
display: flex;
align-items: center;
&:hover {
background-color: var(--color-background-soft);
}
&.active {
background-color: var(--color-primary-bg);
border-left: 3px solid var(--color-primary);
}
.segment-loading {
margin-left: 5px;
}
`
const SegmentText = styled.span`
flex: 1;
`
export default TTSSegmentedText

View File

@ -30,10 +30,11 @@ const VoiceCallModal: React.FC<Props> = ({ visible, onClose }) => {
const [isRecording, setIsRecording] = useState(false)
const [isProcessing, setIsProcessing] = useState(false)
const handleClose = () => {
// 使用useCallback包裹handleClose函数避免useEffect依赖项变化
const handleClose = React.useCallback(() => {
VoiceCallService.endCall()
onClose()
}
}, [onClose])
useEffect(() => {
const startVoiceCall = async () => {

View File

@ -3,6 +3,7 @@ import { isLocalAi } from '@renderer/config/env'
import { useTheme } from '@renderer/context/ThemeProvider'
import db from '@renderer/databases'
import i18n from '@renderer/i18n'
import ASRServerService from '@renderer/services/ASRServerService'
import { useAppDispatch } from '@renderer/store'
import { setAvatar, setFilesPath, setResourcesPath, setUpdateState } from '@renderer/store/runtime'
import { delay, runAsyncFunction } from '@renderer/utils'
@ -19,7 +20,18 @@ import useUpdateHandler from './useUpdateHandler'
export function useAppInit() {
const dispatch = useAppDispatch()
const { proxyUrl, language, windowStyle, autoCheckUpdate, proxyMode, customCss, enableDataCollection } = useSettings()
const {
proxyUrl,
language,
windowStyle,
autoCheckUpdate,
proxyMode,
customCss,
enableDataCollection,
asrEnabled,
asrServiceType,
asrAutoStartServer
} = useSettings()
const { minappShow } = useRuntime()
const { setDefaultModel, setTopicNamingModel, setTranslateModel } = useDefaultModel()
const avatar = useLiveQuery(() => db.settings.get('image://avatar'))
@ -108,4 +120,18 @@ export function useAppInit() {
useEffect(() => {
enableDataCollection ? initAnalytics() : disableAnalytics()
}, [enableDataCollection])
// 自动启动ASR服务器
useEffect(() => {
if (asrEnabled && asrServiceType === 'local' && asrAutoStartServer) {
console.log('自动启动ASR服务器...')
ASRServerService.startServer().then((success) => {
if (success) {
console.log('ASR服务器自动启动成功')
} else {
console.error('ASR服务器自动启动失败')
}
})
}
}, [asrEnabled, asrServiceType, asrAutoStartServer])
}

View File

@ -1422,7 +1422,15 @@
"test": "Test Speech",
"help": "Text-to-speech functionality supports converting text to natural-sounding speech.",
"learn_more": "Learn more",
"tab_title": "[to be translated]:语音合成",
"tab_title": "Text-to-Speech",
"play": "Play speech",
"stop": "Stop playback",
"speak": "Play speech",
"stop_global": "Stop all speech playback",
"stopped": "Speech playback stopped",
"segmented": "Segmented Playback",
"segmented_play": "Segmented Playback",
"segmented_playback": "Segmented Playback",
"error": {
"not_enabled": "Text-to-speech feature is not enabled",
"no_api_key": "API key is not set",
@ -1436,14 +1444,14 @@
"general": "An error occurred during speech synthesis",
"unsupported_service_type": "Unsupported service type: {{serviceType}}"
},
"service_type.mstts": "[to be translated]:免费在线 TTS",
"edge_voice.available_count": "[to be translated]:可用语音: {{count}}个",
"edge_voice.refreshing": "[to be translated]:正在刷新语音列表...",
"edge_voice.refreshed": "[to be translated]:语音列表已刷新",
"mstts.voice": "[to be translated]:免费在线 TTS音色",
"mstts.output_format": "[to be translated]:输出格式",
"mstts.info": "[to be translated]:免费在线TTS服务不需要API密钥完全免费使用。",
"error.no_mstts_voice": "[to be translated]:未设置免费在线 TTS音色"
"service_type.mstts": "Free Online TTS",
"edge_voice.available_count": "Available voices: {{count}}",
"edge_voice.refreshing": "Refreshing voice list...",
"edge_voice.refreshed": "Voice list refreshed",
"mstts.voice": "Free Online TTS Voice",
"mstts.output_format": "Output Format",
"mstts.info": "Free Online TTS service doesn't require an API key, completely free to use.",
"error.no_mstts_voice": "Free Online TTS voice not set"
},
"asr": {
"title": "Speech Recognition",

View File

@ -1436,6 +1436,14 @@
"test": "测试语音",
"help": "语音合成功能支持将文本转换为自然语音。",
"learn_more": "了解更多",
"play": "播放语音",
"stop": "停止播放",
"speak": "播放语音",
"stop_global": "停止所有语音播放",
"stopped": "已停止语音播放",
"segmented": "分段",
"segmented_play": "分段播放",
"segmented_playback": "分段播放",
"error": {
"not_enabled": "语音合成功能未启用",
"no_api_key": "未设置API密钥",
@ -1490,6 +1498,8 @@
"success": "语音识别成功",
"completed": "语音识别完成",
"canceled": "已取消录音",
"auto_start_server": "启动应用自动开启服务器",
"auto_start_server.help": "启用后,应用启动时会自动开启语音识别服务器",
"error": {
"not_enabled": "语音识别功能未启用",
"no_api_key": "未设置API密钥",

View File

@ -1,3 +1,4 @@
import TTSProgressBar from '@renderer/components/TTSProgressBar'
import { FONT_FAMILY } from '@renderer/config/constant'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { useModel } from '@renderer/hooks/useModel'
@ -263,7 +264,7 @@ const MessageItem: FC<Props> = ({
{contextMenuPosition && (
<Dropdown
overlayStyle={{ left: contextMenuPosition.x, top: contextMenuPosition.y, zIndex: 1000 }}
menu={{ items: getContextMenuItems(t, selectedQuoteText, selectedText) }}
menu={{ items: getContextMenuItems(t, selectedQuoteText, selectedText, message) }}
open={true}
trigger={['contextMenu']}>
<div />
@ -276,6 +277,11 @@ const MessageItem: FC<Props> = ({
<MessageErrorBoundary>
<MessageContent message={message} model={model} />
</MessageErrorBoundary>
{isAssistantMessage && (
<ProgressBarWrapper>
<TTSProgressBar messageId={message.id} />
</ProgressBarWrapper>
)}
{showMenubar && (
<MessageFooter
style={{
@ -310,7 +316,12 @@ const getMessageBackground = (isBubbleStyle: boolean, isAssistantMessage: boolea
: undefined
}
const getContextMenuItems = (t: (key: string) => string, selectedQuoteText: string, selectedText: string) => [
const getContextMenuItems = (
t: (key: string) => string,
selectedQuoteText: string,
selectedText: string,
currentMessage?: Message
) => [
{
key: 'copy',
label: t('common.copy'),
@ -325,6 +336,29 @@ const getContextMenuItems = (t: (key: string) => string, selectedQuoteText: stri
onClick: () => {
EventEmitter.emit(EVENT_NAMES.QUOTE_TEXT, selectedQuoteText)
}
},
{
key: 'speak',
label: '朗读',
onClick: () => {
// 从选中的文本开始朗读后面的内容
if (selectedText && currentMessage?.content) {
// 找到选中文本在消息中的位置
const startIndex = currentMessage.content.indexOf(selectedText)
if (startIndex !== -1) {
// 获取选中文本及其后面的所有内容
const textToSpeak = currentMessage.content.substring(startIndex)
import('@renderer/services/TTSService').then(({ default: TTSService }) => {
TTSService.speak(textToSpeak)
})
} else {
// 如果找不到精确位置,则只朗读选中的文本
import('@renderer/services/TTSService').then(({ default: TTSService }) => {
TTSService.speak(selectedText)
})
}
}
}
}
]
@ -381,4 +415,9 @@ const NewContextMessage = styled.div`
cursor: pointer;
`
const ProgressBarWrapper = styled.div`
width: 100%;
padding: 0 10px;
`
export default memo(MessageItem)

View File

@ -1,4 +1,5 @@
import { SearchOutlined, SyncOutlined, TranslationOutlined } from '@ant-design/icons'
import TTSHighlightedText from '@renderer/components/TTSHighlightedText'
import { isOpenAIWebSearch } from '@renderer/config/models'
import { getModelUniqId } from '@renderer/services/ModelService'
import { Message, Model } from '@renderer/types'
@ -6,7 +7,7 @@ import { getBriefInfo } from '@renderer/utils'
import { withMessageThought } from '@renderer/utils/formats'
import { Divider, Flex } from 'antd'
import { clone } from 'lodash'
import React, { Fragment, useMemo } from 'react'
import React, { Fragment, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import BarLoader from 'react-spinners/BarLoader'
import BeatLoader from 'react-spinners/BeatLoader'
@ -29,6 +30,23 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
const { t } = useTranslation()
const message = withMessageThought(clone(_message))
const isWebCitation = model && (isOpenAIWebSearch(model) || model.provider === 'openrouter')
const [isSegmentedPlayback, setIsSegmentedPlayback] = useState(false)
// 监听分段播放状态变化
useEffect(() => {
const handleSegmentedPlaybackUpdate = (event: CustomEvent) => {
const { isSegmentedPlayback } = event.detail
setIsSegmentedPlayback(isSegmentedPlayback)
}
// 添加事件监听器
window.addEventListener('tts-segmented-playback-update', handleSegmentedPlaybackUpdate as EventListener)
// 组件卸载时移除事件监听器
return () => {
window.removeEventListener('tts-segmented-playback-update', handleSegmentedPlaybackUpdate as EventListener)
}
}, [])
// HTML实体编码辅助函数
const encodeHTML = (str: string) => {
@ -205,7 +223,11 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
</Flex>
<MessageThought message={message} />
<MessageTools message={message} />
<Markdown message={{ ...message, content: processedContent.replace(toolUseRegex, '') }} />
{isSegmentedPlayback ? (
<TTSHighlightedText text={processedContent.replace(toolUseRegex, '')} />
) : (
<Markdown message={{ ...message, content: processedContent.replace(toolUseRegex, '') }} />
)}
{message.metadata?.generateImage && <MessageImage message={message} />}
{message.translatedContent && (
<Fragment>

View File

@ -8,7 +8,6 @@ import {
MenuOutlined,
QuestionCircleOutlined,
SaveOutlined,
SoundOutlined,
SyncOutlined,
TranslationOutlined
} from '@ant-design/icons'
@ -16,14 +15,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 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 { translateText } from '@renderer/services/TranslateService'
import TTSService from '@renderer/services/TTSService'
import { RootState } from '@renderer/store'
import type { Message, Model } from '@renderer/types'
import type { Assistant, Topic } from '@renderer/types'
@ -152,7 +150,7 @@ const MessageMenubar: FC<Props> = (props) => {
// 解析编辑后的文本,提取图片 URL
const imageRegex = /!\[image-\d+\]\((.*?)\)/g
const imageUrls: string[] = []
let match
let match: RegExpExecArray | null
let content = editedText
while ((match = imageRegex.exec(editedText)) !== null) {
@ -405,18 +403,7 @@ const MessageMenubar: FC<Props> = (props) => {
</ActionButton>
</Tooltip>
)}
{isAssistantMessage && ttsEnabled && (
<Tooltip title={t('chat.tts.play')} mouseEnterDelay={0.8}>
<ActionButton
className="message-action-button"
onClick={() => {
console.log('点击MessageMenubar中的TTS按钮开始播放消息')
TTSService.speakFromMessage(message)
}}>
<SoundOutlined />
</ActionButton>
</Tooltip>
)}
{isAssistantMessage && ttsEnabled && <TTSButton message={message} className="message-action-button" />}
{!isUserMessage && (
<Dropdown
menu={{

View File

@ -2,7 +2,14 @@ import { GlobalOutlined, InfoCircleOutlined, PlayCircleOutlined, StopOutlined }
import ASRServerService from '@renderer/services/ASRServerService'
import ASRService from '@renderer/services/ASRService'
import { useAppDispatch } from '@renderer/store'
import { setAsrApiKey, setAsrApiUrl, setAsrEnabled, setAsrModel, setAsrServiceType } from '@renderer/store/settings'
import {
setAsrApiKey,
setAsrApiUrl,
setAsrAutoStartServer,
setAsrEnabled,
setAsrModel,
setAsrServiceType
} from '@renderer/store/settings'
import { Button, Form, Input, Select, Space, Switch } from 'antd'
import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -22,6 +29,7 @@ const ASRSettings: FC = () => {
const asrApiKey = useSelector((state: any) => state.settings.asrApiKey)
const asrApiUrl = useSelector((state: any) => state.settings.asrApiUrl)
const asrModel = useSelector((state: any) => state.settings.asrModel || 'whisper-1')
const asrAutoStartServer = useSelector((state: any) => state.settings.asrAutoStartServer)
// 检查服务器状态
useEffect(() => {
@ -178,6 +186,21 @@ const ASRSettings: FC = () => {
</Button>
<BrowserTip>{t('settings.asr.local.browser_tip')}</BrowserTip>
{/* 启动应用自动开启服务器 */}
<Form.Item style={{ marginTop: 16 }}>
<Space>
<Switch
checked={asrAutoStartServer}
onChange={(checked) => dispatch(setAsrAutoStartServer(checked))}
disabled={!asrEnabled}
/>
<span>{t('settings.asr.auto_start_server')}</span>
<Tooltip title={t('settings.asr.auto_start_server.help')}>
<InfoCircleOutlined style={{ color: 'var(--color-text-3)' }} />
</Tooltip>
</Space>
</Form.Item>
</Space>
</Form.Item>
</>

View File

@ -17,9 +17,10 @@ class TTSService {
/**
*
* @param text
* @param segmented 使
*/
speak = async (text: string): Promise<void> => {
await this.service.speak(text)
speak = async (text: string, segmented: boolean = false): Promise<void> => {
await this.service.speak(text, segmented)
}
/**
@ -32,9 +33,10 @@ class TTSService {
/**
*
* @param message
* @param segmented 使
*/
speakFromMessage = async (message: Message): Promise<void> => {
await this.service.speakFromMessage(message)
speakFromMessage = async (message: Message, segmented: boolean = false): Promise<void> => {
await this.service.speakFromMessage(message, segmented)
}
/**
@ -43,6 +45,22 @@ class TTSService {
isCurrentlyPlaying = (): boolean => {
return this.service.isCurrentlyPlaying()
}
/**
*
* @param segmentIndex
*/
playFromSegment = (segmentIndex: number): void => {
this.service.playFromSegment(segmentIndex)
}
/**
*
* @param time
*/
seek = (time: number): void => {
this.service.seek(time)
}
}
// 导出单例

View File

@ -3,6 +3,7 @@ import store from '@renderer/store'
import { setLastPlayedMessageId } from '@renderer/store/settings'
import { Message } from '@renderer/types'
import { TextSegmenter } from './TextSegmenter'
import { TTSServiceFactory } from './TTSServiceFactory'
import { TTSTextFilter } from './TTSTextFilter'
@ -10,11 +11,27 @@ import { TTSTextFilter } from './TTSTextFilter'
* TTS服务类
*
*/
// 音频段落接口
interface AudioSegment {
text: string // 段落文本
audioBlob?: Blob // 对应的音频Blob
audioUrl?: string // 音频URL
isLoaded: boolean // 是否已加载
isLoading: boolean // 是否正在加载
}
export class TTSService {
private static instance: TTSService
private audioElement: HTMLAudioElement | null = null
private isPlaying = false
private playingServiceType: string | null = null
private playingMessageId: string | null = null
private progressUpdateInterval: NodeJS.Timeout | null = null
// 分段播放相关属性
private audioSegments: AudioSegment[] = []
private currentSegmentIndex: number = 0
private isSegmentedPlayback: boolean = false
// 错误消息节流控制
private lastErrorTime = 0
@ -76,9 +93,10 @@ export class TTSService {
/**
*
* @param message
* @param segmented 使
* @returns
*/
public async speakFromMessage(message: Message): Promise<boolean> {
public async speakFromMessage(message: Message, segmented: boolean = false): Promise<boolean> {
// 获取最新的TTS过滤选项
const settings = store.getState().settings
const ttsFilterOptions = settings.ttsFilterOptions || {
@ -94,12 +112,15 @@ export class TTSService {
dispatch(setLastPlayedMessageId(message.id))
console.log('更新最后播放的消息ID:', message.id)
// 记录当前正在播放的消息ID
this.playingMessageId = message.id
// 应用过滤
const filteredText = TTSTextFilter.filterText(message.content, ttsFilterOptions)
console.log('TTS过滤前文本长度:', message.content.length, '过滤后:', filteredText.length)
// 播放过滤后的文本
return this.speak(filteredText)
return this.speak(filteredText, segmented)
}
/**
@ -116,9 +137,15 @@ export class TTSService {
const event = new CustomEvent('tts-state-change', { detail: { isPlaying } })
window.dispatchEvent(event)
// 如果停止播放,清除服务类型
// 如果开始播放,启动进度更新定时器
if (isPlaying && this.audioElement) {
this.startProgressUpdates()
}
// 如果停止播放,清除服务类型和定时器
if (!isPlaying) {
this.playingServiceType = null
this.stopProgressUpdates()
// 确保Web Speech API也停止
if ('speechSynthesis' in window) {
@ -131,9 +158,10 @@ export class TTSService {
/**
*
* @param text
* @param segmented 使
* @returns
*/
public async speak(text: string): Promise<boolean> {
public async speak(text: string, segmented: boolean = false): Promise<boolean> {
try {
// 检查TTS是否启用
const settings = store.getState().settings
@ -164,6 +192,15 @@ export class TTSService {
console.log('使用的TTS服务类型:', serviceType)
// 记录当前使用的服务类型
this.playingServiceType = serviceType
// 设置分段播放模式
this.isSegmentedPlayback = segmented
if (segmented) {
// 分段播放模式
return await this.speakSegmented(text, serviceType, latestSettings)
}
console.log('当前TTS设置详情:', {
ttsServiceType: serviceType,
ttsEdgeVoice: latestSettings.ttsEdgeVoice,
@ -264,8 +301,19 @@ export class TTSService {
console.log('停止Web Speech API播放')
}
// 停止进度更新
this.stopProgressUpdates()
// 更新状态并触发事件
this.updatePlayingState(false)
// 清除正在播放的消息ID
this.playingMessageId = null
// 发送一个最终的进度更新事件,确保进度条消失
this.emitProgressUpdateEvent(0, 0, 0)
// 如果是分段播放模式,不清理资源,以便用户可以从其他段落继续播放
}
/**
@ -276,6 +324,334 @@ export class TTSService {
return this.isPlaying
}
/**
*
* @param time
* @returns
*/
public seek(time: number): boolean {
if (!this.audioElement || !this.isPlaying) {
console.log('无法跳转,音频元素不存在或未在播放中')
return false
}
try {
// 确保时间在有效范围内
const duration = this.audioElement.duration || 0
const validTime = Math.max(0, Math.min(time, duration))
console.log(`跳转到时间点: ${validTime.toFixed(2)}秒 / ${duration.toFixed(2)}`)
this.audioElement.currentTime = validTime
return true
} catch (error) {
console.error('跳转失败:', error)
return false
}
}
/**
*
* @param index
* @returns
*/
public playFromSegment(index: number): boolean {
console.log(`请求从段落 ${index} 开始播放`)
// 如果当前不是分段播放模式,则先将当前消息切换为分段播放模式
if (!this.isSegmentedPlayback) {
console.log('当前不是分段播放模式,无法从指定段落开始播放')
return false
}
if (index < 0 || index >= this.audioSegments.length) {
console.log(`段落索引超出范围: ${index}, 总段落数: ${this.audioSegments.length}`)
return false
}
// 如果正在播放,先停止
if (this.isPlaying) {
console.log('停止当前播放')
this.stop()
}
console.log(`开始播放段落 ${index}: ${this.audioSegments[index].text.substring(0, 20)}...`)
// 开始播放指定段落
return this.playSegment(index)
}
/**
*
* @param text
* @param serviceType TTS服务类型
* @param settings
* @returns
*/
private async speakSegmented(text: string, serviceType: string, settings: any): Promise<boolean> {
try {
console.log('开始分段播放模式')
// 分割文本为句子
const sentences = TextSegmenter.splitIntoSentences(text)
console.log(`文本分割为 ${sentences.length} 个段落`)
if (sentences.length === 0) {
console.log('没有有效段落,取消播放')
return false
}
// 创建音频段落数组
this.audioSegments = sentences.map((sentence) => ({
text: sentence,
isLoaded: false,
isLoading: false
}))
// 设置分段播放模式
this.isSegmentedPlayback = true
// 重置当前段落索引
this.currentSegmentIndex = 0
// 触发分段播放事件
this.emitSegmentedPlaybackEvent()
// 预加载所有段落,确保完整播放
for (let i = 0; i < sentences.length; i++) {
// 使用setTimeout错开加载时间避免同时发起过多请求
setTimeout(() => {
if (this.isSegmentedPlayback) {
// 确保仍然在分段播放模式
this.loadSegmentAudio(i, serviceType, settings)
}
}, i * 100) // 每100毫秒加载一个段落
}
// 不自动开始播放,等待用户点击
console.log('分段播放模式已准备就绪,等待用户点击')
return true
} catch (error: any) {
console.error('TTS分段播放失败:', error)
this.showErrorMessage(error?.message || i18n.t('settings.tts.error.synthesis_failed'))
return false
}
}
/**
*
* @param index
* @param serviceType TTS服务类型
* @param settings
*/
private async loadSegmentAudio(index: number, serviceType: string, settings: any): Promise<void> {
if (index < 0 || index >= this.audioSegments.length) {
return
}
const segment = this.audioSegments[index]
// 如果已加载或正在加载,则跳过
if (segment.isLoaded || segment.isLoading) {
return
}
// 标记为正在加载
segment.isLoading = true
this.emitSegmentedPlaybackEvent()
try {
// 创建TTS服务
const ttsService = TTSServiceFactory.createService(serviceType, settings)
// 合成音频
const audioBlob = await ttsService.synthesize(segment.text)
// 创建音频URL
const audioUrl = URL.createObjectURL(audioBlob)
// 更新段落信息
segment.audioBlob = audioBlob
segment.audioUrl = audioUrl
segment.isLoaded = true
segment.isLoading = false
// 触发事件
this.emitSegmentedPlaybackEvent()
// 如果是当前播放的段落,且尚未开始播放,则开始播放
if (index === this.currentSegmentIndex && this.isSegmentedPlayback && !this.isPlaying) {
this.playSegment(index)
}
} catch (error) {
console.error(`加载段落音频失败 (索引: ${index}):`, error)
segment.isLoading = false
this.emitSegmentedPlaybackEvent()
}
}
/**
*
* @param index
* @returns
*/
private playSegment(index: number): boolean {
if (index < 0 || index >= this.audioSegments.length) {
return false
}
const segment = this.audioSegments[index]
// 如果段落尚未加载完成,则等待加载
if (!segment.isLoaded) {
// 如果尚未开始加载,则开始加载
if (!segment.isLoading) {
const settings = store.getState().settings
const serviceType = settings.ttsServiceType || 'openai'
this.loadSegmentAudio(index, serviceType, settings)
}
return true // 返回true表示已开始处理但尚未实际播放
}
// 更新当前段落索引
this.currentSegmentIndex = index
// 触发事件
this.emitSegmentedPlaybackEvent()
// 播放音频
if (this.audioElement && segment.audioUrl) {
this.audioElement.src = segment.audioUrl
this.audioElement.play().catch((error) => {
console.error('播放段落音频失败:', error)
})
// 更新播放状态
this.updatePlayingState(true)
// 设置音频结束事件
this.audioElement.onended = () => {
// 播放下一个段落
if (index < this.audioSegments.length - 1) {
this.playSegment(index + 1)
} else {
// 所有段落播放完毕
this.updatePlayingState(false)
// 清理资源
this.cleanupSegmentedPlayback()
}
}
return true
}
return false
}
/**
*
*/
private cleanupSegmentedPlayback(): void {
// 释放所有音频URL
for (const segment of this.audioSegments) {
if (segment.audioUrl) {
URL.revokeObjectURL(segment.audioUrl)
}
}
// 重置状态
this.audioSegments = []
this.currentSegmentIndex = 0
this.isSegmentedPlayback = false
// 触发事件
this.emitSegmentedPlaybackEvent()
console.log('分段播放已完成,资源已清理')
}
/**
*
*/
private startProgressUpdates(): void {
// 先停止现有的定时器
this.stopProgressUpdates()
// 确保音频元素存在
if (!this.audioElement) return
// 创建新的定时器每100毫秒更新一次进度
this.progressUpdateInterval = setInterval(() => {
if (this.audioElement && this.isPlaying) {
const currentTime = this.audioElement.currentTime
const duration = this.audioElement.duration || 0
// 计算进度百分比
const progress = duration > 0 ? (currentTime / duration) * 100 : 0
// 触发进度更新事件
this.emitProgressUpdateEvent(currentTime, duration, progress)
}
}, 100)
}
/**
*
*/
private stopProgressUpdates(): void {
if (this.progressUpdateInterval) {
clearInterval(this.progressUpdateInterval)
this.progressUpdateInterval = null
}
}
/**
*
* @param currentTime
* @param duration
* @param progress 0-100
*/
private emitProgressUpdateEvent(currentTime: number, duration: number, progress: number): void {
// 创建事件数据
const eventData = {
messageId: this.playingMessageId,
isPlaying: this.isPlaying,
currentTime,
duration,
progress
}
console.log('发送TTS进度更新事件:', {
messageId: this.playingMessageId,
progress: Math.round(progress),
currentTime: Math.round(currentTime),
duration: Math.round(duration)
})
// 触发事件
window.dispatchEvent(new CustomEvent('tts-progress-update', { detail: eventData }))
}
/**
*
*/
private emitSegmentedPlaybackEvent(): void {
// 创建事件数据
const eventData = {
isSegmentedPlayback: this.isSegmentedPlayback,
segments: this.audioSegments.map((segment) => ({
text: segment.text,
isLoaded: segment.isLoaded,
isLoading: segment.isLoading
})),
currentSegmentIndex: this.currentSegmentIndex,
isPlaying: this.isPlaying
}
// 触发事件
window.dispatchEvent(new CustomEvent('tts-segmented-playback-update', { detail: eventData }))
}
/**
*
* @param message

View File

@ -0,0 +1,85 @@
/**
*
*
*/
export class TextSegmenter {
/**
*
* @param text
* @returns
*/
public static splitIntoSentences(text: string): string[] {
if (!text || text.trim() === '') {
return []
}
// 以句子级别的标点符号为主要分隔点,保证流畅性
// 句号、问号、感叹号、分号作为主要分隔点
const punctuationRegex = /([.;:?!。;:?!]+)/g
// 分割文本
const parts = text.split(punctuationRegex)
const segments: string[] = []
// 将标点符号与前面的文本组合
for (let i = 0; i < parts.length - 1; i += 2) {
const content = parts[i]
const punctuation = parts[i + 1] || ''
if (content.trim() || punctuation.trim()) {
segments.push((content.trim() + punctuation.trim()).trim())
}
}
// 处理最后一个部分(如果有)
if (parts.length % 2 !== 0 && parts[parts.length - 1].trim()) {
segments.push(parts[parts.length - 1].trim())
}
// 进一步处理空格和换行符
const result = segments
.filter((segment) => segment.trim().length > 0) // 过滤空的片段
.flatMap((segment) => {
// 如果片段过长,按换行符进一步分割
if (segment.length > 100) {
const subParts = segment.split(/([\n\r]+)/)
const subSegments: string[] = []
for (let i = 0; i < subParts.length; i++) {
const part = subParts[i].trim()
if (part) {
subSegments.push(part)
}
}
return subSegments.length > 0 ? subSegments : [segment]
}
return [segment]
})
// 合并过短的片段,保证流畅性
const mergedResult: string[] = []
let currentSegment = ''
for (const segment of result) {
// 如果当前片段加上新片段仍然不超过100个字符则合并
if (currentSegment && currentSegment.length + segment.length < 100) {
currentSegment += ' ' + segment
} else {
// 如果当前片段非空,则添加到结果中
if (currentSegment) {
mergedResult.push(currentSegment)
}
currentSegment = segment
}
}
// 添加最后一个片段
if (currentSegment) {
mergedResult.push(currentSegment)
}
// 如果没有成功分割,则返回原文本作为一个句子
return mergedResult.length > 0 ? mergedResult : [text]
}
}

View File

@ -145,6 +145,7 @@ export interface SettingsState {
asrApiKey: string
asrApiUrl: string
asrModel: string
asrAutoStartServer: boolean // 启动应用时自动启动ASR服务器
// 语音通话配置
voiceCallEnabled: boolean
voiceCallModel: Model | null
@ -286,6 +287,7 @@ export const initialState: SettingsState = {
asrApiKey: '',
asrApiUrl: 'https://api.openai.com/v1/audio/transcriptions',
asrModel: 'whisper-1',
asrAutoStartServer: false, // 默认不自动启动ASR服务器
// 语音通话配置
voiceCallEnabled: true,
voiceCallModel: null,
@ -714,6 +716,9 @@ const settingsSlice = createSlice({
setAsrModel: (state, action: PayloadAction<string>) => {
state.asrModel = action.payload
},
setAsrAutoStartServer: (state, action: PayloadAction<boolean>) => {
state.asrAutoStartServer = action.payload
},
setVoiceCallEnabled: (state, action: PayloadAction<boolean>) => {
state.voiceCallEnabled = action.payload
},
@ -851,6 +856,7 @@ export const {
setAsrApiKey,
setAsrApiUrl,
setAsrModel,
setAsrAutoStartServer,
setVoiceCallEnabled,
setVoiceCallModel,
setIsVoiceCallActive,