feat: 实现语音转文字并支持流式输出;

fix: 修复了语音解密失败的问题
This commit is contained in:
cc
2026-01-17 14:16:54 +08:00
parent 650de55202
commit e8babd48b6
33 changed files with 1713 additions and 570 deletions

View File

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

View File

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

View File

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

View File

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