mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 07:16:51 +00:00
feat: 实现语音转文字并支持流式输出;
fix: 修复了语音解密失败的问题
This commit is contained in:
63
src/components/AnimatedStreamingText.tsx
Normal file
63
src/components/AnimatedStreamingText.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import React, { memo, useEffect, useState, useRef } from 'react'
|
||||
|
||||
interface AnimatedStreamingTextProps {
|
||||
text: string
|
||||
className?: string
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export const AnimatedStreamingText = memo(({ text, className, loading }: AnimatedStreamingTextProps) => {
|
||||
const [displayedSegments, setDisplayedSegments] = useState<string[]>([])
|
||||
const prevTextRef = useRef('')
|
||||
|
||||
useEffect(() => {
|
||||
const currentText = (text || '').trim()
|
||||
const prevText = prevTextRef.current
|
||||
|
||||
if (currentText === prevText) return
|
||||
if (!currentText.startsWith(prevText) && prevText !== '') {
|
||||
// 如果不是追加而是全新的文本(比如重新识别),则重置
|
||||
setDisplayedSegments([currentText])
|
||||
prevTextRef.current = currentText
|
||||
return
|
||||
}
|
||||
|
||||
const newPart = currentText.slice(prevText.length)
|
||||
if (newPart) {
|
||||
// 将新部分作为单独的段加入,以触发动画
|
||||
setDisplayedSegments(prev => [...prev, newPart])
|
||||
}
|
||||
prevTextRef.current = currentText
|
||||
}, [text])
|
||||
|
||||
// 处理 loading 状态的显示
|
||||
if (loading && !text) {
|
||||
return <span className={className}>转写中<span className="dot-flashing">...</span></span>
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={className}>
|
||||
{displayedSegments.map((segment, index) => (
|
||||
<span key={index} className="fade-in-text">
|
||||
{segment}
|
||||
</span>
|
||||
))}
|
||||
<style>{`
|
||||
.fade-in-text {
|
||||
animation: fadeIn 0.5s ease-out forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(2px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.dot-flashing {
|
||||
animation: blink 1s infinite;
|
||||
}
|
||||
@keyframes blink { 50% { opacity: 0; } }
|
||||
`}</style>
|
||||
</span>
|
||||
)
|
||||
})
|
||||
|
||||
AnimatedStreamingText.displayName = 'AnimatedStreamingText'
|
||||
255
src/components/VoiceTranscribeDialog.scss
Normal file
255
src/components/VoiceTranscribeDialog.scss
Normal file
@@ -0,0 +1,255 @@
|
||||
.voice-transcribe-dialog-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.voice-transcribe-dialog {
|
||||
background: var(--color-bg-elevated);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
width: 90%;
|
||||
max-width: 480px;
|
||||
animation: slideUp 0.3s ease-out;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.close-button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
color: var(--color-text-secondary);
|
||||
border-radius: 6px;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-bg-hover);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.info-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 16px;
|
||||
|
||||
.info-icon {
|
||||
color: var(--color-primary);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.info-text {
|
||||
font-size: 15px;
|
||||
color: var(--color-text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.model-info {
|
||||
width: 100%;
|
||||
background: var(--color-bg);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
.model-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
|
||||
.label {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.value {
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.download-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px 0;
|
||||
|
||||
.download-icon {
|
||||
.downloading-icon {
|
||||
color: var(--color-primary);
|
||||
animation: bounce 1s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.download-text {
|
||||
font-size: 15px;
|
||||
color: var(--color-text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: var(--color-bg);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--color-primary), var(--color-accent));
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-size: 14px;
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
.complete-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px 0;
|
||||
|
||||
.complete-icon {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.complete-text {
|
||||
font-size: 15px;
|
||||
color: var(--color-text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.error-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
border-radius: 8px;
|
||||
color: #ef4444;
|
||||
font-size: 14px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
|
||||
button {
|
||||
flex: 1;
|
||||
padding: 12px 20px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
|
||||
&.btn-secondary {
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text-primary);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-primary {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
145
src/components/VoiceTranscribeDialog.tsx
Normal file
145
src/components/VoiceTranscribeDialog.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Download, X, CheckCircle, AlertCircle } from 'lucide-react'
|
||||
import './VoiceTranscribeDialog.scss'
|
||||
|
||||
interface VoiceTranscribeDialogProps {
|
||||
onClose: () => void
|
||||
onDownloadComplete: () => void
|
||||
}
|
||||
|
||||
export const VoiceTranscribeDialog: React.FC<VoiceTranscribeDialogProps> = ({
|
||||
onClose,
|
||||
onDownloadComplete
|
||||
}) => {
|
||||
const [isDownloading, setIsDownloading] = useState(false)
|
||||
const [downloadProgress, setDownloadProgress] = useState(0)
|
||||
const [downloadError, setDownloadError] = useState<string | null>(null)
|
||||
const [isComplete, setIsComplete] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// 监听下载进度
|
||||
const removeListener = window.electronAPI.whisper?.onDownloadProgress?.((payload) => {
|
||||
if (payload.percent !== undefined) {
|
||||
setDownloadProgress(payload.percent)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
removeListener?.()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleDownload = async () => {
|
||||
setIsDownloading(true)
|
||||
setDownloadError(null)
|
||||
setDownloadProgress(0)
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.whisper?.downloadModel()
|
||||
|
||||
if (result?.success) {
|
||||
setIsComplete(true)
|
||||
setDownloadProgress(100)
|
||||
|
||||
// 延迟关闭弹窗并触发转写
|
||||
setTimeout(() => {
|
||||
onDownloadComplete()
|
||||
}, 1000)
|
||||
} else {
|
||||
setDownloadError(result?.error || '下载失败')
|
||||
setIsDownloading(false)
|
||||
}
|
||||
} catch (error) {
|
||||
setDownloadError(String(error))
|
||||
setIsDownloading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
if (!isDownloading) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="voice-transcribe-dialog-overlay" onClick={handleCancel}>
|
||||
<div className="voice-transcribe-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="dialog-header">
|
||||
<h3>语音转文字</h3>
|
||||
{!isDownloading && (
|
||||
<button className="close-button" onClick={onClose}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="dialog-content">
|
||||
{!isDownloading && !isComplete && (
|
||||
<>
|
||||
<div className="info-section">
|
||||
<AlertCircle size={48} className="info-icon" />
|
||||
<p className="info-text">
|
||||
首次使用语音转文字功能需要下载 AI 模型
|
||||
</p>
|
||||
<div className="model-info">
|
||||
<div className="model-item">
|
||||
<span className="label">模型名称:</span>
|
||||
<span className="value">SenseVoiceSmall</span>
|
||||
</div>
|
||||
<div className="model-item">
|
||||
<span className="label">文件大小:</span>
|
||||
<span className="value">约 240 MB</span>
|
||||
</div>
|
||||
<div className="model-item">
|
||||
<span className="label">支持语言:</span>
|
||||
<span className="value">中文、英文、日文、韩文</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{downloadError && (
|
||||
<div className="error-message">
|
||||
<AlertCircle size={16} />
|
||||
<span>{downloadError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="dialog-actions">
|
||||
<button className="btn-secondary" onClick={onClose}>
|
||||
取消
|
||||
</button>
|
||||
<button className="btn-primary" onClick={handleDownload}>
|
||||
<Download size={16} />
|
||||
<span>立即下载</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isDownloading && !isComplete && (
|
||||
<div className="download-section">
|
||||
<div className="download-icon">
|
||||
<Download size={48} className="downloading-icon" />
|
||||
</div>
|
||||
<p className="download-text">正在下载模型...</p>
|
||||
<div className="progress-bar">
|
||||
<div
|
||||
className="progress-fill"
|
||||
style={{ width: `${downloadProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="progress-text">{downloadProgress.toFixed(1)}%</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isComplete && (
|
||||
<div className="complete-section">
|
||||
<CheckCircle size={48} className="complete-icon" />
|
||||
<p className="complete-text">下载完成!正在转写语音...</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1882,3 +1882,31 @@
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
/* 语音转文字按钮样式 */
|
||||
.voice-transcribe-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
margin-left: 8px;
|
||||
border: none;
|
||||
background: var(--primary-light);
|
||||
border-radius: 50%;
|
||||
color: var(--primary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,21 @@ import { useChatStore } from '../stores/chatStore'
|
||||
import type { ChatSession, Message } from '../types/models'
|
||||
import { getEmojiPath } from 'wechat-emojis'
|
||||
import { ImagePreview } from '../components/ImagePreview'
|
||||
import { VoiceTranscribeDialog } from '../components/VoiceTranscribeDialog'
|
||||
import { AnimatedStreamingText } from '../components/AnimatedStreamingText'
|
||||
import './ChatPage.scss'
|
||||
|
||||
// 系统消息类型常量
|
||||
const SYSTEM_MESSAGE_TYPES = [
|
||||
10000, // 系统消息
|
||||
266287972401, // 拍一拍
|
||||
]
|
||||
|
||||
// 判断是否为系统消息
|
||||
function isSystemMessage(localType: number): boolean {
|
||||
return SYSTEM_MESSAGE_TYPES.includes(localType)
|
||||
}
|
||||
|
||||
interface ChatPageProps {
|
||||
// 保留接口以备将来扩展
|
||||
}
|
||||
@@ -138,6 +151,8 @@ function ChatPage(_props: ChatPageProps) {
|
||||
const [highlightedMessageKeys, setHighlightedMessageKeys] = useState<string[]>([])
|
||||
const [isRefreshingSessions, setIsRefreshingSessions] = useState(false)
|
||||
const [hasInitialMessages, setHasInitialMessages] = useState(false)
|
||||
const [showVoiceTranscribeDialog, setShowVoiceTranscribeDialog] = useState(false)
|
||||
const [pendingVoiceTranscriptRequest, setPendingVoiceTranscriptRequest] = useState<{ sessionId: string; messageId: string } | null>(null)
|
||||
|
||||
// 联系人信息加载控制
|
||||
const isEnrichingRef = useRef(false)
|
||||
@@ -1128,10 +1143,10 @@ function ChatPage(_props: ChatPageProps) {
|
||||
const prevMsg = index > 0 ? messages[index - 1] : undefined
|
||||
const showDateDivider = shouldShowDateDivider(msg, prevMsg)
|
||||
|
||||
// 显示时间:第一条消息,或者与上一条消息间隔超过5分钟
|
||||
// 显示时间:第一条消息,或者与上一条消息间隔超过5分钟
|
||||
const showTime = !prevMsg || (msg.createTime - prevMsg.createTime > 300)
|
||||
const isSent = msg.isSend === 1
|
||||
const isSystem = msg.localType === 10000
|
||||
const isSystem = isSystemMessage(msg.localType)
|
||||
|
||||
// 系统消息居中显示
|
||||
const wrapperClass = isSystem ? 'system' : (isSent ? 'sent' : 'received')
|
||||
@@ -1272,6 +1287,35 @@ function ChatPage(_props: ChatPageProps) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 语音转文字模型下载弹窗 */}
|
||||
{showVoiceTranscribeDialog && (
|
||||
<VoiceTranscribeDialog
|
||||
onClose={() => {
|
||||
setShowVoiceTranscribeDialog(false)
|
||||
setPendingVoiceTranscriptRequest(null)
|
||||
}}
|
||||
onDownloadComplete={async () => {
|
||||
setShowVoiceTranscribeDialog(false)
|
||||
// 下载完成后,继续转写
|
||||
if (pendingVoiceTranscriptRequest) {
|
||||
try {
|
||||
const result = await window.electronAPI.chat.getVoiceTranscript(
|
||||
pendingVoiceTranscriptRequest.sessionId,
|
||||
pendingVoiceTranscriptRequest.messageId
|
||||
)
|
||||
if (result.success) {
|
||||
const cacheKey = `voice-transcript:${pendingVoiceTranscriptRequest.messageId}`
|
||||
voiceTranscriptCache.set(cacheKey, (result.transcript || '').trim())
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ChatPage] 语音转文字失败:', error)
|
||||
}
|
||||
}
|
||||
setPendingVoiceTranscriptRequest(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1292,7 +1336,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
|
||||
myAvatarUrl?: string;
|
||||
isGroupChat?: boolean;
|
||||
}) {
|
||||
const isSystem = message.localType === 10000
|
||||
const isSystem = isSystemMessage(message.localType)
|
||||
const isEmoji = message.localType === 47
|
||||
const isImage = message.localType === 3
|
||||
const isVoice = message.localType === 34
|
||||
@@ -1570,7 +1614,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
|
||||
if (!isImage) return
|
||||
if (imageLocalPath) return // 已有图片,不需要解密
|
||||
if (!message.imageMd5 && !message.imageDatName) return
|
||||
|
||||
|
||||
const container = imageContainerRef.current
|
||||
if (!container) return
|
||||
|
||||
@@ -1612,8 +1656,32 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
|
||||
}
|
||||
}, [isVoice])
|
||||
|
||||
// 监听流式转写结果
|
||||
useEffect(() => {
|
||||
if (!isVoice) return
|
||||
const removeListener = window.electronAPI.chat.onVoiceTranscriptPartial?.((payload: { msgId: string; text: string }) => {
|
||||
if (payload.msgId === String(message.localId)) {
|
||||
setVoiceTranscript(payload.text)
|
||||
voiceTranscriptCache.set(voiceTranscriptCacheKey, payload.text)
|
||||
}
|
||||
})
|
||||
return () => removeListener?.()
|
||||
}, [isVoice, message.localId, voiceTranscriptCacheKey])
|
||||
|
||||
const requestVoiceTranscript = useCallback(async () => {
|
||||
if (voiceTranscriptLoading || voiceTranscriptRequestedRef.current) return
|
||||
|
||||
// 检查模型状态
|
||||
const modelStatus = await window.electronAPI.whisper?.getModelStatus()
|
||||
if (!modelStatus?.exists) {
|
||||
// 模型未下载,抛出错误让外层处理
|
||||
const error: any = new Error('MODEL_NOT_DOWNLOADED')
|
||||
error.requiresDownload = true
|
||||
error.sessionId = session.username
|
||||
error.messageId = String(message.localId)
|
||||
throw error
|
||||
}
|
||||
|
||||
voiceTranscriptRequestedRef.current = true
|
||||
setVoiceTranscriptLoading(true)
|
||||
setVoiceTranscriptError(false)
|
||||
@@ -1627,7 +1695,13 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
|
||||
setVoiceTranscriptError(true)
|
||||
voiceTranscriptRequestedRef.current = false
|
||||
}
|
||||
} catch {
|
||||
} catch (error: any) {
|
||||
// 检查是否是模型未下载错误
|
||||
if (error?.requiresDownload) {
|
||||
// 不显示错误状态,等待用户手动点击转文字按钮时会触发下载弹窗
|
||||
voiceTranscriptRequestedRef.current = false
|
||||
return
|
||||
}
|
||||
setVoiceTranscriptError(true)
|
||||
voiceTranscriptRequestedRef.current = false
|
||||
} finally {
|
||||
@@ -1635,13 +1709,23 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
|
||||
}
|
||||
}, [message.localId, session.username, voiceTranscriptCacheKey, voiceTranscriptLoading])
|
||||
|
||||
// 根据设置决定是否自动转写
|
||||
const [autoTranscribeEnabled, setAutoTranscribeEnabled] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
window.electronAPI.config.get('autoTranscribeVoice').then((value) => {
|
||||
setAutoTranscribeEnabled(value === true)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoTranscribeEnabled) return
|
||||
if (!isVoice) return
|
||||
if (!voiceDataUrl) return
|
||||
if (voiceTranscriptError) return
|
||||
if (voiceTranscriptLoading || voiceTranscript !== undefined || voiceTranscriptRequestedRef.current) return
|
||||
void requestVoiceTranscript()
|
||||
}, [isVoice, voiceDataUrl, voiceTranscript, voiceTranscriptError, voiceTranscriptLoading, requestVoiceTranscript])
|
||||
}, [autoTranscribeEnabled, isVoice, voiceDataUrl, voiceTranscript, voiceTranscriptError, voiceTranscriptLoading, requestVoiceTranscript])
|
||||
|
||||
if (isSystem) {
|
||||
return (
|
||||
@@ -1771,7 +1855,12 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
|
||||
setVoiceLoading(true)
|
||||
setVoiceError(false)
|
||||
try {
|
||||
const result = await window.electronAPI.chat.getVoiceData(session.username, String(message.localId))
|
||||
const result = await window.electronAPI.chat.getVoiceData(
|
||||
session.username,
|
||||
String(message.localId),
|
||||
message.createTime,
|
||||
message.serverId
|
||||
)
|
||||
if (result.success && result.data) {
|
||||
const url = `data:audio/wav;base64,${result.data}`
|
||||
voiceDataUrlCache.set(voiceCacheKey, url)
|
||||
@@ -1842,6 +1931,22 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
|
||||
{showDecryptHint && <span className="voice-hint">点击解密</span>}
|
||||
{voiceError && <span className="voice-error">播放失败</span>}
|
||||
</div>
|
||||
{/* 转文字按钮 */}
|
||||
{voiceDataUrl && !voiceTranscript && !voiceTranscriptLoading && (
|
||||
<button
|
||||
className="voice-transcribe-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
void requestVoiceTranscript()
|
||||
}}
|
||||
title="转文字"
|
||||
type="button"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{showTranscript && (
|
||||
<div
|
||||
@@ -1849,7 +1954,16 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
|
||||
onClick={handleTranscriptRetry}
|
||||
title={voiceTranscriptError ? '点击重试语音转写' : undefined}
|
||||
>
|
||||
{transcriptDisplay}
|
||||
{voiceTranscriptError ? (
|
||||
'转写失败,点击重试'
|
||||
) : !voiceTranscript ? (
|
||||
voiceTranscriptLoading ? '转写中...' : '未识别到文字'
|
||||
) : (
|
||||
<AnimatedStreamingText
|
||||
text={transcriptText}
|
||||
loading={voiceTranscriptLoading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,7 @@ interface ExportOptions {
|
||||
exportImages: boolean
|
||||
exportVoices: boolean
|
||||
exportEmojis: boolean
|
||||
exportVoiceAsText: boolean
|
||||
}
|
||||
|
||||
interface ExportResult {
|
||||
@@ -54,7 +55,8 @@ function ExportPage() {
|
||||
exportMedia: false,
|
||||
exportImages: true,
|
||||
exportVoices: true,
|
||||
exportEmojis: true
|
||||
exportEmojis: true,
|
||||
exportVoiceAsText: false
|
||||
})
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
@@ -158,6 +160,7 @@ function ExportPage() {
|
||||
exportImages: options.exportMedia && options.exportImages,
|
||||
exportVoices: options.exportMedia && options.exportVoices,
|
||||
exportEmojis: options.exportMedia && options.exportEmojis,
|
||||
exportVoiceAsText: options.exportMedia && options.exportVoiceAsText,
|
||||
dateRange: options.useAllTime ? null : options.dateRange ? {
|
||||
start: Math.floor(options.dateRange.start.getTime() / 1000),
|
||||
// 将结束日期设置为当天的 23:59:59,以包含当天的所有消息
|
||||
@@ -372,9 +375,9 @@ function ExportPage() {
|
||||
<span className="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="media-option-divider"></div>
|
||||
|
||||
|
||||
<label className={`media-checkbox-row ${!options.exportMedia ? 'disabled' : ''}`}>
|
||||
<div className="media-checkbox-info">
|
||||
<span className="media-checkbox-title">图片</span>
|
||||
@@ -387,9 +390,9 @@ function ExportPage() {
|
||||
onChange={e => setOptions({ ...options, exportImages: e.target.checked })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
|
||||
<div className="media-option-divider"></div>
|
||||
|
||||
|
||||
<label className={`media-checkbox-row ${!options.exportMedia ? 'disabled' : ''}`}>
|
||||
<div className="media-checkbox-info">
|
||||
<span className="media-checkbox-title">语音</span>
|
||||
@@ -402,9 +405,24 @@ function ExportPage() {
|
||||
onChange={e => setOptions({ ...options, exportVoices: e.target.checked })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
|
||||
<div className="media-option-divider"></div>
|
||||
|
||||
|
||||
<label className={`media-checkbox-row ${!options.exportMedia ? 'disabled' : ''}`}>
|
||||
<div className="media-checkbox-info">
|
||||
<span className="media-checkbox-title">语音转文字</span>
|
||||
<span className="media-checkbox-desc">将语音消息转换为文字导出</span>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={options.exportVoiceAsText}
|
||||
disabled={!options.exportMedia}
|
||||
onChange={e => setOptions({ ...options, exportVoiceAsText: e.target.checked })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="media-option-divider"></div>
|
||||
|
||||
<label className={`media-checkbox-row ${!options.exportMedia ? 'disabled' : ''}`}>
|
||||
<div className="media-checkbox-info">
|
||||
<span className="media-checkbox-title">表情</span>
|
||||
|
||||
@@ -13,19 +13,6 @@ import './SettingsPage.scss'
|
||||
|
||||
type SettingsTab = 'appearance' | 'database' | 'whisper' | 'cache' | 'about'
|
||||
|
||||
const whisperModels = [
|
||||
{ value: 'tiny', label: 'tiny (75 MB)' },
|
||||
{ value: 'base', label: 'base (142 MB)' },
|
||||
{ value: 'small', label: 'small (466 MB)' },
|
||||
{ value: 'medium', label: 'medium (1.5 GB)' },
|
||||
{ value: 'large-v3', label: 'large-v3 (2.9 GB)' }
|
||||
]
|
||||
|
||||
const whisperSources = [
|
||||
{ value: 'official', label: 'HuggingFace 官方' },
|
||||
{ value: 'tsinghua', label: '清华镜像 (hf-mirror)' }
|
||||
]
|
||||
|
||||
const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
|
||||
{ id: 'appearance', label: '外观', icon: Palette },
|
||||
{ id: 'database', label: '数据库连接', icon: Database },
|
||||
@@ -57,10 +44,10 @@ function SettingsPage() {
|
||||
const [logEnabled, setLogEnabled] = useState(false)
|
||||
const [whisperModelName, setWhisperModelName] = useState('base')
|
||||
const [whisperModelDir, setWhisperModelDir] = useState('')
|
||||
const [whisperDownloadSource, setWhisperDownloadSource] = useState('tsinghua')
|
||||
const [isWhisperDownloading, setIsWhisperDownloading] = useState(false)
|
||||
const [whisperDownloadProgress, setWhisperDownloadProgress] = useState(0)
|
||||
const [whisperModelStatus, setWhisperModelStatus] = useState<{ exists: boolean; path?: string } | null>(null)
|
||||
const [whisperModelStatus, setWhisperModelStatus] = useState<{ exists: boolean; modelPath?: string; tokensPath?: string } | null>(null)
|
||||
const [autoTranscribeVoice, setAutoTranscribeVoice] = useState(false)
|
||||
|
||||
const [isLoading, setIsLoadingState] = useState(false)
|
||||
const [isTesting, setIsTesting] = useState(false)
|
||||
@@ -124,7 +111,7 @@ function SettingsPage() {
|
||||
const savedImageAesKey = await configService.getImageAesKey()
|
||||
const savedWhisperModelName = await configService.getWhisperModelName()
|
||||
const savedWhisperModelDir = await configService.getWhisperModelDir()
|
||||
const savedWhisperSource = await configService.getWhisperDownloadSource()
|
||||
const savedAutoTranscribe = await configService.getAutoTranscribeVoice()
|
||||
|
||||
if (savedKey) setDecryptKey(savedKey)
|
||||
if (savedPath) setDbPath(savedPath)
|
||||
@@ -135,9 +122,8 @@ function SettingsPage() {
|
||||
}
|
||||
if (savedImageAesKey) setImageAesKey(savedImageAesKey)
|
||||
setLogEnabled(savedLogEnabled)
|
||||
if (savedWhisperModelName) setWhisperModelName(savedWhisperModelName)
|
||||
setAutoTranscribeVoice(savedAutoTranscribe)
|
||||
if (savedWhisperModelDir) setWhisperModelDir(savedWhisperModelDir)
|
||||
if (savedWhisperSource) setWhisperDownloadSource(savedWhisperSource)
|
||||
} catch (e) {
|
||||
console.error('加载配置失败:', e)
|
||||
}
|
||||
@@ -145,14 +131,15 @@ function SettingsPage() {
|
||||
|
||||
|
||||
|
||||
const refreshWhisperStatus = async (modelNameValue = whisperModelName, modelDirValue = whisperModelDir) => {
|
||||
const refreshWhisperStatus = async (modelDirValue = whisperModelDir) => {
|
||||
try {
|
||||
const result = await window.electronAPI.whisper?.getModelStatus({
|
||||
modelName: modelNameValue,
|
||||
downloadDir: modelDirValue || undefined
|
||||
})
|
||||
const result = await window.electronAPI.whisper?.getModelStatus()
|
||||
if (result?.success) {
|
||||
setWhisperModelStatus({ exists: Boolean(result.exists), path: result.path })
|
||||
setWhisperModelStatus({
|
||||
exists: Boolean(result.exists),
|
||||
modelPath: result.modelPath,
|
||||
tokensPath: result.tokensPath
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
setWhisperModelStatus(null)
|
||||
@@ -178,17 +165,16 @@ function SettingsPage() {
|
||||
|
||||
useEffect(() => {
|
||||
const removeListener = window.electronAPI.whisper?.onDownloadProgress?.((payload) => {
|
||||
if (payload.modelName !== whisperModelName) return
|
||||
if (typeof payload.percent === 'number') {
|
||||
setWhisperDownloadProgress(payload.percent)
|
||||
}
|
||||
})
|
||||
return () => removeListener?.()
|
||||
}, [whisperModelName])
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void refreshWhisperStatus(whisperModelName, whisperModelDir)
|
||||
}, [whisperModelName, whisperModelDir])
|
||||
void refreshWhisperStatus(whisperModelDir)
|
||||
}, [whisperModelDir])
|
||||
|
||||
const handleCheckUpdate = async () => {
|
||||
setIsCheckingUpdate(true)
|
||||
@@ -331,30 +317,21 @@ function SettingsPage() {
|
||||
await configService.setWhisperModelName(value)
|
||||
}
|
||||
|
||||
const handleWhisperSourceChange = async (value: string) => {
|
||||
setWhisperDownloadSource(value)
|
||||
await configService.setWhisperDownloadSource(value)
|
||||
}
|
||||
|
||||
const handleDownloadWhisperModel = async () => {
|
||||
if (isWhisperDownloading) return
|
||||
setIsWhisperDownloading(true)
|
||||
setWhisperDownloadProgress(0)
|
||||
try {
|
||||
const result = await window.electronAPI.whisper.downloadModel({
|
||||
modelName: whisperModelName,
|
||||
downloadDir: whisperModelDir || undefined,
|
||||
source: whisperDownloadSource
|
||||
})
|
||||
const result = await window.electronAPI.whisper.downloadModel()
|
||||
if (result.success) {
|
||||
setWhisperDownloadProgress(100)
|
||||
showMessage('Whisper 模型下载完成', true)
|
||||
await refreshWhisperStatus(whisperModelName, whisperModelDir)
|
||||
showMessage('SenseVoiceSmall 模型下载完成', true)
|
||||
await refreshWhisperStatus(whisperModelDir)
|
||||
} else {
|
||||
showMessage(result.error || 'Whisper 模型下载失败', false)
|
||||
showMessage(result.error || '模型下载失败', false)
|
||||
}
|
||||
} catch (e) {
|
||||
showMessage(`Whisper 模型下载失败: ${e}`, false)
|
||||
showMessage(`模型下载失败: ${e}`, false)
|
||||
} finally {
|
||||
setIsWhisperDownloading(false)
|
||||
}
|
||||
@@ -475,9 +452,8 @@ function SettingsPage() {
|
||||
} else {
|
||||
await configService.setImageAesKey('')
|
||||
}
|
||||
await configService.setWhisperModelName(whisperModelName)
|
||||
await configService.setWhisperModelDir(whisperModelDir)
|
||||
await configService.setWhisperDownloadSource(whisperDownloadSource)
|
||||
await configService.setAutoTranscribeVoice(autoTranscribeVoice)
|
||||
await configService.setOnboardingDone(true)
|
||||
|
||||
showMessage('配置保存成功,正在测试连接...', true)
|
||||
@@ -513,9 +489,8 @@ function SettingsPage() {
|
||||
setWxid('')
|
||||
setCachePath('')
|
||||
setLogEnabled(false)
|
||||
setWhisperModelName('base')
|
||||
setAutoTranscribeVoice(false)
|
||||
setWhisperModelDir('')
|
||||
setWhisperDownloadSource('tsinghua')
|
||||
setWhisperModelStatus(null)
|
||||
setWhisperDownloadProgress(0)
|
||||
setIsWhisperDownloading(false)
|
||||
@@ -674,14 +649,14 @@ function SettingsPage() {
|
||||
<label>账号 wxid</label>
|
||||
<span className="form-hint">微信账号标识</span>
|
||||
<div className="wxid-input-wrapper" ref={wxidDropdownRef}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="例如: wxid_xxxxxx"
|
||||
value={wxid}
|
||||
onChange={(e) => setWxid(e.target.value)}
|
||||
<input
|
||||
type="text"
|
||||
placeholder="例如: wxid_xxxxxx"
|
||||
value={wxid}
|
||||
onChange={(e) => setWxid(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
<button
|
||||
type="button"
|
||||
className={`wxid-dropdown-btn ${showWxidSelect ? 'open' : ''}`}
|
||||
onClick={() => wxidOptions.length > 0 ? setShowWxidSelect(!showWxidSelect) : handleScanWxid()}
|
||||
title={wxidOptions.length > 0 ? "选择已检测到的账号" : "扫描账号"}
|
||||
@@ -691,8 +666,8 @@ function SettingsPage() {
|
||||
{showWxidSelect && wxidOptions.length > 0 && (
|
||||
<div className="wxid-dropdown">
|
||||
{wxidOptions.map((opt) => (
|
||||
<div
|
||||
key={opt.wxid}
|
||||
<div
|
||||
key={opt.wxid}
|
||||
className={`wxid-option ${opt.wxid === wxid ? 'active' : ''}`}
|
||||
onClick={() => handleSelectWxid(opt.wxid)}
|
||||
>
|
||||
@@ -759,34 +734,31 @@ function SettingsPage() {
|
||||
)
|
||||
const renderWhisperTab = () => (
|
||||
<div className="tab-content">
|
||||
<p className="section-desc">语音解密后自动转写为文字</p>
|
||||
<div className="form-group whisper-section">
|
||||
<label>语音识别模型 (Whisper)</label>
|
||||
<span className="form-hint">语音解密后自动转文字,模型越大越准确但下载更慢</span>
|
||||
<div className="whisper-grid">
|
||||
<div className="whisper-field">
|
||||
<span className="field-label">模型</span>
|
||||
<select
|
||||
value={whisperModelName}
|
||||
onChange={(e) => handleWhisperModelChange(e.target.value)}
|
||||
>
|
||||
{whisperModels.map((model) => (
|
||||
<option key={model.value} value={model.value}>{model.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="whisper-field">
|
||||
<span className="field-label">下载源</span>
|
||||
<select
|
||||
value={whisperDownloadSource}
|
||||
onChange={(e) => handleWhisperSourceChange(e.target.value)}
|
||||
>
|
||||
{whisperSources.map((source) => (
|
||||
<option key={source.value} value={source.value}>{source.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>自动语音转文字</label>
|
||||
<span className="form-hint">语音解密后自动转写为文字(需下载模型)</span>
|
||||
<div className="log-toggle-line">
|
||||
<span className="log-status">{autoTranscribeVoice ? '已开启' : '已关闭'}</span>
|
||||
<label className="switch" htmlFor="auto-transcribe-toggle">
|
||||
<input
|
||||
id="auto-transcribe-toggle"
|
||||
className="switch-input"
|
||||
type="checkbox"
|
||||
checked={autoTranscribeVoice}
|
||||
onChange={async (e) => {
|
||||
const enabled = e.target.checked
|
||||
setAutoTranscribeVoice(enabled)
|
||||
await configService.setAutoTranscribeVoice(enabled)
|
||||
showMessage(enabled ? '已开启自动转文字' : '已关闭自动转文字', true)
|
||||
}}
|
||||
/>
|
||||
<span className="switch-slider" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group whisper-section">
|
||||
<label>语音识别模型 (SenseVoiceSmall)</label>
|
||||
<span className="form-hint">基于 Sherpa-onnx,支持中文、英文、日文、韩文</span>
|
||||
<span className="form-hint">模型下载目录</span>
|
||||
<input
|
||||
type="text"
|
||||
@@ -801,9 +773,9 @@ function SettingsPage() {
|
||||
</div>
|
||||
<div className="whisper-status-line">
|
||||
<span className={`status ${whisperModelStatus?.exists ? 'ok' : 'warn'}`}>
|
||||
{whisperModelStatus?.exists ? '已下载' : '未下载'}
|
||||
{whisperModelStatus?.exists ? '已下载 (240 MB)' : '未下载 (240 MB)'}
|
||||
</span>
|
||||
{whisperModelStatus?.path && <span className="path">{whisperModelStatus.path}</span>}
|
||||
{whisperModelStatus?.modelPath && <span className="path">{whisperModelStatus.modelPath}</span>}
|
||||
</div>
|
||||
{isWhisperDownloading ? (
|
||||
<div className="whisper-progress">
|
||||
@@ -917,8 +889,8 @@ function SettingsPage() {
|
||||
</div>
|
||||
<div className="wxid-dialog-list">
|
||||
{wxidOptions.map((opt) => (
|
||||
<div
|
||||
key={opt.wxid}
|
||||
<div
|
||||
key={opt.wxid}
|
||||
className={`wxid-dialog-item ${opt.wxid === wxid ? 'active' : ''}`}
|
||||
onClick={() => handleSelectWxid(opt.wxid)}
|
||||
>
|
||||
|
||||
@@ -20,7 +20,8 @@ export const CONFIG_KEYS = {
|
||||
IMAGE_AES_KEY: 'imageAesKey',
|
||||
WHISPER_MODEL_NAME: 'whisperModelName',
|
||||
WHISPER_MODEL_DIR: 'whisperModelDir',
|
||||
WHISPER_DOWNLOAD_SOURCE: 'whisperDownloadSource'
|
||||
WHISPER_DOWNLOAD_SOURCE: 'whisperDownloadSource',
|
||||
AUTO_TRANSCRIBE_VOICE: 'autoTranscribeVoice'
|
||||
} as const
|
||||
|
||||
// 获取解密密钥
|
||||
@@ -218,3 +219,14 @@ export async function getOnboardingDone(): Promise<boolean> {
|
||||
export async function setOnboardingDone(done: boolean): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.ONBOARDING_DONE, done)
|
||||
}
|
||||
|
||||
// 获取自动语音转文字开关
|
||||
export async function getAutoTranscribeVoice(): Promise<boolean> {
|
||||
const value = await config.get(CONFIG_KEYS.AUTO_TRANSCRIBE_VOICE)
|
||||
return value === true
|
||||
}
|
||||
|
||||
// 设置自动语音转文字开关
|
||||
export async function setAutoTranscribeVoice(enabled: boolean): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.AUTO_TRANSCRIBE_VOICE, enabled)
|
||||
}
|
||||
|
||||
7
src/types/electron.d.ts
vendored
7
src/types/electron.d.ts
vendored
@@ -94,8 +94,9 @@ export interface ElectronAPI {
|
||||
error?: string
|
||||
}>
|
||||
getImageData: (sessionId: string, msgId: string) => Promise<{ success: boolean; data?: string; error?: string }>
|
||||
getVoiceData: (sessionId: string, msgId: string) => Promise<{ success: boolean; data?: string; error?: string }>
|
||||
getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) => Promise<{ success: boolean; data?: string; error?: string }>
|
||||
getVoiceTranscript: (sessionId: string, msgId: string) => Promise<{ success: boolean; transcript?: string; error?: string }>
|
||||
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => () => void
|
||||
}
|
||||
|
||||
image: {
|
||||
@@ -297,8 +298,8 @@ export interface ElectronAPI {
|
||||
}>
|
||||
}
|
||||
whisper: {
|
||||
downloadModel: (payload: { modelName: string; downloadDir?: string; source?: string }) => Promise<{ success: boolean; path?: string; error?: string }>
|
||||
getModelStatus: (payload: { modelName: string; downloadDir?: string }) => Promise<{ success: boolean; exists?: boolean; path?: string; sizeBytes?: number; error?: string }>
|
||||
downloadModel: () => Promise<{ success: boolean; modelPath?: string; tokensPath?: string; error?: string }>
|
||||
getModelStatus: () => Promise<{ success: boolean; exists?: boolean; modelPath?: string; tokensPath?: string; sizeBytes?: number; error?: string }>
|
||||
onDownloadProgress: (callback: (payload: { modelName: string; downloadedBytes: number; totalBytes?: number; percent?: number }) => void) => () => void
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user