mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-07 22:10:21 +08:00
添加了 TTS 相关服务并更新了设置
This commit is contained in:
parent
d458395076
commit
e0531ca5b6
@ -5,10 +5,11 @@ import {
|
|||||||
DragOutlined,
|
DragOutlined,
|
||||||
PauseCircleOutlined,
|
PauseCircleOutlined,
|
||||||
PlayCircleOutlined,
|
PlayCircleOutlined,
|
||||||
|
SettingOutlined,
|
||||||
SoundOutlined
|
SoundOutlined
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import { Button, Space, Tooltip } from 'antd'
|
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 { useTranslation } from 'react-i18next'
|
||||||
import { useDispatch } from 'react-redux'
|
import { useDispatch } from 'react-redux'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
@ -24,349 +25,7 @@ interface Props {
|
|||||||
onPositionChange?: (position: { x: number; y: number }) => void
|
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`
|
const Container = styled.div`
|
||||||
width: 300px;
|
width: 300px;
|
||||||
background-color: var(--color-background);
|
background-color: var(--color-background);
|
||||||
@ -375,6 +34,13 @@ const Container = styled.div`
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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`
|
const Header = styled.div`
|
||||||
@ -385,10 +51,35 @@ const Header = styled.div`
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
cursor: move;
|
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`
|
const CloseButton = styled.div`
|
||||||
margin-left: auto;
|
margin-left: 8px; // 与设置按钮保持间距
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
`
|
`
|
||||||
|
|
||||||
@ -435,4 +126,457 @@ const RecordButton = styled(Button)`
|
|||||||
min-width: 120px;
|
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
|
||||||
@ -15,7 +15,12 @@
|
|||||||
"initialization_failed": "初始化语音通话失败",
|
"initialization_failed": "初始化语音通话失败",
|
||||||
"error": "语音通话出错",
|
"error": "语音通话出错",
|
||||||
"initializing": "正在初始化语音通话...",
|
"initializing": "正在初始化语音通话...",
|
||||||
"ready": "语音通话已就绪"
|
"ready": "语音通话已就绪",
|
||||||
|
"shortcut_key_setting": "语音识别快捷键设置",
|
||||||
|
"press_any_key": "请按任意键...",
|
||||||
|
"save": "保存",
|
||||||
|
"cancel": "取消",
|
||||||
|
"shortcut_key_tip": "按下此快捷键开始录音,松开快捷键结束录音并发送"
|
||||||
},
|
},
|
||||||
"agents": {
|
"agents": {
|
||||||
"add.button": "添加到助手",
|
"add.button": "添加到助手",
|
||||||
|
|||||||
@ -121,22 +121,35 @@ const MessageItem: FC<Props> = ({
|
|||||||
) {
|
) {
|
||||||
console.log('新消息生成完成,消息ID:', message.id)
|
console.log('新消息生成完成,消息ID:', message.id)
|
||||||
|
|
||||||
// 如果 skipNextAutoTTS 为 true,重置为 false,以便下一条消息可以自动播放
|
// 当新消息生成完成时,始终重置 skipNextAutoTTS 为 false
|
||||||
if (skipNextAutoTTS) {
|
// 这样确保新生成的消息可以自动播放
|
||||||
console.log('重置 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))
|
dispatch(setSkipNextAutoTTS(false))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [
|
}, [isLastMessage, isAssistantMessage, message.status, message.content, message.id, lastPlayedMessageId, dispatch])
|
||||||
isLastMessage,
|
|
||||||
isAssistantMessage,
|
|
||||||
message.status,
|
|
||||||
message.id,
|
|
||||||
generating,
|
|
||||||
skipNextAutoTTS,
|
|
||||||
dispatch,
|
|
||||||
prevGeneratingRef
|
|
||||||
])
|
|
||||||
|
|
||||||
// 自动播放TTS的逻辑
|
// 自动播放TTS的逻辑
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -151,12 +164,32 @@ const MessageItem: FC<Props> = ({
|
|||||||
) {
|
) {
|
||||||
// 检查是否需要跳过自动TTS
|
// 检查是否需要跳过自动TTS
|
||||||
if (skipNextAutoTTS) {
|
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,而是在新消息生成时重置
|
// 注意:不在这里重置 skipNextAutoTTS,而是在新消息生成时重置
|
||||||
return
|
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) {
|
if (message.content && message.content.trim() && message.id !== lastPlayedMessageId) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user