feat(voice-transcribe): 新增语音转写语言过滤配置功能(支持用户自定义允许的转写语言),优化模型下载的超时处理与进度日志,提升下载稳健性,同步更新相关 UI 样式。

This commit is contained in:
Forrest
2026-01-17 19:54:31 +08:00
parent dc12df0fcf
commit 0853e049c8
9 changed files with 338 additions and 66 deletions

View File

@@ -987,6 +987,11 @@ function ChatPage(_props: ChatPageProps) {
})
}
const handleRequireModelDownload = useCallback((sessionId: string, messageId: string) => {
setPendingVoiceTranscriptRequest({ sessionId, messageId })
setShowVoiceTranscribeDialog(true)
}, [])
return (
<div className={`chat-page ${isResizing ? 'resizing' : ''}`}>
{/* 左侧会话列表 */}
@@ -1166,6 +1171,7 @@ function ChatPage(_props: ChatPageProps) {
showTime={!showDateDivider && showTime}
myAvatarUrl={myAvatarUrl}
isGroupChat={isGroupChat(currentSession.username)}
onRequireModelDownload={handleRequireModelDownload}
/>
</div>
)
@@ -1298,20 +1304,16 @@ function ChatPage(_props: ChatPageProps) {
}}
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)
}
// 清除缓存中的请求标记,让组件可以重新尝试
const cacheKey = `voice-transcript:${pendingVoiceTranscriptRequest.messageId}`
// 不直接调用转写,而是让组件自己重试
// 通过触发一个自定义事件来通知所有 MessageBubble 组件
window.dispatchEvent(new CustomEvent('model-downloaded', {
detail: { messageId: pendingVoiceTranscriptRequest.messageId }
}))
}
setPendingVoiceTranscriptRequest(null)
}}
@@ -1330,12 +1332,13 @@ const senderAvatarCache = new Map<string, { avatarUrl?: string; displayName?: st
const senderAvatarLoading = new Map<string, Promise<{ avatarUrl?: string; displayName?: string } | null>>()
// 消息气泡组件
function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }: {
function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, onRequireModelDownload }: {
message: Message;
session: ChatSession;
showTime?: boolean;
myAvatarUrl?: string;
isGroupChat?: boolean;
onRequireModelDownload?: (sessionId: string, messageId: string) => void;
}) {
const isSystem = isSystemMessage(message.localType)
const isEmoji = message.localType === 47
@@ -1682,21 +1685,27 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
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
// 检查 whisper API 是否可用
if (!window.electronAPI?.whisper?.getModelStatus) {
console.warn('[ChatPage] whisper API 不可用')
setVoiceTranscriptError(true)
return
}
voiceTranscriptRequestedRef.current = true
setVoiceTranscriptLoading(true)
setVoiceTranscriptError(false)
try {
// 检查模型状态
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
}
const result = await window.electronAPI.chat.getVoiceTranscript(session.username, String(message.localId))
if (result.success) {
const transcriptText = (result.transcript || '').trim()
@@ -1709,8 +1718,10 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
} catch (error: any) {
// 检查是否是模型未下载错误
if (error?.requiresDownload) {
// 不显示错误状态,等待用户手动点击转文字按钮时会触发下载弹窗
voiceTranscriptRequestedRef.current = false
// 模型未下载,触发下载弹窗
onRequireModelDownload?.(error.sessionId, error.messageId)
// 不要重置 voiceTranscriptRequestedRef避免重复触发
setVoiceTranscriptLoading(false)
return
}
setVoiceTranscriptError(true)
@@ -1718,7 +1729,27 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
} finally {
setVoiceTranscriptLoading(false)
}
}, [message.localId, session.username, voiceTranscriptCacheKey, voiceTranscriptLoading])
}, [message.localId, session.username, voiceTranscriptCacheKey, voiceTranscriptLoading, onRequireModelDownload])
// 监听模型下载完成事件
useEffect(() => {
if (!isVoice) return
const handleModelDownloaded = (event: CustomEvent) => {
if (event.detail?.messageId === String(message.localId)) {
// 重置状态,允许重新尝试转写
voiceTranscriptRequestedRef.current = false
setVoiceTranscriptError(false)
// 立即尝试转写
void requestVoiceTranscript()
}
}
window.addEventListener('model-downloaded', handleModelDownloaded as EventListener)
return () => {
window.removeEventListener('model-downloaded', handleModelDownloaded as EventListener)
}
}, [isVoice, message.localId, requestVoiceTranscript])
// 根据设置决定是否自动转写
const [autoTranscribeEnabled, setAutoTranscribeEnabled] = useState(false)

