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

This commit is contained in:
1600822305 2025-04-11 21:49:47 +08:00
parent db3293bbb4
commit b2a0a029d2
3 changed files with 544 additions and 362 deletions

View File

@ -5,10 +5,11 @@ import {
DragOutlined,
PauseCircleOutlined,
PlayCircleOutlined,
SettingOutlined,
SoundOutlined
} from '@ant-design/icons'
import { Button, Space, Tooltip } from 'antd'
import React, { useEffect, useRef, useState } from 'react'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useDispatch } from 'react-redux'
import styled from 'styled-components'
@ -24,349 +25,7 @@ interface Props {
onPositionChange?: (position: { x: number; y: number }) => void
}
const DraggableVoiceCallWindow: React.FC<Props> = ({
visible,
onClose,
position = { x: 20, y: 20 },
onPositionChange
}) => {
const { t } = useTranslation()
const dispatch = useDispatch()
const [isDragging, setIsDragging] = useState(false)
const [currentPosition, setCurrentPosition] = useState(position)
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 })
const containerRef = useRef<HTMLDivElement>(null)
// 语音通话状态
const [transcript, setTranscript] = useState('')
const [isListening, setIsListening] = useState(false)
const [isSpeaking, setIsSpeaking] = useState(false)
const [isRecording, setIsRecording] = useState(false)
const [isProcessing, setIsProcessing] = useState(false)
const [isPaused, setIsPaused] = useState(false)
const [isMuted, setIsMuted] = useState(false)
// 使用useRef跟踪窗口是否已经初始化避免重复初始化
const isInitializedRef = useRef(false)
// 使用useRef跟踪是否已经设置了状态避免重复设置
const stateSetRef = useRef(false)
// 单独处理状态设置只在visible变化时执行一次
useEffect(() => {
if (visible) {
// 只有在状态没有设置过的情况下,才设置其他状态
if (!stateSetRef.current) {
// 设置状态标记为已完成
stateSetRef.current = true
// 更新语音通话窗口状态
dispatch(setIsVoiceCallActive(true))
// 重置最后播放的消息ID
dispatch(setLastPlayedMessageId(null))
}
} else if (!visible) {
// 当窗口关闭时重置状态标记
stateSetRef.current = false
isInitializedRef.current = false
// 更新语音通话窗口状态
dispatch(setIsVoiceCallActive(false))
}
}, [visible, dispatch])
// 单独的 useEffect 来设置 skipNextAutoTTS确保每次窗口可见时都设置
useEffect(() => {
if (visible) {
// 每次窗口可见时都设置跳过下一次自动TTS
console.log('设置 skipNextAutoTTS 为 true确保打开窗口时不会自动播放最后一条消息')
// 使用 setTimeout 确保在所有消息组件渲染后设置
setTimeout(() => {
dispatch(setSkipNextAutoTTS(true))
}, 100)
}
}, [visible, dispatch])
// 处理语音通话初始化和清理
useEffect(() => {
// 添加TTS状态变化事件监听器
const handleTTSStateChange = (event: CustomEvent) => {
const { isPlaying } = event.detail
console.log('TTS状态变化事件:', isPlaying)
setIsSpeaking(isPlaying)
}
const startVoiceCall = async () => {
try {
// 显示加载中提示
window.message.loading({ content: t('voice_call.initializing'), key: 'voice-call-init' })
// 预先初始化语音识别服务
try {
await VoiceCallService.initialize()
} catch (initError) {
console.warn('语音识别服务初始化警告:', initError)
// 不抛出异常,允许程序继续运行
}
// 启动语音通话
await VoiceCallService.startCall({
onTranscript: (text) => setTranscript(text),
onResponse: () => {
// 这里不设置response因为响应会显示在聊天界面中
},
onListeningStateChange: setIsListening,
onSpeakingStateChange: setIsSpeaking
})
// 关闭加载中提示
window.message.success({ content: t('voice_call.ready'), key: 'voice-call-init' })
} catch (error) {
console.error('Voice call error:', error)
window.message.error({ content: t('voice_call.error'), key: 'voice-call-init' })
onClose()
}
}
if (visible && !isInitializedRef.current) {
// 设置初始化标记为已完成
isInitializedRef.current = true
// 启动语音通话
startVoiceCall()
// 添加事件监听器
window.addEventListener('tts-state-change', handleTTSStateChange as EventListener)
}
return () => {
if (!visible) {
VoiceCallService.endCall()
}
// 移除事件监听器
window.removeEventListener('tts-state-change', handleTTSStateChange as EventListener)
}
}, [visible, t, onClose])
// 拖拽相关处理
const handleDragStart = (e: React.MouseEvent) => {
if (containerRef.current) {
setIsDragging(true)
const rect = containerRef.current.getBoundingClientRect()
setDragOffset({
x: e.clientX - rect.left,
y: e.clientY - rect.top
})
}
}
const handleDrag = (e: MouseEvent) => {
if (isDragging) {
const newPosition = {
x: e.clientX - dragOffset.x,
y: e.clientY - dragOffset.y
}
setCurrentPosition(newPosition)
onPositionChange?.(newPosition)
}
}
const handleDragEnd = () => {
setIsDragging(false)
}
useEffect(() => {
if (isDragging) {
document.addEventListener('mousemove', handleDrag)
document.addEventListener('mouseup', handleDragEnd)
}
return () => {
document.removeEventListener('mousemove', handleDrag)
document.removeEventListener('mouseup', handleDragEnd)
}
}, [isDragging, handleDrag])
// 语音通话相关处理
const toggleMute = () => {
setIsMuted(!isMuted)
VoiceCallService.setMuted(!isMuted)
}
const togglePause = () => {
const newPauseState = !isPaused
setIsPaused(newPauseState)
VoiceCallService.setPaused(newPauseState)
}
// 长按说话相关处理
const handleRecordStart = async (e: React.MouseEvent | React.TouchEvent) => {
e.preventDefault() // 防止触摸事件的默认行为
if (isProcessing || isPaused) return
// 先清除之前的语音识别结果
setTranscript('')
// 无论是否正在播放都强制停止TTS
VoiceCallService.stopTTS()
setIsSpeaking(false)
// 更新UI状态
setIsRecording(true)
setIsProcessing(true) // 设置处理状态,防止重复点击
// 开始录音
try {
await VoiceCallService.startRecording()
console.log('开始录音')
setIsProcessing(false) // 录音开始后取消处理状态
} catch (error) {
console.error('开始录音出错:', error)
window.message.error({ content: '启动语音识别失败,请确保语音识别服务已启动', key: 'voice-call-error' })
setIsRecording(false)
setIsProcessing(false)
}
}
const handleRecordEnd = async (e: React.MouseEvent | React.TouchEvent) => {
e.preventDefault() // 防止触摸事件的默认行为
if (!isRecording) return
// 立即更新UI状态
setIsRecording(false)
setIsProcessing(true)
// 无论是否正在播放都强制停止TTS
VoiceCallService.stopTTS()
setIsSpeaking(false)
// 确保录音完全停止
try {
// 传递 true 参数,表示将结果发送到聊天界面
const success = await VoiceCallService.stopRecordingAndSendToChat()
console.log('录音已停止,结果已发送到聊天界面', success ? '成功' : '失败')
if (success) {
// 显示成功消息
window.message.success({ content: '语音识别已完成,正在发送消息...', key: 'voice-call-send' })
} else {
// 显示失败消息
window.message.error({ content: '发送语音识别结果失败', key: 'voice-call-error' })
}
} catch (error) {
console.error('停止录音出错:', error)
window.message.error({ content: '停止录音出错', key: 'voice-call-error' })
} finally {
// 无论成功与否,都确保在一定时间后重置处理状态
setTimeout(() => {
setIsProcessing(false)
}, 1000) // 增加延迟时间,确保有足够时间处理结果
}
}
// 处理鼠标/触摸离开按钮的情况
const handleRecordCancel = async (e: React.MouseEvent | React.TouchEvent) => {
e.preventDefault()
if (isRecording) {
// 立即更新UI状态
setIsRecording(false)
setIsProcessing(true)
// 无论是否正在播放都强制停止TTS
VoiceCallService.stopTTS()
setIsSpeaking(false)
// 取消录音不发送给AI
try {
await VoiceCallService.cancelRecording()
console.log('录音已取消')
// 清除输入文本
setTranscript('')
} catch (error) {
console.error('取消录音出错:', error)
} finally {
// 无论成功与否,都确保在一定时间后重置处理状态
setTimeout(() => {
setIsProcessing(false)
}, 1000)
}
}
}
if (!visible) return null
return (
<Container
ref={containerRef}
style={{
left: `${currentPosition.x}px`,
top: `${currentPosition.y}px`,
position: 'fixed',
zIndex: 1000
}}>
<Header onMouseDown={handleDragStart}>
<DragOutlined style={{ cursor: 'move', marginRight: 8 }} />
{t('voice_call.title')}
<CloseButton onClick={onClose}>
<CloseOutlined />
</CloseButton>
</Header>
<Content>
<VisualizerContainer>
<VoiceVisualizer isActive={isListening || isRecording} type="input" />
<VoiceVisualizer isActive={isSpeaking} type="output" />
</VisualizerContainer>
<TranscriptContainer>
{transcript && (
<TranscriptText>
<UserLabel>{t('voice_call.you')}:</UserLabel> {transcript}
</TranscriptText>
)}
</TranscriptContainer>
<ControlsContainer>
<Space>
<Button
type="text"
icon={isMuted ? <AudioMutedOutlined /> : <AudioOutlined />}
onClick={toggleMute}
size="large"
title={isMuted ? t('voice_call.unmute') : t('voice_call.mute')}
/>
<Button
type="text"
icon={isPaused ? <PlayCircleOutlined /> : <PauseCircleOutlined />}
onClick={togglePause}
size="large"
title={isPaused ? t('voice_call.resume') : t('voice_call.pause')}
/>
<Tooltip title={t('voice_call.press_to_talk')}>
<RecordButton
type={isRecording ? 'primary' : 'default'}
icon={<SoundOutlined />}
onMouseDown={handleRecordStart}
onMouseUp={handleRecordEnd}
onMouseLeave={handleRecordCancel}
onTouchStart={handleRecordStart}
onTouchEnd={handleRecordEnd}
onTouchCancel={handleRecordCancel}
size="large"
disabled={isProcessing || isPaused}>
{isRecording ? t('voice_call.release_to_send') : t('voice_call.press_to_talk')}
</RecordButton>
</Tooltip>
</Space>
</ControlsContainer>
</Content>
</Container>
)
}
// 样式组件
// --- 样式组件 ---
const Container = styled.div`
width: 300px;
background-color: var(--color-background);
@ -375,6 +34,13 @@ const Container = styled.div`
overflow: hidden;
display: flex;
flex-direction: column;
transform-origin: top left;
will-change: transform;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
cursor: default;
`
const Header = styled.div`
@ -385,10 +51,35 @@ const Header = styled.div`
display: flex;
align-items: center;
cursor: move;
user-select: none;
position: relative;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background-color: rgba(255, 255, 255, 0.2);
}
&:hover::before {
background-color: rgba(255, 255, 255, 0.4);
}
.drag-icon {
margin-right: 8px; // DragOutlined 的样式
}
.settings-button {
margin-left: auto; // 推到最右边
color: white; // 设置按钮颜色
}
`
const CloseButton = styled.div`
margin-left: auto;
margin-left: 8px; // 与设置按钮保持间距
cursor: pointer;
`
@ -435,4 +126,457 @@ const RecordButton = styled(Button)`
min-width: 120px;
`
export default DraggableVoiceCallWindow
// 设置面板的样式
const SettingsPanel = styled.div`
margin-bottom: 10px;
padding: 10px;
border: 1px solid var(--color-border);
border-radius: 8px;
`
const SettingsTitle = styled.div`
margin-bottom: 8px;
`
const ShortcutKeyButton = styled(Button)`
min-width: 120px;
`
const SettingsTip = styled.div`
margin-top: 8px;
font-size: 12px;
color: var(--color-text-secondary);
`
// --- 样式组件结束 ---
const DraggableVoiceCallWindow: React.FC<Props> = ({
visible,
onClose,
position = { x: 20, y: 20 },
onPositionChange
}) => {
const { t } = useTranslation()
const dispatch = useDispatch()
const [isDragging, setIsDragging] = useState(false)
const [currentPosition, setCurrentPosition] = useState(position)
const dragStartRef = useRef<{ startX: number; startY: number; initialX: number; initialY: number } | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
// --- 语音通话状态 ---
const [transcript, setTranscript] = useState('')
const [isListening, setIsListening] = useState(false)
const [isSpeaking, setIsSpeaking] = useState(false)
const [isRecording, setIsRecording] = useState(false)
const [isProcessing, setIsProcessing] = useState(false)
const [isPaused, setIsPaused] = useState(false)
const [isMuted, setIsMuted] = useState(false)
// --- 语音通话状态结束 ---
// --- 快捷键相关状态 ---
const [shortcutKey, setShortcutKey] = useState('Space')
const [isShortcutPressed, setIsShortcutPressed] = useState(false)
const [isSettingsVisible, setIsSettingsVisible] = useState(false)
const [tempShortcutKey, setTempShortcutKey] = useState(shortcutKey)
const [isRecordingShortcut, setIsRecordingShortcut] = useState(false)
// --- 快捷键相关状态结束 ---
const isInitializedRef = useRef(false)
// --- 拖拽逻辑 ---
const handleDragStart = useCallback(
(e: React.MouseEvent) => {
if ((e.target as HTMLElement).closest('button, input, a')) {
return
}
e.preventDefault()
setIsDragging(true)
dragStartRef.current = {
startX: e.clientX,
startY: e.clientY,
initialX: currentPosition.x,
initialY: currentPosition.y
}
},
[currentPosition]
)
const handleDrag = useCallback(
(e: MouseEvent) => {
if (!isDragging || !dragStartRef.current) return
e.preventDefault()
const deltaX = e.clientX - dragStartRef.current.startX
const deltaY = e.clientY - dragStartRef.current.startY
let newX = dragStartRef.current.initialX + deltaX
let newY = dragStartRef.current.initialY + deltaY
const windowWidth = window.innerWidth
const windowHeight = window.innerHeight
const containerWidth = containerRef.current?.offsetWidth || 300
const containerHeight = containerRef.current?.offsetHeight || 300
newX = Math.max(0, Math.min(newX, windowWidth - containerWidth))
newY = Math.max(0, Math.min(newY, windowHeight - containerHeight))
const newPosition = { x: newX, y: newY }
setCurrentPosition(newPosition)
onPositionChange?.(newPosition)
},
[isDragging, onPositionChange]
)
const handleDragEnd = useCallback(
(e: MouseEvent) => {
if (isDragging) {
e.preventDefault()
setIsDragging(false)
dragStartRef.current = null
}
},
[isDragging] // 移除了 currentPosition 依赖,因为它只在 handleDragStart 中读取一次
)
const throttle = useMemo(() => {
let lastCall = 0
const delay = 16 // ~60fps
return (func: (e: MouseEvent) => void) => {
return (e: MouseEvent) => {
const now = new Date().getTime()
if (now - lastCall < delay) {
return
}
lastCall = now
func(e)
}
}
}, [])
const throttledHandleDrag = useMemo(() => throttle(handleDrag), [handleDrag, throttle])
useEffect(() => {
if (isDragging) {
document.addEventListener('mousemove', throttledHandleDrag)
document.addEventListener('mouseup', handleDragEnd)
document.body.style.cursor = 'move'
} else {
document.removeEventListener('mousemove', throttledHandleDrag)
document.removeEventListener('mouseup', handleDragEnd)
document.body.style.cursor = 'default'
}
return () => {
document.removeEventListener('mousemove', throttledHandleDrag)
document.removeEventListener('mouseup', handleDragEnd)
document.body.style.cursor = 'default'
}
}, [isDragging, throttledHandleDrag, handleDragEnd])
// --- 拖拽逻辑结束 ---
// --- 状态和副作用管理 ---
useEffect(() => {
const handleTTSStateChange = (event: CustomEvent) => {
const { isPlaying } = event.detail
setIsSpeaking(isPlaying)
}
const startVoiceCall = async () => {
try {
window.message.loading({ content: t('voice_call.initializing'), key: 'voice-call-init' })
try {
await VoiceCallService.initialize()
} catch (initError) {
console.warn('语音识别服务初始化警告:', initError)
}
await VoiceCallService.startCall({
onTranscript: setTranscript,
onResponse: () => { /* 响应在聊天界面处理 */ },
onListeningStateChange: setIsListening,
onSpeakingStateChange: setIsSpeaking
})
window.message.success({ content: t('voice_call.ready'), key: 'voice-call-init' })
isInitializedRef.current = true
} catch (error) {
console.error('Voice call error:', error)
window.message.error({ content: t('voice_call.error'), key: 'voice-call-init' })
onClose()
}
}
if (visible) {
dispatch(setIsVoiceCallActive(true))
dispatch(setLastPlayedMessageId(null))
dispatch(setSkipNextAutoTTS(true))
if (!isInitializedRef.current) {
startVoiceCall()
}
window.addEventListener('tts-state-change', handleTTSStateChange as EventListener)
} else if (!visible && isInitializedRef.current) {
dispatch(setIsVoiceCallActive(false))
dispatch(setSkipNextAutoTTS(false))
VoiceCallService.endCall()
setTranscript('')
setIsListening(false)
setIsSpeaking(false)
setIsRecording(false)
setIsProcessing(false)
setIsPaused(false)
setIsMuted(false)
isInitializedRef.current = false
window.removeEventListener('tts-state-change', handleTTSStateChange as EventListener)
}
return () => {
window.removeEventListener('tts-state-change', handleTTSStateChange as EventListener)
}
}, [visible, dispatch, t, onClose])
// --- 状态和副作用管理结束 ---
// --- 语音通话控制函数 ---
const toggleMute = useCallback(() => {
const newMuteState = !isMuted
setIsMuted(newMuteState)
VoiceCallService.setMuted(newMuteState)
}, [isMuted]) // 添加依赖
const togglePause = useCallback(() => {
const newPauseState = !isPaused
setIsPaused(newPauseState)
VoiceCallService.setPaused(newPauseState)
}, [isPaused]) // 添加依赖
// !! 将这些函数定义移到 handleKeyDown/handleKeyUp 之前 !!
const handleRecordStart = useCallback(
async (e: React.MouseEvent | React.TouchEvent | KeyboardEvent) => {
e.preventDefault()
if (isProcessing || isPaused) return
setTranscript('')
VoiceCallService.stopTTS()
setIsSpeaking(false)
setIsRecording(true)
setIsProcessing(true)
try {
await VoiceCallService.startRecording()
setIsProcessing(false)
} catch (error) {
window.message.error({ content: '启动语音识别失败,请确保语音识别服务已启动', key: 'voice-call-error' })
setIsRecording(false)
setIsProcessing(false)
}
},
[isProcessing, isPaused]
)
const handleRecordEnd = useCallback(
async (e: React.MouseEvent | React.TouchEvent | KeyboardEvent) => {
e.preventDefault()
if (!isRecording) return
setIsRecording(false)
setIsProcessing(true)
VoiceCallService.stopTTS()
setIsSpeaking(false)
try {
const success = await VoiceCallService.stopRecordingAndSendToChat()
if (success) {
window.message.success({ content: '语音识别已完成,正在发送消息...', key: 'voice-call-send' })
} else {
window.message.error({ content: '发送语音识别结果失败', key: 'voice-call-error' })
}
} catch (error) {
window.message.error({ content: '停止录音出错', key: 'voice-call-error' })
} finally {
setTimeout(() => setIsProcessing(false), 500)
}
},
[isRecording]
)
const handleRecordCancel = useCallback(
async (e: React.MouseEvent | React.TouchEvent | KeyboardEvent) => {
e.preventDefault()
if (isRecording) {
setIsRecording(false)
setIsProcessing(true)
VoiceCallService.stopTTS()
setIsSpeaking(false)
try {
await VoiceCallService.cancelRecording()
setTranscript('')
} catch (error) {
console.error('取消录音出错:', error);
} finally {
setTimeout(() => setIsProcessing(false), 500)
}
}
},
[isRecording]
)
// --- 语音通话控制函数结束 ---
// --- 快捷键相关函数 ---
const getKeyDisplayName = (keyCode: string) => {
const keyMap: Record<string, string> = {
Space: '空格键', Enter: '回车键', ShiftLeft: '左Shift键', ShiftRight: '右Shift键',
ControlLeft: '左Ctrl键', ControlRight: '右Ctrl键', AltLeft: '左Alt键', AltRight: '右Alt键'
}
return keyMap[keyCode] || keyCode
}
const handleShortcutKeyChange = useCallback((e: KeyboardEvent) => {
e.preventDefault()
if (isRecordingShortcut) {
setTempShortcutKey(e.code)
setIsRecordingShortcut(false)
}
}, [isRecordingShortcut])
const saveShortcutKey = useCallback(() => {
setShortcutKey(tempShortcutKey)
localStorage.setItem('voiceCallShortcutKey', tempShortcutKey)
setIsSettingsVisible(false)
}, [tempShortcutKey])
// 现在可以安全地使用 handleRecordStart/End
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (isRecordingShortcut) {
handleShortcutKeyChange(e)
return
}
if (e.code === shortcutKey && !isProcessing && !isPaused && visible && !isShortcutPressed) {
e.preventDefault()
setIsShortcutPressed(true)
const mockEvent = new MouseEvent('mousedown') as unknown as React.MouseEvent // 类型断言
handleRecordStart(mockEvent) // 现在 handleRecordStart 已经定义
}
}, [
shortcutKey, isProcessing, isPaused, visible, isShortcutPressed,
handleRecordStart, // 依赖项
isRecordingShortcut, handleShortcutKeyChange
])
const handleKeyUp = useCallback((e: KeyboardEvent) => {
if (e.code === shortcutKey && isShortcutPressed && visible) {
e.preventDefault()
setIsShortcutPressed(false)
const mockEvent = new MouseEvent('mouseup') as unknown as React.MouseEvent // 类型断言
handleRecordEnd(mockEvent) // 现在 handleRecordEnd 已经定义
}
}, [shortcutKey, isShortcutPressed, visible, handleRecordEnd]) // 依赖项
useEffect(() => {
const savedShortcut = localStorage.getItem('voiceCallShortcutKey')
if (savedShortcut) {
setShortcutKey(savedShortcut)
setTempShortcutKey(savedShortcut)
}
}, [])
useEffect(() => {
if (visible) {
window.addEventListener('keydown', handleKeyDown)
window.addEventListener('keyup', handleKeyUp)
}
return () => {
window.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('keyup', handleKeyUp)
}
}, [visible, handleKeyDown, handleKeyUp])
// --- 快捷键相关函数结束 ---
// 如果不可见,直接返回 null
if (!visible) return null
// --- JSX 渲染 ---
return (
<Container
ref={containerRef}
style={{
transform: `translate(${currentPosition.x}px, ${currentPosition.y}px)` // 使用 transform 定位
}}>
{/* 将 onMouseDown 移到 Header 上 */}
<Header onMouseDown={handleDragStart}>
<DragOutlined className="drag-icon" /> {/* 应用样式类 */}
{t('voice_call.title')}
<Button
type="text"
icon={<SettingOutlined />}
onClick={() => setIsSettingsVisible(!isSettingsVisible)}
className="settings-button" // 应用样式类
/>
<CloseButton onClick={onClose}>
<CloseOutlined />
</CloseButton>
</Header>
<Content>
{isSettingsVisible && (
<SettingsPanel> {/* 使用 styled-component */}
<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>
<ControlsContainer>
<Space>
<Button
type="text"
icon={isMuted ? <AudioMutedOutlined /> : <AudioOutlined />}
onClick={toggleMute}
size="large"
title={isMuted ? t('voice_call.unmute') : t('voice_call.mute')}
/>
<Button
type="text"
icon={isPaused ? <PlayCircleOutlined /> : <PauseCircleOutlined />}
onClick={togglePause}
size="large"
title={isPaused ? t('voice_call.resume') : t('voice_call.pause')}
/>
<Tooltip title={`${t('voice_call.press_to_talk')} (${getKeyDisplayName(shortcutKey)})`}>
<RecordButton
type={isRecording ? 'primary' : 'default'}
icon={<SoundOutlined />}
onMouseDown={handleRecordStart}
onMouseUp={handleRecordEnd}
onMouseLeave={handleRecordCancel}
onTouchStart={handleRecordStart}
onTouchEnd={handleRecordEnd}
onTouchCancel={handleRecordCancel}
size="large"
disabled={isProcessing || isPaused}>
{isRecording ? t('voice_call.release_to_send') : t('voice_call.press_to_talk')}
</RecordButton>
</Tooltip>
</Space>
</ControlsContainer>
</Content>
</Container>
)
}
export default DraggableVoiceCallWindow

View File

@ -15,7 +15,12 @@
"initialization_failed": "初始化语音通话失败",
"error": "语音通话出错",
"initializing": "正在初始化语音通话...",
"ready": "语音通话已就绪"
"ready": "语音通话已就绪",
"shortcut_key_setting": "语音识别快捷键设置",
"press_any_key": "请按任意键...",
"save": "保存",
"cancel": "取消",
"shortcut_key_tip": "按下此快捷键开始录音,松开快捷键结束录音并发送"
},
"agents": {
"add.button": "添加到助手",

View File

@ -121,22 +121,35 @@ const MessageItem: FC<Props> = ({
) {
console.log('新消息生成完成消息ID:', message.id)
// 如果 skipNextAutoTTS 为 true重置为 false以便下一条消息可以自动播放
if (skipNextAutoTTS) {
console.log('重置 skipNextAutoTTS 为 false以便下一条消息可以自动播放')
// 当新消息生成完成时,始终重置 skipNextAutoTTS 为 false
// 这样确保新生成的消息可以自动播放
console.log('新消息生成完成,重置 skipNextAutoTTS 为 false')
dispatch(setSkipNextAutoTTS(false))
}
}, [isLastMessage, isAssistantMessage, message.status, message.id, generating, dispatch, prevGeneratingRef])
// 当消息内容变化时,重置 skipNextAutoTTS
useEffect(() => {
// 如果是最后一条助手消息,且消息状态为成功,且消息内容不为空
if (
isLastMessage &&
isAssistantMessage &&
message.status === 'success' &&
message.content &&
message.content.trim()
) {
// 如果是新生成的消息,重置 skipNextAutoTTS 为 false
if (message.id !== lastPlayedMessageId) {
console.log(
'检测到新消息,重置 skipNextAutoTTS 为 false消息ID:',
message.id,
'消息内容前20个字符:',
message.content?.substring(0, 20)
)
dispatch(setSkipNextAutoTTS(false))
}
}
}, [
isLastMessage,
isAssistantMessage,
message.status,
message.id,
generating,
skipNextAutoTTS,
dispatch,
prevGeneratingRef
])
}, [isLastMessage, isAssistantMessage, message.status, message.content, message.id, lastPlayedMessageId, dispatch])
// 自动播放TTS的逻辑
useEffect(() => {
@ -151,12 +164,32 @@ const MessageItem: FC<Props> = ({
) {
// 检查是否需要跳过自动TTS
if (skipNextAutoTTS) {
console.log('跳过自动TTS因为 skipNextAutoTTS 为 true消息ID:', message.id)
console.log(
'跳过自动TTS因为 skipNextAutoTTS 为 true消息ID:',
message.id,
'消息内容前20个字符:',
message.content?.substring(0, 20),
'消息状态:',
message.status,
'是否最后一条消息:',
isLastMessage,
'是否助手消息:',
isAssistantMessage,
'是否正在生成中:',
generating,
'语音通话窗口状态:',
isVoiceCallActive
)
// 注意:不在这里重置 skipNextAutoTTS而是在新消息生成时重置
return
}
console.log('准备自动播放TTS因为 skipNextAutoTTS 为 false消息ID:', message.id)
console.log(
'准备自动播放TTS因为 skipNextAutoTTS 为 false消息ID:',
message.id,
'消息内容前20个字符:',
message.content?.substring(0, 20)
)
// 检查消息是否有内容,且消息是新的(不是上次播放过的消息)
if (message.content && message.content.trim() && message.id !== lastPlayedMessageId) {