This commit is contained in:
1600822305 2025-04-11 19:03:02 +08:00
parent be67580230
commit 8e56f8774f
20 changed files with 884 additions and 308 deletions

View File

@ -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",

View File

@ -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'

View File

@ -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

View File

@ -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}
/>

View File

@ -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

View File

@ -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.",

View File

@ -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": "音声認識",

View File

@ -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]:语音功能",

View File

@ -1427,6 +1427,7 @@
"filter.markdown": "过滤Markdown标记",
"filter.code_blocks": "过滤代码块",
"filter.html_tags": "过滤HTML标签",
"filter.emojis": "过滤表情符号",
"max_text_length": "最大文本长度",
"test": "测试语音",
"help": "语音合成功能支持将文本转换为自然语音。",

View File

@ -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]:语音功能",

View 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;

View File

@ -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, {})
);
// 清空输入框

View File

@ -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) {

View File

@ -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

View File

@ -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

View File

@ -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' })

View File

@ -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)

View File

@ -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
}
}

View File

@ -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

View File

@ -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