View File

@@ -352,6 +352,37 @@
color: var(--text-secondary);
}
.language-checkboxes {
display: flex;
flex-wrap: wrap;
gap: 16px;
margin-top: 8px;
}
.language-checkbox {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
accent-color: var(--primary);
}
.checkbox-label {
font-size: 14px;
color: var(--text-primary);
}
&:hover .checkbox-label {
color: var(--primary);
}
}
.switch {
position: relative;
width: 46px;

View File

@@ -48,6 +48,7 @@ function SettingsPage() {
const [whisperDownloadProgress, setWhisperDownloadProgress] = useState(0)
const [whisperModelStatus, setWhisperModelStatus] = useState<{ exists: boolean; modelPath?: string; tokensPath?: string } | null>(null)
const [autoTranscribeVoice, setAutoTranscribeVoice] = useState(false)
const [transcribeLanguages, setTranscribeLanguages] = useState<string[]>(['zh'])
const [isLoading, setIsLoadingState] = useState(false)
const [isTesting, setIsTesting] = useState(false)
@@ -112,6 +113,7 @@ function SettingsPage() {
const savedWhisperModelName = await configService.getWhisperModelName()
const savedWhisperModelDir = await configService.getWhisperModelDir()
const savedAutoTranscribe = await configService.getAutoTranscribeVoice()
const savedTranscribeLanguages = await configService.getTranscribeLanguages()
if (savedKey) setDecryptKey(savedKey)
if (savedPath) setDbPath(savedPath)
@@ -123,6 +125,15 @@ function SettingsPage() {
if (savedImageAesKey) setImageAesKey(savedImageAesKey)
setLogEnabled(savedLogEnabled)
setAutoTranscribeVoice(savedAutoTranscribe)
setTranscribeLanguages(savedTranscribeLanguages)
// 如果语言列表为空,保存默认值
if (!savedTranscribeLanguages || savedTranscribeLanguages.length === 0) {
const defaultLanguages = ['zh']
setTranscribeLanguages(defaultLanguages)
await configService.setTranscribeLanguages(defaultLanguages)
}
if (savedWhisperModelDir) setWhisperModelDir(savedWhisperModelDir)
} catch (e) {
console.error('加载配置失败:', e)
@@ -454,6 +465,7 @@ function SettingsPage() {
}
await configService.setWhisperModelDir(whisperModelDir)
await configService.setAutoTranscribeVoice(autoTranscribeVoice)
await configService.setTranscribeLanguages(transcribeLanguages)
await configService.setOnboardingDone(true)
showMessage('配置保存成功,正在测试连接...', true)
@@ -490,6 +502,7 @@ function SettingsPage() {
setCachePath('')
setLogEnabled(false)
setAutoTranscribeVoice(false)
setTranscribeLanguages(['zh'])
setWhisperModelDir('')
setWhisperModelStatus(null)
setWhisperDownloadProgress(0)
@@ -757,6 +770,46 @@ function SettingsPage() {
</label>
</div>
</div>
<div className="form-group">
<label></label>
<span className="form-hint"></span>
<div className="language-checkboxes">
{[
{ code: 'zh', name: '中文' },
{ code: 'en', name: '英文' },
{ code: 'ja', name: '日文' },
{ code: 'ko', name: '韩文' }
].map((lang) => (
<label key={lang.code} className="language-checkbox">
<input
type="checkbox"
checked={transcribeLanguages.includes(lang.code)}
onChange={async (e) => {
const checked = e.target.checked
let newLanguages: string[]
if (checked) {
// 添加语言
newLanguages = [...transcribeLanguages, lang.code]
} else {
// 移除语言,但至少保留一个
if (transcribeLanguages.length <= 1) {
showMessage('至少需要选择一种语言', false)
return
}
newLanguages = transcribeLanguages.filter(l => l !== lang.code)
}
setTranscribeLanguages(newLanguages)
await configService.setTranscribeLanguages(newLanguages)
showMessage(`${checked ? '添加' : '移除'}${lang.name}`, true)
}}
/>
<span className="checkbox-label">{lang.name}</span>
</label>
))}
</div>
</div>
<div className="form-group whisper-section">
<label> (SenseVoiceSmall)</label>
<span className="form-hint"> Sherpa-onnx</span>