从密语给批量语音转文字搬过来了

This commit is contained in:
xuncha
2026-02-06 17:57:39 +08:00
committed by xuncha
parent a19f2a57c3
commit d3a1db4efe
6 changed files with 803 additions and 1 deletions

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link } from 'lucide-react'
import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link, Mic, CheckCircle, XCircle } from 'lucide-react'
import { createPortal } from 'react-dom'
import { useChatStore } from '../stores/chatStore'
import type { ChatSession, Message } from '../types/models'
@@ -174,6 +174,18 @@ function ChatPage(_props: ChatPageProps) {
const [showVoiceTranscribeDialog, setShowVoiceTranscribeDialog] = useState(false)
const [pendingVoiceTranscriptRequest, setPendingVoiceTranscriptRequest] = useState<{ sessionId: string; messageId: string } | null>(null)
// 批量语音转文字相关状态
const [isBatchTranscribing, setIsBatchTranscribing] = useState(false)
const [batchTranscribeProgress, setBatchTranscribeProgress] = useState({ current: 0, total: 0 })
const [showBatchConfirm, setShowBatchConfirm] = useState(false)
const [batchVoiceCount, setBatchVoiceCount] = useState(0)
const [batchVoiceMessages, setBatchVoiceMessages] = useState<Message[] | null>(null)
const [batchVoiceDates, setBatchVoiceDates] = useState<string[]>([])
const [batchSelectedDates, setBatchSelectedDates] = useState<Set<string>>(new Set())
const [showBatchProgress, setShowBatchProgress] = useState(false)
const [showBatchResult, setShowBatchResult] = useState(false)
const [batchResult, setBatchResult] = useState({ success: 0, fail: 0 })
// 联系人信息加载控制
const isEnrichingRef = useRef(false)
const enrichCancelledRef = useRef(false)
@@ -1183,6 +1195,155 @@ function ChatPage(_props: ChatPageProps) {
setShowVoiceTranscribeDialog(true)
}, [])
// 批量语音转文字
const handleBatchTranscribe = useCallback(async () => {
if (!currentSessionId) return
const session = sessions.find(s => s.username === currentSessionId)
if (!session) {
alert('未找到当前会话')
return
}
if (isBatchTranscribing) return
const result = await window.electronAPI.chat.getAllVoiceMessages(currentSessionId)
if (!result.success || !result.messages) {
alert(`获取语音消息失败: ${result.error || '未知错误'}`)
return
}
const voiceMessages = result.messages
if (voiceMessages.length === 0) {
alert('当前会话没有语音消息')
return
}
const dateSet = new Set<string>()
voiceMessages.forEach(m => dateSet.add(new Date(m.createTime * 1000).toISOString().slice(0, 10)))
const sortedDates = Array.from(dateSet).sort((a, b) => b.localeCompare(a))
setBatchVoiceMessages(voiceMessages)
setBatchVoiceCount(voiceMessages.length)
setBatchVoiceDates(sortedDates)
setBatchSelectedDates(new Set(sortedDates))
setShowBatchConfirm(true)
}, [sessions, currentSessionId, isBatchTranscribing])
// 确认批量转写
const confirmBatchTranscribe = useCallback(async () => {
if (!currentSessionId) return
const selected = batchSelectedDates
if (selected.size === 0) {
alert('请至少选择一个日期')
return
}
const messages = batchVoiceMessages
if (!messages || messages.length === 0) {
setShowBatchConfirm(false)
return
}
const voiceMessages = messages.filter(m =>
selected.has(new Date(m.createTime * 1000).toISOString().slice(0, 10))
)
if (voiceMessages.length === 0) {
alert('所选日期下没有语音消息')
return
}
setShowBatchConfirm(false)
setBatchVoiceMessages(null)
setBatchVoiceDates([])
setBatchSelectedDates(new Set())
const session = sessions.find(s => s.username === currentSessionId)
if (!session) return
setIsBatchTranscribing(true)
setShowBatchProgress(true)
setBatchTranscribeProgress({ current: 0, total: voiceMessages.length })
// 检查模型状态
const modelStatus = await window.electronAPI.whisper.getModelStatus()
if (!modelStatus?.exists) {
alert('SenseVoice 模型未下载,请先在设置中下载模型')
setIsBatchTranscribing(false)
setShowBatchProgress(false)
return
}
let successCount = 0
let failCount = 0
let completedCount = 0
const concurrency = 3
const transcribeOne = async (msg: Message) => {
try {
const result = await window.electronAPI.chat.getVoiceTranscript(
session.username,
String(msg.localId),
msg.createTime
)
return { success: result.success }
} catch {
return { success: false }
}
}
for (let i = 0; i < voiceMessages.length; i += concurrency) {
const batch = voiceMessages.slice(i, i + concurrency)
const results = await Promise.all(batch.map(msg => transcribeOne(msg)))
results.forEach(result => {
if (result.success) successCount++
else failCount++
completedCount++
setBatchTranscribeProgress({ current: completedCount, total: voiceMessages.length })
})
}
setIsBatchTranscribing(false)
setShowBatchProgress(false)
setBatchResult({ success: successCount, fail: failCount })
setShowBatchResult(true)
}, [sessions, currentSessionId, batchSelectedDates, batchVoiceMessages])
// 批量转写:按日期的消息数量
const batchCountByDate = useMemo(() => {
const map = new Map<string, number>()
if (!batchVoiceMessages) return map
batchVoiceMessages.forEach(m => {
const d = new Date(m.createTime * 1000).toISOString().slice(0, 10)
map.set(d, (map.get(d) || 0) + 1)
})
return map
}, [batchVoiceMessages])
// 批量转写:选中日期对应的语音条数
const batchSelectedMessageCount = useMemo(() => {
if (!batchVoiceMessages) return 0
return batchVoiceMessages.filter(m =>
batchSelectedDates.has(new Date(m.createTime * 1000).toISOString().slice(0, 10))
).length
}, [batchVoiceMessages, batchSelectedDates])
const toggleBatchDate = useCallback((date: string) => {
setBatchSelectedDates(prev => {
const next = new Set(prev)
if (next.has(date)) next.delete(date)
else next.add(date)
return next
})
}, [])
const selectAllBatchDates = useCallback(() => setBatchSelectedDates(new Set(batchVoiceDates)), [batchVoiceDates])
const clearAllBatchDates = useCallback(() => setBatchSelectedDates(new Set()), [])
const formatBatchDateLabel = useCallback((dateStr: string) => {
const [y, m, d] = dateStr.split('-').map(Number)
return `${y}${m}${d}`
}, [])
return (
<div className={`chat-page ${isResizing ? 'resizing' : ''}`}>
{/* 左侧会话列表 */}
@@ -1293,6 +1454,18 @@ function ChatPage(_props: ChatPageProps) {
)}
</div>
<div className="header-actions">
<button
className="icon-btn batch-transcribe-btn"
onClick={handleBatchTranscribe}
disabled={isBatchTranscribing || !currentSessionId}
title={isBatchTranscribing ? `批量转写中 (${batchTranscribeProgress.current}/${batchTranscribeProgress.total})` : '批量语音转文字'}
>
{isBatchTranscribing ? (
<Loader2 size={18} className="spin" />
) : (
<Mic size={18} />
)}
</button>
<button
className="icon-btn jump-to-time-btn"
onClick={() => setShowJumpDialog(true)}
@@ -1542,6 +1715,150 @@ function ChatPage(_props: ChatPageProps) {
}}
/>
)}
{/* 批量转写确认对话框 */}
{showBatchConfirm && createPortal(
<div className="batch-modal-overlay" onClick={() => setShowBatchConfirm(false)}>
<div className="batch-modal-content batch-confirm-modal" onClick={(e) => e.stopPropagation()}>
<div className="batch-modal-header">
<Mic size={20} />
<h3></h3>
</div>
<div className="batch-modal-body">
<p></p>
{batchVoiceDates.length > 0 && (
<div className="batch-dates-list-wrap">
<div className="batch-dates-actions">
<button type="button" className="batch-dates-btn" onClick={selectAllBatchDates}></button>
<button type="button" className="batch-dates-btn" onClick={clearAllBatchDates}></button>
</div>
<ul className="batch-dates-list">
{batchVoiceDates.map(dateStr => {
const count = batchCountByDate.get(dateStr) ?? 0
const checked = batchSelectedDates.has(dateStr)
return (
<li key={dateStr}>
<label className="batch-date-row">
<input
type="checkbox"
checked={checked}
onChange={() => toggleBatchDate(dateStr)}
/>
<span className="batch-date-label">{formatBatchDateLabel(dateStr)}</span>
<span className="batch-date-count">{count} </span>
</label>
</li>
)
})}
</ul>
</div>
)}
<div className="batch-info">
<div className="info-item">
<span className="label">:</span>
<span className="value">{batchSelectedDates.size} {batchSelectedMessageCount} </span>
</div>
<div className="info-item">
<span className="label">:</span>
<span className="value"> {Math.ceil(batchSelectedMessageCount * 2 / 60)} </span>
</div>
</div>
<div className="batch-warning">
<AlertCircle size={16} />
<span>使</span>
</div>
</div>
<div className="batch-modal-footer">
<button className="btn-secondary" onClick={() => setShowBatchConfirm(false)}>
</button>
<button className="btn-primary batch-transcribe-start-btn" onClick={confirmBatchTranscribe}>
<Mic size={16} />
</button>
</div>
</div>
</div>,
document.body
)}
{/* 批量转写进度对话框 */}
{showBatchProgress && createPortal(
<div className="batch-modal-overlay">
<div className="batch-modal-content batch-progress-modal" onClick={(e) => e.stopPropagation()}>
<div className="batch-modal-header">
<Loader2 size={20} className="spin" />
<h3>...</h3>
</div>
<div className="batch-modal-body">
<div className="progress-info">
<div className="progress-text">
<span> {batchTranscribeProgress.current} / {batchTranscribeProgress.total} </span>
<span className="progress-percent">
{batchTranscribeProgress.total > 0
? Math.round((batchTranscribeProgress.current / batchTranscribeProgress.total) * 100)
: 0}%
</span>
</div>
<div className="progress-bar">
<div
className="progress-fill"
style={{
width: `${batchTranscribeProgress.total > 0
? (batchTranscribeProgress.current / batchTranscribeProgress.total) * 100
: 0}%`
}}
/>
</div>
</div>
<div className="batch-tip">
<span>使</span>
</div>
</div>
</div>
</div>,
document.body
)}
{/* 批量转写结果对话框 */}
{showBatchResult && createPortal(
<div className="batch-modal-overlay" onClick={() => setShowBatchResult(false)}>
<div className="batch-modal-content batch-result-modal" onClick={(e) => e.stopPropagation()}>
<div className="batch-modal-header">
<CheckCircle size={20} />
<h3></h3>
</div>
<div className="batch-modal-body">
<div className="result-summary">
<div className="result-item success">
<CheckCircle size={18} />
<span className="label">:</span>
<span className="value">{batchResult.success} </span>
</div>
{batchResult.fail > 0 && (
<div className="result-item fail">
<XCircle size={18} />
<span className="label">:</span>
<span className="value">{batchResult.fail} </span>
</div>
)}
</div>
{batchResult.fail > 0 && (
<div className="result-tip">
<AlertCircle size={16} />
<span></span>
</div>
)}
</div>
<div className="batch-modal-footer">
<button className="btn-primary" onClick={() => setShowBatchResult(false)}>
</button>
</div>
</div>
</div>,
document.body
)}
</div>
)
}