mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-24 18:50:56 +08:00
xuf
This commit is contained in:
parent
be67580230
commit
8e56f8774f
@ -123,7 +123,7 @@
|
||||
"@types/adm-zip": "^0",
|
||||
"@types/diff": "^7",
|
||||
"@types/fs-extra": "^11",
|
||||
"@types/lodash": "^4.17.5",
|
||||
"@types/lodash": "^4.17.16",
|
||||
"@types/markdown-it": "^14",
|
||||
"@types/md5": "^2.3.5",
|
||||
"@types/node": "^18.19.9",
|
||||
|
||||
@ -7,8 +7,8 @@ import Logger from 'electron-log'
|
||||
|
||||
import { registerIpc } from './ipc'
|
||||
import { configManager } from './services/ConfigManager'
|
||||
import { registerMsTTSIpcHandlers } from './services/MsTTSIpcHandler'
|
||||
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'
|
||||
|
||||
@ -1,24 +1,40 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Button, Space, Tooltip } from 'antd';
|
||||
import {
|
||||
AudioMutedOutlined,
|
||||
AudioOutlined,
|
||||
CloseOutlined,
|
||||
DragOutlined,
|
||||
PauseCircleOutlined,
|
||||
PlayCircleOutlined,
|
||||
SoundOutlined,
|
||||
DragOutlined
|
||||
} from '@ant-design/icons';
|
||||
import styled from 'styled-components';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import VoiceVisualizer from './VoiceVisualizer';
|
||||
import { VoiceCallService } from '../services/VoiceCallService';
|
||||
SoundOutlined
|
||||
} from '@ant-design/icons'
|
||||
import { Button, Space, Tooltip } from 'antd'
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDispatch } from 'react-redux'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { VoiceCallService } from '../services/VoiceCallService'
|
||||
import { setIsVoiceCallActive, setLastPlayedMessageId, setSkipNextAutoTTS } from '../store/settings'
|
||||
import VoiceVisualizer from './VoiceVisualizer'
|
||||
|
||||
// 节流函数,限制函数调用频率
|
||||
|
||||
function throttle<T extends (...args: any[]) => any>(func: T, delay: number): (...args: Parameters<T>) => void {
|
||||
let lastCall = 0
|
||||
return (...args: Parameters<T>) => {
|
||||
const now = Date.now()
|
||||
if (now - lastCall >= delay) {
|
||||
lastCall = now
|
||||
func(...args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
position?: { x: number, y: number };
|
||||
onPositionChange?: (position: { x: number, y: number }) => void;
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
position?: { x: number; y: number }
|
||||
onPositionChange?: (position: { x: number; y: number }) => void
|
||||
}
|
||||
|
||||
const DraggableVoiceCallWindow: React.FC<Props> = ({
|
||||
@ -27,32 +43,33 @@ const DraggableVoiceCallWindow: React.FC<Props> = ({
|
||||
position = { x: 20, y: 20 },
|
||||
onPositionChange
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [currentPosition, setCurrentPosition] = useState(position);
|
||||
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
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);
|
||||
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)
|
||||
|
||||
useEffect(() => {
|
||||
const startVoiceCall = async () => {
|
||||
try {
|
||||
// 显示加载中提示
|
||||
window.message.loading({ content: t('voice_call.initializing'), key: 'voice-call-init' });
|
||||
window.message.loading({ content: t('voice_call.initializing'), key: 'voice-call-init' })
|
||||
|
||||
// 预先初始化语音识别服务
|
||||
try {
|
||||
await VoiceCallService.initialize();
|
||||
await VoiceCallService.initialize()
|
||||
} catch (initError) {
|
||||
console.warn('语音识别服务初始化警告:', initError);
|
||||
console.warn('语音识别服务初始化警告:', initError)
|
||||
// 不抛出异常,允许程序继续运行
|
||||
}
|
||||
|
||||
@ -63,187 +80,280 @@ const DraggableVoiceCallWindow: React.FC<Props> = ({
|
||||
// 这里不设置response,因为响应会显示在聊天界面中
|
||||
},
|
||||
onListeningStateChange: setIsListening,
|
||||
onSpeakingStateChange: setIsSpeaking,
|
||||
});
|
||||
onSpeakingStateChange: setIsSpeaking
|
||||
})
|
||||
|
||||
// 关闭加载中提示
|
||||
window.message.success({ content: t('voice_call.ready'), key: 'voice-call-init' });
|
||||
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();
|
||||
console.error('Voice call error:', error)
|
||||
window.message.error({ content: t('voice_call.error'), key: 'voice-call-init' })
|
||||
onClose()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 添加TTS状态变化事件监听器
|
||||
const handleTTSStateChange = (event: CustomEvent) => {
|
||||
const { isPlaying } = event.detail;
|
||||
console.log('TTS状态变化事件:', isPlaying);
|
||||
setIsSpeaking(isPlaying);
|
||||
};
|
||||
const { isPlaying } = event.detail
|
||||
console.log('TTS状态变化事件:', isPlaying)
|
||||
setIsSpeaking(isPlaying)
|
||||
}
|
||||
|
||||
if (visible) {
|
||||
startVoiceCall();
|
||||
// 更新语音通话窗口状态
|
||||
dispatch(setIsVoiceCallActive(true))
|
||||
// 重置最后播放的消息ID,确保不会自动播放已有消息
|
||||
dispatch(setLastPlayedMessageId(null))
|
||||
// 设置跳过下一次自动TTS,确保打开窗口时不会自动播放最后一条消息
|
||||
dispatch(setSkipNextAutoTTS(true))
|
||||
startVoiceCall()
|
||||
// 添加事件监听器
|
||||
window.addEventListener('tts-state-change', handleTTSStateChange as EventListener);
|
||||
window.addEventListener('tts-state-change', handleTTSStateChange as EventListener)
|
||||
}
|
||||
|
||||
return () => {
|
||||
VoiceCallService.endCall();
|
||||
// 更新语音通话窗口状态
|
||||
dispatch(setIsVoiceCallActive(false))
|
||||
VoiceCallService.endCall()
|
||||
// 移除事件监听器
|
||||
window.removeEventListener('tts-state-change', handleTTSStateChange as EventListener);
|
||||
};
|
||||
}, [visible, t]);
|
||||
window.removeEventListener('tts-state-change', handleTTSStateChange as EventListener)
|
||||
}
|
||||
}, [visible, t])
|
||||
|
||||
// 拖拽相关处理
|
||||
const handleDragStart = (e: React.MouseEvent) => {
|
||||
if (containerRef.current) {
|
||||
setIsDragging(true);
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
e.preventDefault() // 防止默认行为
|
||||
e.stopPropagation() // 阻止事件冒泡
|
||||
|
||||
// 直接使用鼠标相对于屏幕的位置和窗口相对于屏幕的位置计算偏移
|
||||
setIsDragging(true)
|
||||
|
||||
// 记录鼠标相对于窗口左上角的偏移量
|
||||
setDragOffset({
|
||||
x: e.clientX - rect.left,
|
||||
y: e.clientY - rect.top
|
||||
});
|
||||
}
|
||||
};
|
||||
x: e.clientX - currentPosition.x,
|
||||
y: e.clientY - currentPosition.y
|
||||
})
|
||||
|
||||
const handleDrag = (e: MouseEvent) => {
|
||||
if (isDragging) {
|
||||
const newPosition = {
|
||||
x: e.clientX - dragOffset.x,
|
||||
y: e.clientY - dragOffset.y
|
||||
};
|
||||
setCurrentPosition(newPosition);
|
||||
onPositionChange?.(newPosition);
|
||||
// 在开发环境下只输出一次关键日志
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('开始拖拽 - 偏移量:', { x: e.clientX - currentPosition.x, y: e.clientY - currentPosition.y })
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
// 使用useCallback包装并优化handleDrag函数
|
||||
const handleDragBase = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
if (isDragging && containerRef.current) {
|
||||
// 限制拖拽范围,防止窗口被拖出屏幕
|
||||
const windowWidth = window.innerWidth
|
||||
const windowHeight = window.innerHeight
|
||||
const containerWidth = containerRef.current.offsetWidth
|
||||
const containerHeight = containerRef.current.offsetHeight
|
||||
|
||||
// 计算新位置,确保窗口不会被拖出屏幕
|
||||
// 使用鼠标当前位置减去偏移量,得到窗口应该在的位置
|
||||
const newX = Math.min(Math.max(0, e.clientX - dragOffset.x), windowWidth - containerWidth)
|
||||
const newY = Math.min(Math.max(0, e.clientY - dragOffset.y), windowHeight - containerHeight)
|
||||
|
||||
const newPosition = { x: newX, y: newY }
|
||||
|
||||
// 完全关闭拖拽中的日志输出
|
||||
// 如果需要调试,可以在这里添加日志代码
|
||||
|
||||
// 立即更新窗口位置,提高响应速度
|
||||
containerRef.current.style.left = `${newX}px`
|
||||
containerRef.current.style.top = `${newY}px`
|
||||
|
||||
// 更新状态
|
||||
setCurrentPosition(newPosition)
|
||||
onPositionChange?.(newPosition)
|
||||
}
|
||||
},
|
||||
[isDragging, dragOffset, onPositionChange]
|
||||
)
|
||||
|
||||
// 使用useMemo包装节流函数,避免重复创建
|
||||
const handleDrag = useMemo(() => throttle(handleDragBase, 16), [handleDragBase]) // 16ms 大约相当于 60fps
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setIsDragging(false)
|
||||
// 完全关闭拖拽结束的日志输出
|
||||
}, [])
|
||||
|
||||
// 添加键盘快捷键支持
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose()
|
||||
} else if (e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
|
||||
// 防止箭头键滚动页面
|
||||
e.preventDefault()
|
||||
|
||||
// 移动步长
|
||||
const step = e.shiftKey ? 10 : 5
|
||||
|
||||
// 根据按键移动窗口
|
||||
let newX = currentPosition.x
|
||||
let newY = currentPosition.y
|
||||
|
||||
if (e.key === 'ArrowUp') newY -= step
|
||||
if (e.key === 'ArrowDown') newY += step
|
||||
if (e.key === 'ArrowLeft') newX -= step
|
||||
if (e.key === 'ArrowRight') newX += step
|
||||
|
||||
// 限制范围
|
||||
if (containerRef.current) {
|
||||
const windowWidth = window.innerWidth
|
||||
const windowHeight = window.innerHeight
|
||||
const containerWidth = containerRef.current.offsetWidth
|
||||
const containerHeight = containerRef.current.offsetHeight
|
||||
|
||||
newX = Math.min(Math.max(0, newX), windowWidth - containerWidth)
|
||||
newY = Math.min(Math.max(0, newY), windowHeight - containerHeight)
|
||||
}
|
||||
|
||||
const newPosition = { x: newX, y: newY }
|
||||
setCurrentPosition(newPosition)
|
||||
onPositionChange?.(newPosition)
|
||||
}
|
||||
},
|
||||
[currentPosition, onClose, onPositionChange]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (isDragging) {
|
||||
document.addEventListener('mousemove', handleDrag);
|
||||
document.addEventListener('mouseup', handleDragEnd);
|
||||
document.addEventListener('mousemove', handleDrag)
|
||||
document.addEventListener('mouseup', handleDragEnd)
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleDrag);
|
||||
document.removeEventListener('mouseup', handleDragEnd);
|
||||
};
|
||||
}, [isDragging]);
|
||||
document.removeEventListener('mousemove', handleDrag)
|
||||
document.removeEventListener('mouseup', handleDragEnd)
|
||||
}
|
||||
}, [isDragging, handleDrag, handleDragEnd])
|
||||
|
||||
// 添加键盘事件监听
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [visible, handleKeyDown])
|
||||
|
||||
// 语音通话相关处理
|
||||
const toggleMute = () => {
|
||||
setIsMuted(!isMuted);
|
||||
VoiceCallService.setMuted(!isMuted);
|
||||
};
|
||||
setIsMuted(!isMuted)
|
||||
VoiceCallService.setMuted(!isMuted)
|
||||
}
|
||||
|
||||
const togglePause = () => {
|
||||
const newPauseState = !isPaused;
|
||||
setIsPaused(newPauseState);
|
||||
VoiceCallService.setPaused(newPauseState);
|
||||
};
|
||||
const newPauseState = !isPaused
|
||||
setIsPaused(newPauseState)
|
||||
VoiceCallService.setPaused(newPauseState)
|
||||
}
|
||||
|
||||
// 长按说话相关处理
|
||||
const handleRecordStart = async (e: React.MouseEvent | React.TouchEvent) => {
|
||||
e.preventDefault(); // 防止触摸事件的默认行为
|
||||
e.preventDefault() // 防止触摸事件的默认行为
|
||||
|
||||
if (isProcessing || isPaused) return;
|
||||
if (isProcessing || isPaused) return
|
||||
|
||||
// 先清除之前的语音识别结果
|
||||
setTranscript('');
|
||||
setTranscript('')
|
||||
|
||||
// 无论是否正在播放,都强制停止TTS
|
||||
VoiceCallService.stopTTS();
|
||||
setIsSpeaking(false);
|
||||
VoiceCallService.stopTTS()
|
||||
setIsSpeaking(false)
|
||||
|
||||
// 更新UI状态
|
||||
setIsRecording(true);
|
||||
setIsProcessing(true); // 设置处理状态,防止重复点击
|
||||
setIsRecording(true)
|
||||
setIsProcessing(true) // 设置处理状态,防止重复点击
|
||||
|
||||
// 开始录音
|
||||
try {
|
||||
await VoiceCallService.startRecording();
|
||||
console.log('开始录音');
|
||||
setIsProcessing(false); // 录音开始后取消处理状态
|
||||
await VoiceCallService.startRecording()
|
||||
console.log('开始录音')
|
||||
setIsProcessing(false) // 录音开始后取消处理状态
|
||||
} catch (error) {
|
||||
console.error('开始录音出错:', error);
|
||||
window.message.error({ content: '启动语音识别失败,请确保语音识别服务已启动', key: 'voice-call-error' });
|
||||
setIsRecording(false);
|
||||
setIsProcessing(false);
|
||||
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(); // 防止触摸事件的默认行为
|
||||
e.preventDefault() // 防止触摸事件的默认行为
|
||||
|
||||
if (!isRecording) return;
|
||||
if (!isRecording) return
|
||||
|
||||
// 立即更新UI状态
|
||||
setIsRecording(false);
|
||||
setIsProcessing(true);
|
||||
setIsRecording(false)
|
||||
setIsProcessing(true)
|
||||
|
||||
// 无论是否正在播放,都强制停止TTS
|
||||
VoiceCallService.stopTTS();
|
||||
setIsSpeaking(false);
|
||||
VoiceCallService.stopTTS()
|
||||
setIsSpeaking(false)
|
||||
|
||||
// 确保录音完全停止
|
||||
try {
|
||||
// 传递 true 参数,表示将结果发送到聊天界面
|
||||
const success = await VoiceCallService.stopRecordingAndSendToChat();
|
||||
console.log('录音已停止,结果已发送到聊天界面', success ? '成功' : '失败');
|
||||
const success = await VoiceCallService.stopRecordingAndSendToChat()
|
||||
console.log('录音已停止,结果已发送到聊天界面', success ? '成功' : '失败')
|
||||
|
||||
if (success) {
|
||||
// 显示成功消息
|
||||
window.message.success({ content: '语音识别已完成,正在发送消息...', key: 'voice-call-send' });
|
||||
window.message.success({ content: '语音识别已完成,正在发送消息...', key: 'voice-call-send' })
|
||||
} else {
|
||||
// 显示失败消息
|
||||
window.message.error({ content: '发送语音识别结果失败', key: 'voice-call-error' });
|
||||
window.message.error({ content: '发送语音识别结果失败', key: 'voice-call-error' })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('停止录音出错:', error);
|
||||
window.message.error({ content: '停止录音出错', key: 'voice-call-error' });
|
||||
console.error('停止录音出错:', error)
|
||||
window.message.error({ content: '停止录音出错', key: 'voice-call-error' })
|
||||
} finally {
|
||||
// 无论成功与否,都确保在一定时间后重置处理状态
|
||||
setTimeout(() => {
|
||||
setIsProcessing(false);
|
||||
}, 1000); // 增加延迟时间,确保有足够时间处理结果
|
||||
setIsProcessing(false)
|
||||
}, 1000) // 增加延迟时间,确保有足够时间处理结果
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 处理鼠标/触摸离开按钮的情况
|
||||
const handleRecordCancel = async (e: React.MouseEvent | React.TouchEvent) => {
|
||||
e.preventDefault();
|
||||
e.preventDefault()
|
||||
|
||||
if (isRecording) {
|
||||
// 立即更新UI状态
|
||||
setIsRecording(false);
|
||||
setIsProcessing(true);
|
||||
setIsRecording(false)
|
||||
setIsProcessing(true)
|
||||
|
||||
// 无论是否正在播放,都强制停止TTS
|
||||
VoiceCallService.stopTTS();
|
||||
setIsSpeaking(false);
|
||||
VoiceCallService.stopTTS()
|
||||
setIsSpeaking(false)
|
||||
|
||||
// 取消录音,不发送给AI
|
||||
try {
|
||||
await VoiceCallService.cancelRecording();
|
||||
console.log('录音已取消');
|
||||
await VoiceCallService.cancelRecording()
|
||||
console.log('录音已取消')
|
||||
|
||||
// 清除输入文本
|
||||
setTranscript('');
|
||||
setTranscript('')
|
||||
} catch (error) {
|
||||
console.error('取消录音出错:', error);
|
||||
console.error('取消录音出错:', error)
|
||||
} finally {
|
||||
// 无论成功与否,都确保在一定时间后重置处理状态
|
||||
setTimeout(() => {
|
||||
setIsProcessing(false);
|
||||
}, 1000);
|
||||
setIsProcessing(false)
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (!visible) return null;
|
||||
if (!visible) return null
|
||||
|
||||
return (
|
||||
<Container
|
||||
@ -252,9 +362,10 @@ const DraggableVoiceCallWindow: React.FC<Props> = ({
|
||||
left: `${currentPosition.x}px`,
|
||||
top: `${currentPosition.y}px`,
|
||||
position: 'fixed',
|
||||
zIndex: 1000
|
||||
}}
|
||||
>
|
||||
zIndex: 1000,
|
||||
transform: 'translate3d(0,0,0)', // 启用GPU加速,提高拖拽流畅度
|
||||
willChange: 'left, top' // 提示浏览器这些属性将会变化,优化渲染
|
||||
}}>
|
||||
<Header onMouseDown={handleDragStart}>
|
||||
<DragOutlined style={{ cursor: 'move', marginRight: 8 }} />
|
||||
{t('voice_call.title')}
|
||||
@ -295,7 +406,7 @@ const DraggableVoiceCallWindow: React.FC<Props> = ({
|
||||
/>
|
||||
<Tooltip title={t('voice_call.press_to_talk')}>
|
||||
<RecordButton
|
||||
type={isRecording ? "primary" : "default"}
|
||||
type={isRecording ? 'primary' : 'default'}
|
||||
icon={<SoundOutlined />}
|
||||
onMouseDown={handleRecordStart}
|
||||
onMouseUp={handleRecordEnd}
|
||||
@ -304,8 +415,7 @@ const DraggableVoiceCallWindow: React.FC<Props> = ({
|
||||
onTouchEnd={handleRecordEnd}
|
||||
onTouchCancel={handleRecordCancel}
|
||||
size="large"
|
||||
disabled={isProcessing || isPaused}
|
||||
>
|
||||
disabled={isProcessing || isPaused}>
|
||||
{isRecording ? t('voice_call.release_to_send') : t('voice_call.press_to_talk')}
|
||||
</RecordButton>
|
||||
</Tooltip>
|
||||
@ -313,8 +423,8 @@ const DraggableVoiceCallWindow: React.FC<Props> = ({
|
||||
</ControlsContainer>
|
||||
</Content>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
// 样式组件
|
||||
const Container = styled.div`
|
||||
@ -325,7 +435,7 @@ const Container = styled.div`
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
`
|
||||
|
||||
const Header = styled.div`
|
||||
padding: 8px 12px;
|
||||
@ -335,25 +445,25 @@ const Header = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: move;
|
||||
`;
|
||||
`
|
||||
|
||||
const CloseButton = styled.div`
|
||||
margin-left: auto;
|
||||
cursor: pointer;
|
||||
`;
|
||||
`
|
||||
|
||||
const Content = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
`;
|
||||
`
|
||||
|
||||
const VisualizerContainer = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
height: 60px;
|
||||
`;
|
||||
`
|
||||
|
||||
const TranscriptContainer = styled.div`
|
||||
flex: 1;
|
||||
@ -364,25 +474,25 @@ const TranscriptContainer = styled.div`
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
background-color: var(--color-background-2);
|
||||
`;
|
||||
`
|
||||
|
||||
const TranscriptText = styled.div`
|
||||
margin-bottom: 8px;
|
||||
`;
|
||||
`
|
||||
|
||||
const UserLabel = styled.span`
|
||||
font-weight: bold;
|
||||
color: var(--color-primary);
|
||||
`;
|
||||
`
|
||||
|
||||
const ControlsContainer = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 8px 0;
|
||||
`;
|
||||
`
|
||||
|
||||
const RecordButton = styled(Button)`
|
||||
min-width: 120px;
|
||||
`;
|
||||
`
|
||||
|
||||
export default DraggableVoiceCallWindow;
|
||||
export default DraggableVoiceCallWindow
|
||||
|
||||
@ -2,8 +2,10 @@ import React, { useState } from 'react';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import { PhoneOutlined, LoadingOutlined } from '@ant-design/icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { VoiceCallService } from '../services/VoiceCallService';
|
||||
import DraggableVoiceCallWindow from './DraggableVoiceCallWindow';
|
||||
import { setIsVoiceCallActive, setLastPlayedMessageId, setSkipNextAutoTTS } from '../store/settings';
|
||||
|
||||
interface Props {
|
||||
disabled?: boolean;
|
||||
@ -12,6 +14,7 @@ interface Props {
|
||||
|
||||
const VoiceCallButton: React.FC<Props> = ({ disabled = false, style }) => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
const [isWindowVisible, setIsWindowVisible] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [windowPosition, setWindowPosition] = useState({ x: 20, y: 20 });
|
||||
@ -24,6 +27,11 @@ const VoiceCallButton: React.FC<Props> = ({ disabled = false, style }) => {
|
||||
// 初始化语音服务
|
||||
await VoiceCallService.initialize();
|
||||
setIsWindowVisible(true);
|
||||
dispatch(setIsVoiceCallActive(true));
|
||||
// 重置最后播放的消息ID,确保不会自动播放已有消息
|
||||
dispatch(setLastPlayedMessageId(null));
|
||||
// 设置跳过下一次自动TTS,确保打开窗口时不会自动播放最后一条消息
|
||||
dispatch(setSkipNextAutoTTS(true));
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize voice call:', error);
|
||||
window.message.error(t('voice_call.initialization_failed'));
|
||||
@ -45,7 +53,10 @@ const VoiceCallButton: React.FC<Props> = ({ disabled = false, style }) => {
|
||||
</Tooltip>
|
||||
<DraggableVoiceCallWindow
|
||||
visible={isWindowVisible}
|
||||
onClose={() => setIsWindowVisible(false)}
|
||||
onClose={() => {
|
||||
setIsWindowVisible(false);
|
||||
dispatch(setIsVoiceCallActive(false));
|
||||
}}
|
||||
position={windowPosition}
|
||||
onPositionChange={setWindowPosition}
|
||||
/>
|
||||
|
||||
@ -1,74 +1,74 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface Props {
|
||||
isActive: boolean;
|
||||
type: 'input' | 'output';
|
||||
isActive: boolean
|
||||
type: 'input' | 'output'
|
||||
}
|
||||
|
||||
const VoiceVisualizer: React.FC<Props> = ({ isActive, type }) => {
|
||||
const { t } = useTranslation();
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const animationRef = useRef<number | undefined>(undefined);
|
||||
const { t } = useTranslation()
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const animationRef = useRef<number | undefined>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
const width = canvas.width;
|
||||
const height = canvas.height;
|
||||
const width = canvas.width
|
||||
const height = canvas.height
|
||||
|
||||
const drawVisualizer = () => {
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
|
||||
if (!isActive) {
|
||||
// 绘制静态波形
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, height / 2);
|
||||
ctx.lineTo(width, height / 2);
|
||||
ctx.strokeStyle = type === 'input' ? 'var(--color-text-2)' : 'var(--color-primary)';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
return;
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, height / 2)
|
||||
ctx.lineTo(width, height / 2)
|
||||
ctx.strokeStyle = type === 'input' ? 'var(--color-text-2)' : 'var(--color-primary)'
|
||||
ctx.lineWidth = 2
|
||||
ctx.stroke()
|
||||
return
|
||||
}
|
||||
|
||||
// 绘制动态波形
|
||||
const barCount = 30;
|
||||
const barWidth = width / barCount;
|
||||
const color = type === 'input' ? 'var(--color-text-1)' : 'var(--color-primary)';
|
||||
const barCount = 30
|
||||
const barWidth = width / barCount
|
||||
const color = type === 'input' ? 'var(--color-text-1)' : 'var(--color-primary)'
|
||||
|
||||
for (let i = 0; i < barCount; i++) {
|
||||
const barHeight = Math.random() * (height / 2) + 10;
|
||||
const x = i * barWidth;
|
||||
const y = height / 2 - barHeight / 2;
|
||||
const barHeight = Math.random() * (height / 2) + 10
|
||||
const x = i * barWidth
|
||||
const y = height / 2 - barHeight / 2
|
||||
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(x, y, barWidth - 2, barHeight);
|
||||
ctx.fillStyle = color
|
||||
ctx.fillRect(x, y, barWidth - 2, barHeight)
|
||||
}
|
||||
|
||||
animationRef.current = requestAnimationFrame(drawVisualizer);
|
||||
};
|
||||
animationRef.current = requestAnimationFrame(drawVisualizer)
|
||||
}
|
||||
|
||||
drawVisualizer();
|
||||
drawVisualizer()
|
||||
|
||||
return () => {
|
||||
if (animationRef.current) {
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
cancelAnimationFrame(animationRef.current)
|
||||
}
|
||||
};
|
||||
}, [isActive, type]);
|
||||
}
|
||||
}, [isActive, type])
|
||||
|
||||
return (
|
||||
<Container $type={type}>
|
||||
<Label>{type === 'input' ? t('voice_call.you') : t('voice_call.ai')}</Label>
|
||||
<Canvas ref={canvasRef} width={200} height={50} />
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div<{ $type: 'input' | 'output' }>`
|
||||
display: flex;
|
||||
@ -77,21 +77,17 @@ const Container = styled.div<{ $type: 'input' | 'output' }>`
|
||||
width: 45%;
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
background-color: ${props =>
|
||||
props.$type === 'input'
|
||||
? 'var(--color-background-3)'
|
||||
: 'var(--color-primary-bg)'
|
||||
};
|
||||
`;
|
||||
background-color: ${(props) => (props.$type === 'input' ? 'var(--color-background-3)' : 'var(--color-primary-bg)')};
|
||||
`
|
||||
|
||||
const Label = styled.div`
|
||||
margin-bottom: 8px;
|
||||
font-weight: bold;
|
||||
`;
|
||||
`
|
||||
|
||||
const Canvas = styled.canvas`
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
`;
|
||||
`
|
||||
|
||||
export default VoiceVisualizer;
|
||||
export default VoiceVisualizer
|
||||
|
||||
@ -1413,6 +1413,7 @@
|
||||
"filter.markdown": "Filter Markdown",
|
||||
"filter.code_blocks": "Filter code blocks",
|
||||
"filter.html_tags": "Filter HTML tags",
|
||||
"filter.emojis": "Filter emojis",
|
||||
"max_text_length": "Maximum text length",
|
||||
"test": "Test Speech",
|
||||
"help": "Text-to-speech functionality supports converting text to natural-sounding speech.",
|
||||
|
||||
@ -1421,7 +1421,8 @@
|
||||
"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音色"
|
||||
"error.no_mstts_voice": "[to be translated]:未设置免费在线 TTS音色",
|
||||
"filter.emojis": "[to be translated]:过滤表情符号"
|
||||
},
|
||||
"asr": {
|
||||
"title": "音声認識",
|
||||
|
||||
@ -1421,7 +1421,8 @@
|
||||
"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音色"
|
||||
"error.no_mstts_voice": "[to be translated]:未设置免费在线 TTS音色",
|
||||
"filter.emojis": "[to be translated]:过滤表情符号"
|
||||
},
|
||||
"voice": {
|
||||
"title": "[to be translated]:语音功能",
|
||||
|
||||
@ -1427,6 +1427,7 @@
|
||||
"filter.markdown": "过滤Markdown标记",
|
||||
"filter.code_blocks": "过滤代码块",
|
||||
"filter.html_tags": "过滤HTML标签",
|
||||
"filter.emojis": "过滤表情符号",
|
||||
"max_text_length": "最大文本长度",
|
||||
"test": "测试语音",
|
||||
"help": "语音合成功能支持将文本转换为自然语音。",
|
||||
|
||||
@ -1421,7 +1421,8 @@
|
||||
"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音色"
|
||||
"error.no_mstts_voice": "[to be translated]:未设置免费在线 TTS音色",
|
||||
"filter.emojis": "[to be translated]:过滤表情符号"
|
||||
},
|
||||
"voice": {
|
||||
"title": "[to be translated]:语音功能",
|
||||
|
||||
343
src/renderer/src/pages/VoiceCallWindow.tsx
Normal file
343
src/renderer/src/pages/VoiceCallWindow.tsx
Normal file
@ -0,0 +1,343 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import styled from 'styled-components';
|
||||
import { Button, Space, Tooltip } from 'antd';
|
||||
import {
|
||||
AudioMutedOutlined,
|
||||
AudioOutlined,
|
||||
CloseOutlined,
|
||||
PauseCircleOutlined,
|
||||
PlayCircleOutlined,
|
||||
SoundOutlined
|
||||
} from '@ant-design/icons';
|
||||
import { VoiceCallService } from '../services/VoiceCallService';
|
||||
import VoiceVisualizer from '../components/VoiceVisualizer';
|
||||
import { setIsVoiceCallActive, setLastPlayedMessageId, setSkipNextAutoTTS } from '../store/settings';
|
||||
|
||||
/**
|
||||
* 独立窗口的语音通话组件
|
||||
* 这个组件用于在独立窗口中显示语音通话界面
|
||||
*/
|
||||
const VoiceCallWindow: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// 语音通话状态
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
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' });
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
// 添加TTS状态变化事件监听器
|
||||
const handleTTSStateChange = (event: CustomEvent) => {
|
||||
const { isPlaying } = event.detail;
|
||||
console.log('TTS状态变化事件:', isPlaying);
|
||||
setIsSpeaking(isPlaying);
|
||||
};
|
||||
|
||||
// 更新语音通话窗口状态
|
||||
dispatch(setIsVoiceCallActive(true));
|
||||
// 重置最后播放的消息ID,确保不会自动播放已有消息
|
||||
dispatch(setLastPlayedMessageId(null));
|
||||
// 设置跳过下一次自动TTS,确保打开窗口时不会自动播放最后一条消息
|
||||
dispatch(setSkipNextAutoTTS(true));
|
||||
|
||||
startVoiceCall();
|
||||
// 添加事件监听器
|
||||
window.addEventListener('tts-state-change', handleTTSStateChange as EventListener);
|
||||
|
||||
return () => {
|
||||
// 更新语音通话窗口状态
|
||||
dispatch(setIsVoiceCallActive(false));
|
||||
VoiceCallService.endCall();
|
||||
// 移除事件监听器
|
||||
window.removeEventListener('tts-state-change', handleTTSStateChange as EventListener);
|
||||
};
|
||||
}, [t, dispatch]);
|
||||
|
||||
// 语音通话相关处理
|
||||
const toggleMute = () => {
|
||||
setIsMuted(!isMuted);
|
||||
VoiceCallService.setMuted(!isMuted);
|
||||
};
|
||||
|
||||
const togglePause = () => {
|
||||
const newPauseState = !isPaused;
|
||||
setIsPaused(newPauseState);
|
||||
VoiceCallService.setPaused(newPauseState);
|
||||
};
|
||||
|
||||
// 关闭窗口
|
||||
const handleClose = () => {
|
||||
window.close();
|
||||
};
|
||||
|
||||
// 长按说话相关处理
|
||||
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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Header>
|
||||
<Title>{t('voice_call.title')}</Title>
|
||||
<CloseButton onClick={handleClose}>
|
||||
<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: 100%;
|
||||
height: 100vh;
|
||||
background-color: var(--color-background);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const Header = styled.div`
|
||||
padding: 12px 16px;
|
||||
background-color: var(--color-primary);
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const Title = styled.div`
|
||||
flex: 1;
|
||||
font-size: 16px;
|
||||
`;
|
||||
|
||||
const CloseButton = styled.div`
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
const Content = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const VisualizerContainer = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
height: 100px;
|
||||
`;
|
||||
|
||||
const TranscriptContainer = styled.div`
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
background-color: var(--color-background-2);
|
||||
`;
|
||||
|
||||
const TranscriptText = styled.div`
|
||||
margin-bottom: 8px;
|
||||
`;
|
||||
|
||||
const UserLabel = styled.span`
|
||||
font-weight: bold;
|
||||
color: var(--color-primary);
|
||||
`;
|
||||
|
||||
const ControlsContainer = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 16px 0;
|
||||
`;
|
||||
|
||||
const RecordButton = styled(Button)`
|
||||
min-width: 120px;
|
||||
`;
|
||||
|
||||
export default VoiceCallWindow;
|
||||
@ -35,7 +35,7 @@ import { getModelUniqId } from '@renderer/services/ModelService'
|
||||
import { estimateMessageUsage, estimateTextTokens as estimateTxtTokens } from '@renderer/services/TokenService'
|
||||
import { translateText } from '@renderer/services/TranslateService'
|
||||
import WebSearchService from '@renderer/services/WebSearchService'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import store, { useAppDispatch } from '@renderer/store'
|
||||
import { sendMessage as _sendMessage } from '@renderer/store/messages'
|
||||
import { setSearching } from '@renderer/store/runtime'
|
||||
import { Assistant, FileType, KnowledgeBase, KnowledgeItem, MCPServer, Message, Model, Topic } from '@renderer/types'
|
||||
@ -714,21 +714,12 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
textareaRef.current?.focus()
|
||||
}),
|
||||
// 监听语音通话消息
|
||||
EventEmitter.on(EVENT_NAMES.VOICE_CALL_MESSAGE, (data: { text: string, model: string }) => {
|
||||
EventEmitter.on(EVENT_NAMES.VOICE_CALL_MESSAGE, (data: { text: string, model: any, isVoiceCall?: boolean, useVoiceCallModel?: boolean, voiceCallModelId?: string }) => {
|
||||
console.log('收到语音通话消息:', data);
|
||||
|
||||
// 先设置输入框文本
|
||||
setText(data.text);
|
||||
|
||||
// 如果有指定模型,切换到该模型
|
||||
if (data.model) {
|
||||
// 查找对应的模型对象
|
||||
const modelObj = assistant.model?.id === data.model ? assistant.model : undefined;
|
||||
if (modelObj) {
|
||||
setModel(modelObj);
|
||||
}
|
||||
}
|
||||
|
||||
// 使用延时确保文本已经设置到输入框
|
||||
setTimeout(() => {
|
||||
// 直接调用发送消息函数,而不检查inputEmpty
|
||||
@ -743,18 +734,65 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
content: data.text
|
||||
});
|
||||
|
||||
// 如果有指定模型,设置模型
|
||||
if (data.model) {
|
||||
// 查找对应的模型对象
|
||||
const modelObj = assistant.model?.id === data.model ? assistant.model : undefined;
|
||||
// 如果是语音通话消息,使用语音通话专用模型
|
||||
if (data.isVoiceCall || data.useVoiceCallModel) {
|
||||
// 从全局设置中获取语音通话专用模型
|
||||
const { voiceCallModel } = store.getState().settings;
|
||||
|
||||
// 打印调试信息
|
||||
console.log('语音通话消息,尝试使用语音通话专用模型');
|
||||
console.log('全局设置中的语音通话模型:', voiceCallModel ? JSON.stringify(voiceCallModel) : 'null');
|
||||
console.log('事件中传递的模型:', data.model ? JSON.stringify(data.model) : 'null');
|
||||
|
||||
// 如果全局设置中有语音通话专用模型,优先使用
|
||||
if (voiceCallModel) {
|
||||
userMessage.model = voiceCallModel;
|
||||
console.log('使用全局设置中的语音通话专用模型:', voiceCallModel.name);
|
||||
|
||||
// 强制覆盖消息中的模型
|
||||
userMessage.modelId = voiceCallModel.id;
|
||||
}
|
||||
// 如果没有全局设置,但事件中传递了模型,使用事件中的模型
|
||||
else if (data.model && typeof data.model === 'object') {
|
||||
userMessage.model = data.model;
|
||||
console.log('使用事件中传递的模型:', data.model.name || data.model.id);
|
||||
|
||||
// 强制覆盖消息中的模型
|
||||
userMessage.modelId = data.model.id;
|
||||
}
|
||||
// 如果没有模型对象,但有模型ID,尝试使用模型ID
|
||||
else if (data.voiceCallModelId) {
|
||||
console.log('使用事件中传递的模型ID:', data.voiceCallModelId);
|
||||
userMessage.modelId = data.voiceCallModelId;
|
||||
}
|
||||
// 如果以上都没有,使用当前助手模型
|
||||
else {
|
||||
console.log('没有找到语音通话专用模型,使用当前助手模型');
|
||||
}
|
||||
}
|
||||
// 非语音通话消息,使用当前助手模型
|
||||
else if (data.model) {
|
||||
const modelObj = assistant.model?.id === data.model.id ? assistant.model : undefined;
|
||||
if (modelObj) {
|
||||
userMessage.model = modelObj;
|
||||
console.log('使用当前助手模型:', modelObj.name || modelObj.id);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果是语音通话消息,创建一个新的助手对象,并设置模型
|
||||
let assistantToUse = assistant;
|
||||
if ((data.isVoiceCall || data.useVoiceCallModel) && userMessage.model) {
|
||||
// 创建一个新的助手对象,以避免修改原始助手
|
||||
assistantToUse = { ...assistant };
|
||||
|
||||
// 设置助手的模型为语音通话专用模型
|
||||
assistantToUse.model = userMessage.model;
|
||||
console.log('为语音通话消息创建了新的助手对象,并设置了模型:', userMessage.model.name || userMessage.model.id);
|
||||
}
|
||||
|
||||
// 分发发送消息的action
|
||||
dispatch(
|
||||
_sendMessage(userMessage, assistant, topic, {})
|
||||
_sendMessage(userMessage, assistantToUse, topic, {})
|
||||
);
|
||||
|
||||
// 清空输入框
|
||||
|
||||
@ -7,7 +7,8 @@ import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import { getMessageModelId } from '@renderer/services/MessagesService'
|
||||
import { getModelUniqId } from '@renderer/services/ModelService'
|
||||
import TTSService from '@renderer/services/TTSService'
|
||||
import { RootState } from '@renderer/store'
|
||||
import { RootState, useAppDispatch } from '@renderer/store'
|
||||
import { setLastPlayedMessageId, setSkipNextAutoTTS } from '@renderer/store/settings'
|
||||
import { Assistant, Message, Topic } from '@renderer/types'
|
||||
import { classNames } from '@renderer/utils'
|
||||
import { useSelector } from 'react-redux'
|
||||
@ -57,7 +58,8 @@ const MessageItem: FC<Props> = ({
|
||||
const [selectedQuoteText, setSelectedQuoteText] = useState<string>('')
|
||||
|
||||
// 获取TTS设置
|
||||
const ttsEnabled = useSelector((state: RootState) => state.settings.ttsEnabled)
|
||||
const { ttsEnabled, isVoiceCallActive, lastPlayedMessageId, skipNextAutoTTS } = useSelector((state: RootState) => state.settings)
|
||||
const dispatch = useAppDispatch()
|
||||
const [selectedText, setSelectedText] = useState<string>('')
|
||||
|
||||
const isLastMessage = index === 0
|
||||
@ -98,18 +100,35 @@ const MessageItem: FC<Props> = ({
|
||||
|
||||
// 自动播放TTS的逻辑
|
||||
useEffect(() => {
|
||||
// 如果是最后一条助手消息,且消息状态为成功,且不是正在生成中,且TTS已启用
|
||||
if (isLastMessage && isAssistantMessage && message.status === 'success' && !generating && ttsEnabled) {
|
||||
// 检查消息是否有内容
|
||||
if (message.content && message.content.trim()) {
|
||||
console.log('自动播放最新助手消息的TTS:', message.id)
|
||||
// 如果是最后一条助手消息,且消息状态为成功,且不是正在生成中,且TTS已启用,且语音通话窗口已打开
|
||||
if (isLastMessage && isAssistantMessage && message.status === 'success' && !generating && ttsEnabled && isVoiceCallActive) {
|
||||
// 检查是否需要跳过自动TTS
|
||||
if (skipNextAutoTTS) {
|
||||
console.log('跳过自动TTS,因为 skipNextAutoTTS 为 true')
|
||||
// 重置 skipNextAutoTTS 状态,只跳过一次
|
||||
dispatch(setSkipNextAutoTTS(false))
|
||||
return
|
||||
}
|
||||
|
||||
// 检查消息是否有内容,且消息是新的(不是上次播放过的消息)
|
||||
if (message.content && message.content.trim() && message.id !== lastPlayedMessageId) {
|
||||
console.log('自动播放最新助手消息的TTS:', message.id, '语音通话窗口状态:', isVoiceCallActive)
|
||||
|
||||
// 更新最后播放的消息ID
|
||||
dispatch(setLastPlayedMessageId(message.id))
|
||||
|
||||
// 使用延时确保消息已完全加载
|
||||
setTimeout(() => {
|
||||
TTSService.speakFromMessage(message)
|
||||
}, 500)
|
||||
} else if (message.id === lastPlayedMessageId) {
|
||||
console.log('不自动播放TTS,因为该消息已经播放过:', message.id)
|
||||
}
|
||||
} else if (isLastMessage && isAssistantMessage && message.status === 'success' && !generating && ttsEnabled && !isVoiceCallActive) {
|
||||
// 如果语音通话窗口没有打开,则不自动播放TTS
|
||||
console.log('不自动播放TTS,因为语音通话窗口没有打开')
|
||||
}
|
||||
}, [isLastMessage, isAssistantMessage, message, generating, ttsEnabled])
|
||||
}, [isLastMessage, isAssistantMessage, message, generating, ttsEnabled, isVoiceCallActive, lastPlayedMessageId, skipNextAutoTTS, dispatch])
|
||||
|
||||
const messageHighlightHandler = useCallback((highlight: boolean = true) => {
|
||||
if (messageContainerRef.current) {
|
||||
|
||||
@ -942,6 +942,14 @@ const TTSSettings: FC = () => {
|
||||
/>{' '}
|
||||
{t('settings.tts.filter.html_tags')}
|
||||
</FilterOptionItem>
|
||||
<FilterOptionItem>
|
||||
<Switch
|
||||
checked={ttsFilterOptions.filterEmojis}
|
||||
onChange={(checked) => dispatch(setTtsFilterOptions({ filterEmojis: checked }))}
|
||||
disabled={!ttsEnabled}
|
||||
/>{' '}
|
||||
{t('settings.tts.filter.emojis')}
|
||||
</FilterOptionItem>
|
||||
<FilterOptionItem>
|
||||
<LengthLabel>{t('settings.tts.max_text_length')}:</LengthLabel>
|
||||
<Select
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { InfoCircleOutlined, PhoneOutlined } from '@ant-design/icons'
|
||||
import { getModelLogo } from '@renderer/config/models'
|
||||
import SelectModelPopup from '@renderer/components/Popups/SelectModelPopup'
|
||||
import { getModelLogo } from '@renderer/config/models'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import { setVoiceCallEnabled, setVoiceCallModel } from '@renderer/store/settings'
|
||||
import { Button, Form, Space, Switch, Tooltip as AntTooltip } from 'antd'
|
||||
@ -55,29 +55,25 @@ const VoiceCallSettings: FC = () => {
|
||||
<Button
|
||||
onClick={handleSelectModel}
|
||||
disabled={!voiceCallEnabled}
|
||||
icon={voiceCallModel ?
|
||||
<ModelIcon src={getModelLogo(voiceCallModel.id)} alt="Model logo" /> :
|
||||
<PhoneOutlined style={{ marginRight: 8 }} />
|
||||
}
|
||||
>
|
||||
icon={
|
||||
voiceCallModel ? (
|
||||
<ModelIcon src={getModelLogo(voiceCallModel.id)} alt="Model logo" />
|
||||
) : (
|
||||
<PhoneOutlined style={{ marginRight: 8 }} />
|
||||
)
|
||||
}>
|
||||
{voiceCallModel ? voiceCallModel.name : t('settings.voice_call.model.select')}
|
||||
</Button>
|
||||
{voiceCallModel && (
|
||||
<InfoText>
|
||||
{t('settings.voice_call.model.current', { model: voiceCallModel.name })}
|
||||
</InfoText>
|
||||
<InfoText>{t('settings.voice_call.model.current', { model: voiceCallModel.name })}</InfoText>
|
||||
)}
|
||||
</Space>
|
||||
<InfoText>
|
||||
{t('settings.voice_call.model.info')}
|
||||
</InfoText>
|
||||
<InfoText>{t('settings.voice_call.model.info')}</InfoText>
|
||||
</Form.Item>
|
||||
|
||||
{/* ASR 和 TTS 设置提示 */}
|
||||
<Form.Item>
|
||||
<Alert type="info">
|
||||
{t('settings.voice_call.asr_tts_info')}
|
||||
</Alert>
|
||||
<Alert type="info">{t('settings.voice_call.asr_tts_info')}</Alert>
|
||||
</Form.Item>
|
||||
|
||||
{/* 测试按钮 */}
|
||||
@ -86,8 +82,9 @@ const VoiceCallSettings: FC = () => {
|
||||
type="primary"
|
||||
icon={<PhoneOutlined />}
|
||||
disabled={!voiceCallEnabled}
|
||||
onClick={() => window.message.info({ content: t('settings.voice_call.test_info'), key: 'voice-call-test' })}
|
||||
>
|
||||
onClick={() =>
|
||||
window.message.info({ content: t('settings.voice_call.test_info'), key: 'voice-call-test' })
|
||||
}>
|
||||
{t('settings.voice_call.test')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
@ -100,8 +97,6 @@ const Container = styled.div`
|
||||
padding: 0 0 20px 0;
|
||||
`
|
||||
|
||||
|
||||
|
||||
const InfoText = styled.div`
|
||||
color: var(--color-text-3);
|
||||
font-size: 12px;
|
||||
@ -117,24 +112,31 @@ const ModelIcon = styled.img`
|
||||
const Alert = styled.div<{ type: 'info' | 'warning' | 'error' | 'success' }>`
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
background-color: ${props =>
|
||||
props.type === 'info' ? 'var(--color-info-bg)' :
|
||||
props.type === 'warning' ? 'var(--color-warning-bg)' :
|
||||
props.type === 'error' ? 'var(--color-error-bg)' :
|
||||
'var(--color-success-bg)'
|
||||
};
|
||||
border: 1px solid ${props =>
|
||||
props.type === 'info' ? 'var(--color-info-border)' :
|
||||
props.type === 'warning' ? 'var(--color-warning-border)' :
|
||||
props.type === 'error' ? 'var(--color-error-border)' :
|
||||
'var(--color-success-border)'
|
||||
};
|
||||
color: ${props =>
|
||||
props.type === 'info' ? 'var(--color-info-text)' :
|
||||
props.type === 'warning' ? 'var(--color-warning-text)' :
|
||||
props.type === 'error' ? 'var(--color-error-text)' :
|
||||
'var(--color-success-text)'
|
||||
};
|
||||
background-color: ${(props) =>
|
||||
props.type === 'info'
|
||||
? 'var(--color-info-bg)'
|
||||
: props.type === 'warning'
|
||||
? 'var(--color-warning-bg)'
|
||||
: props.type === 'error'
|
||||
? 'var(--color-error-bg)'
|
||||
: 'var(--color-success-bg)'};
|
||||
border: 1px solid
|
||||
${(props) =>
|
||||
props.type === 'info'
|
||||
? 'var(--color-info-border)'
|
||||
: props.type === 'warning'
|
||||
? 'var(--color-warning-border)'
|
||||
: props.type === 'error'
|
||||
? 'var(--color-error-border)'
|
||||
: 'var(--color-success-border)'};
|
||||
color: ${(props) =>
|
||||
props.type === 'info'
|
||||
? 'var(--color-info-text)'
|
||||
: props.type === 'warning'
|
||||
? 'var(--color-warning-text)'
|
||||
: props.type === 'error'
|
||||
? 'var(--color-error-text)'
|
||||
: 'var(--color-success-text)'};
|
||||
`
|
||||
|
||||
export default VoiceCallSettings
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { fetchChatCompletion } from '@renderer/services/ApiService'
|
||||
import ASRService from '@renderer/services/ASRService'
|
||||
import { getDefaultAssistant } from '@renderer/services/AssistantService'
|
||||
import { getAssistantMessage, getUserMessage } from '@renderer/services/MessagesService'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import { getAssistantMessage, getUserMessage } from '@renderer/services/MessagesService'
|
||||
import TTSService from '@renderer/services/TTSService'
|
||||
import store from '@renderer/store'
|
||||
// 导入类型
|
||||
@ -234,7 +234,7 @@ class VoiceCallServiceClass {
|
||||
}
|
||||
|
||||
// 等待一下,确保连接已建立
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
}
|
||||
|
||||
// 开始录音
|
||||
@ -473,6 +473,9 @@ class VoiceCallServiceClass {
|
||||
if (voiceCallModel) {
|
||||
// 如果有自定义模型,覆盖默认助手的模型
|
||||
assistant.model = voiceCallModel
|
||||
console.log('设置语音通话专用模型:', JSON.stringify(voiceCallModel))
|
||||
} else {
|
||||
console.log('没有设置语音通话专用模型,使用默认助手模型:', JSON.stringify(assistant.model))
|
||||
}
|
||||
|
||||
// 如果需要发送到聊天界面,触发事件
|
||||
@ -480,14 +483,46 @@ class VoiceCallServiceClass {
|
||||
console.log('将语音识别结果发送到聊天界面:', text)
|
||||
|
||||
try {
|
||||
// 获取语音通话专用模型
|
||||
const { voiceCallModel } = store.getState().settings
|
||||
|
||||
// 打印日志查看模型信息
|
||||
console.log('语音通话专用模型:', voiceCallModel ? JSON.stringify(voiceCallModel) : 'null')
|
||||
console.log('助手模型:', assistant.model ? JSON.stringify(assistant.model) : 'null')
|
||||
|
||||
// 准备要发送的模型
|
||||
const modelToUse = voiceCallModel || assistant.model
|
||||
|
||||
// 确保模型对象完整
|
||||
if (modelToUse && typeof modelToUse === 'object') {
|
||||
console.log('使用完整模型对象:', modelToUse.name || modelToUse.id)
|
||||
} else {
|
||||
console.error('模型对象不完整或不存在')
|
||||
}
|
||||
|
||||
// 直接触发事件,将语音识别结果发送到聊天界面
|
||||
EventEmitter.emit(EVENT_NAMES.VOICE_CALL_MESSAGE, {
|
||||
// 优先使用语音通话专用模型,而不是助手模型
|
||||
const eventData = {
|
||||
text,
|
||||
model: assistant.model
|
||||
})
|
||||
model: modelToUse,
|
||||
isVoiceCall: true, // 标记这是语音通话消息
|
||||
useVoiceCallModel: true, // 明确标记使用语音通话模型
|
||||
voiceCallModelId: voiceCallModel?.id // 传递语音通话模型ID
|
||||
}
|
||||
|
||||
// 打印完整的事件数据
|
||||
console.log('发送语音通话消息事件数据:', JSON.stringify(eventData))
|
||||
|
||||
// 发送事件
|
||||
EventEmitter.emit(EVENT_NAMES.VOICE_CALL_MESSAGE, eventData)
|
||||
|
||||
// 打印日志确认事件已触发
|
||||
console.log('事件已触发,消息内容:', text, '模型:', assistant.model)
|
||||
console.log(
|
||||
'事件已触发,消息内容:',
|
||||
text,
|
||||
'模型:',
|
||||
voiceCallModel ? voiceCallModel.name : assistant.model?.name
|
||||
)
|
||||
|
||||
// 使用消息通知用户
|
||||
window.message.success({ content: '语音识别已完成,正在发送消息...', key: 'voice-call-send' })
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import i18n from '@renderer/i18n'
|
||||
import store from '@renderer/store'
|
||||
import { setLastPlayedMessageId } from '@renderer/store/settings'
|
||||
import { Message } from '@renderer/types'
|
||||
|
||||
import { TTSServiceFactory } from './TTSServiceFactory'
|
||||
@ -68,6 +69,11 @@ export class TTSService {
|
||||
maxTextLength: 4000
|
||||
}
|
||||
|
||||
// 更新最后播放的消息ID
|
||||
const dispatch = store.dispatch
|
||||
dispatch(setLastPlayedMessageId(message.id))
|
||||
console.log('更新最后播放的消息ID:', message.id)
|
||||
|
||||
// 应用过滤
|
||||
const filteredText = TTSTextFilter.filterText(message.content, ttsFilterOptions)
|
||||
console.log('TTS过滤前文本长度:', message.content.length, '过滤后:', filteredText.length)
|
||||
|
||||
@ -16,6 +16,7 @@ export class TTSTextFilter {
|
||||
filterMarkdown: boolean
|
||||
filterCodeBlocks: boolean
|
||||
filterHtmlTags: boolean
|
||||
filterEmojis: boolean
|
||||
maxTextLength: number
|
||||
}
|
||||
): string {
|
||||
@ -43,6 +44,11 @@ export class TTSTextFilter {
|
||||
filteredText = this.filterHtmlTags(filteredText)
|
||||
}
|
||||
|
||||
// 过滤表情符号
|
||||
if (options.filterEmojis) {
|
||||
filteredText = this.filterEmojis(filteredText)
|
||||
}
|
||||
|
||||
// 限制文本长度
|
||||
if (options.maxTextLength > 0 && filteredText.length > options.maxTextLength) {
|
||||
filteredText = filteredText.substring(0, options.maxTextLength)
|
||||
@ -145,4 +151,23 @@ export class TTSTextFilter {
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
/**
|
||||
* 过滤表情符号
|
||||
* @param text 原始文本
|
||||
* @returns 过滤后的文本
|
||||
*/
|
||||
private static filterEmojis(text: string): string {
|
||||
// 过滤Unicode表情符号
|
||||
// 这个正则表达式匹配大多数Unicode表情符号
|
||||
text = text.replace(/[\u{1F300}-\u{1F5FF}\u{1F600}-\u{1F64F}\u{1F680}-\u{1F6FF}\u{1F700}-\u{1F77F}\u{1F780}-\u{1F7FF}\u{1F800}-\u{1F8FF}\u{1F900}-\u{1F9FF}\u{1FA00}-\u{1FA6F}\u{1FA70}-\u{1FAFF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/gu, '')
|
||||
|
||||
// 过滤符号表情,如 :) :( :D 等
|
||||
text = text.replace(/[:;][\-']?[\)\(DOPdop\|\\\/\{\}\[\]\*]+/g, '')
|
||||
|
||||
// 过滤文本表情,如 (smile) (sad) 等
|
||||
text = text.replace(/\([a-zA-Z]+\)/g, '')
|
||||
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
@ -137,6 +137,7 @@ export interface SettingsState {
|
||||
filterMarkdown: boolean // 过滤Markdown标记
|
||||
filterCodeBlocks: boolean // 过滤代码块
|
||||
filterHtmlTags: boolean // 过滤HTML标签
|
||||
filterEmojis: boolean // 过滤表情符号
|
||||
maxTextLength: number // 最大文本长度
|
||||
}
|
||||
// ASR配置(语音识别)
|
||||
@ -148,6 +149,9 @@ export interface SettingsState {
|
||||
// 语音通话配置
|
||||
voiceCallEnabled: boolean
|
||||
voiceCallModel: Model | null
|
||||
isVoiceCallActive: boolean // 语音通话窗口是否激活
|
||||
lastPlayedMessageId: string | null // 最后一次播放的消息ID
|
||||
skipNextAutoTTS: boolean // 是否跳过下一次自动TTS
|
||||
// Quick Panel Triggers
|
||||
enableQuickPanelTriggers: boolean
|
||||
// Export Menu Options
|
||||
@ -275,6 +279,7 @@ export const initialState: SettingsState = {
|
||||
filterMarkdown: true, // 默认过滤Markdown标记
|
||||
filterCodeBlocks: true, // 默认过滤代码块
|
||||
filterHtmlTags: true, // 默认过滤HTML标签
|
||||
filterEmojis: true, // 默认过滤表情符号
|
||||
maxTextLength: 4000 // 默认最大文本长度
|
||||
},
|
||||
// ASR配置(语音识别)
|
||||
@ -286,6 +291,9 @@ export const initialState: SettingsState = {
|
||||
// 语音通话配置
|
||||
voiceCallEnabled: true,
|
||||
voiceCallModel: null,
|
||||
isVoiceCallActive: false, // 语音通话窗口是否激活
|
||||
lastPlayedMessageId: null, // 最后一次播放的消息ID
|
||||
skipNextAutoTTS: false, // 是否跳过下一次自动TTS
|
||||
// Quick Panel Triggers
|
||||
enableQuickPanelTriggers: false,
|
||||
// Export Menu Options
|
||||
@ -684,6 +692,7 @@ const settingsSlice = createSlice({
|
||||
filterMarkdown?: boolean
|
||||
filterCodeBlocks?: boolean
|
||||
filterHtmlTags?: boolean
|
||||
filterEmojis?: boolean
|
||||
maxTextLength?: number
|
||||
}>
|
||||
) => {
|
||||
@ -714,6 +723,15 @@ const settingsSlice = createSlice({
|
||||
setVoiceCallModel: (state, action: PayloadAction<Model | null>) => {
|
||||
state.voiceCallModel = action.payload
|
||||
},
|
||||
setIsVoiceCallActive: (state, action: PayloadAction<boolean>) => {
|
||||
state.isVoiceCallActive = action.payload
|
||||
},
|
||||
setLastPlayedMessageId: (state, action: PayloadAction<string | null>) => {
|
||||
state.lastPlayedMessageId = action.payload
|
||||
},
|
||||
setSkipNextAutoTTS: (state, action: PayloadAction<boolean>) => {
|
||||
state.skipNextAutoTTS = action.payload
|
||||
},
|
||||
// Quick Panel Triggers action
|
||||
setEnableQuickPanelTriggers: (state, action: PayloadAction<boolean>) => {
|
||||
state.enableQuickPanelTriggers = action.payload
|
||||
@ -837,7 +855,10 @@ export const {
|
||||
setAsrApiUrl,
|
||||
setAsrModel,
|
||||
setVoiceCallEnabled,
|
||||
setVoiceCallModel
|
||||
setVoiceCallModel,
|
||||
setIsVoiceCallActive,
|
||||
setLastPlayedMessageId,
|
||||
setSkipNextAutoTTS
|
||||
} = settingsSlice.actions
|
||||
|
||||
export default settingsSlice.reducer
|
||||
|
||||
53
yarn.lock
53
yarn.lock
@ -3471,7 +3471,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/lodash@npm:^4.17.5":
|
||||
"@types/lodash@npm:^4.17.16":
|
||||
version: 4.17.16
|
||||
resolution: "@types/lodash@npm:4.17.16"
|
||||
checksum: 10c0/cf017901b8ab1d7aabc86d5189d9288f4f99f19a75caf020c0e2c77b8d4cead4db0d0b842d009b029339f92399f49f34377dd7c2721053388f251778b4c23534
|
||||
@ -3927,7 +3927,7 @@ __metadata:
|
||||
"@types/adm-zip": "npm:^0"
|
||||
"@types/diff": "npm:^7"
|
||||
"@types/fs-extra": "npm:^11"
|
||||
"@types/lodash": "npm:^4.17.5"
|
||||
"@types/lodash": "npm:^4.17.16"
|
||||
"@types/markdown-it": "npm:^14"
|
||||
"@types/md5": "npm:^2.3.5"
|
||||
"@types/node": "npm:^18.19.9"
|
||||
@ -3991,7 +3991,6 @@ __metadata:
|
||||
rc-virtual-list: "npm:^3.18.5"
|
||||
react: "npm:^19.0.0"
|
||||
react-dom: "npm:^19.0.0"
|
||||
react-draggable: "npm:^4.4.6"
|
||||
react-hotkeys-hook: "npm:^4.6.1"
|
||||
react-i18next: "npm:^14.1.2"
|
||||
react-infinite-scroll-component: "npm:^6.1.0"
|
||||
@ -5291,13 +5290,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"clsx@npm:^1.1.1":
|
||||
version: 1.2.1
|
||||
resolution: "clsx@npm:1.2.1"
|
||||
checksum: 10c0/34dead8bee24f5e96f6e7937d711978380647e936a22e76380290e35486afd8634966ce300fc4b74a32f3762c7d4c0303f442c3e259f4ce02374eb0c82834f27
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"code-point-at@npm:^1.0.0":
|
||||
version: 1.1.0
|
||||
resolution: "code-point-at@npm:1.1.0"
|
||||
@ -9519,7 +9511,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"js-tokens@npm:^3.0.0 || ^4.0.0, js-tokens@npm:^4.0.0":
|
||||
"js-tokens@npm:^4.0.0":
|
||||
version: 4.0.0
|
||||
resolution: "js-tokens@npm:4.0.0"
|
||||
checksum: 10c0/e248708d377aa058eacf2037b07ded847790e6de892bbad3dac0abba2e759cb9f121b00099a65195616badcb6eca8d14d975cb3e89eb1cfda644756402c8aeed
|
||||
@ -10194,17 +10186,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"loose-envify@npm:^1.4.0":
|
||||
version: 1.4.0
|
||||
resolution: "loose-envify@npm:1.4.0"
|
||||
dependencies:
|
||||
js-tokens: "npm:^3.0.0 || ^4.0.0"
|
||||
bin:
|
||||
loose-envify: cli.js
|
||||
checksum: 10c0/655d110220983c1a4b9c0c679a2e8016d4b67f6e9c7b5435ff5979ecdb20d0813f4dec0a08674fcbdd4846a3f07edbb50a36811fd37930b94aaa0d9daceb017e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"lop@npm:^0.4.1":
|
||||
version: 0.4.2
|
||||
resolution: "lop@npm:0.4.2"
|
||||
@ -12004,7 +11985,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"object-assign@npm:^4, object-assign@npm:^4.0.1, object-assign@npm:^4.1.0, object-assign@npm:^4.1.1":
|
||||
"object-assign@npm:^4, object-assign@npm:^4.0.1, object-assign@npm:^4.1.0":
|
||||
version: 4.1.1
|
||||
resolution: "object-assign@npm:4.1.1"
|
||||
checksum: 10c0/1f4df9945120325d041ccf7b86f31e8bcc14e73d29171e37a7903050e96b81323784ec59f93f102ec635bcf6fa8034ba3ea0a8c7e69fa202b87ae3b6cec5a414
|
||||
@ -13017,17 +12998,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"prop-types@npm:^15.8.1":
|
||||
version: 15.8.1
|
||||
resolution: "prop-types@npm:15.8.1"
|
||||
dependencies:
|
||||
loose-envify: "npm:^1.4.0"
|
||||
object-assign: "npm:^4.1.1"
|
||||
react-is: "npm:^16.13.1"
|
||||
checksum: 10c0/59ece7ca2fb9838031d73a48d4becb9a7cc1ed10e610517c7d8f19a1e02fa47f7c27d557d8a5702bec3cfeccddc853579832b43f449e54635803f277b1c78077
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"property-information@npm:^6.0.0":
|
||||
version: 6.5.0
|
||||
resolution: "property-information@npm:6.5.0"
|
||||
@ -13746,19 +13716,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-draggable@npm:^4.4.6":
|
||||
version: 4.4.6
|
||||
resolution: "react-draggable@npm:4.4.6"
|
||||
dependencies:
|
||||
clsx: "npm:^1.1.1"
|
||||
prop-types: "npm:^15.8.1"
|
||||
peerDependencies:
|
||||
react: ">= 16.3.0"
|
||||
react-dom: ">= 16.3.0"
|
||||
checksum: 10c0/1e8cf47414a8554caa68447e5f27749bc40e1eabb4806e2dadcb39ab081d263f517d6aaec5231677e6b425603037c7e3386d1549898f9ffcc98a86cabafb2b9a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-hotkeys-hook@npm:^4.6.1":
|
||||
version: 4.6.1
|
||||
resolution: "react-hotkeys-hook@npm:4.6.1"
|
||||
@ -13798,7 +13755,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-is@npm:^16.13.1, react-is@npm:^16.7.0":
|
||||
"react-is@npm:^16.7.0":
|
||||
version: 16.13.1
|
||||
resolution: "react-is@npm:16.13.1"
|
||||
checksum: 10c0/33977da7a5f1a287936a0c85639fec6ca74f4f15ef1e59a6bc20338fc73dc69555381e211f7a3529b8150a1f71e4225525b41b60b52965bda53ce7d47377ada1
|
||||
|
||||
Loading…
Reference in New Issue
Block a user