diff --git a/electron/main.ts b/electron/main.ts index 92564f8..98f9d92 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -896,6 +896,9 @@ function registerIpcHandlers() { ipcMain.handle('chat:getVoiceData', async (_, sessionId: string, msgId: string, createTime?: number, serverId?: string | number) => { return chatService.getVoiceData(sessionId, msgId, createTime, serverId) }) + ipcMain.handle('chat:getAllVoiceMessages', async (_, sessionId: string) => { + return chatService.getAllVoiceMessages(sessionId) + }) ipcMain.handle('chat:resolveVoiceCache', async (_, sessionId: string, msgId: string) => { return chatService.resolveVoiceCache(sessionId, msgId) }) diff --git a/electron/preload.ts b/electron/preload.ts index 715d548..a3f3451 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -139,6 +139,7 @@ contextBridge.exposeInMainWorld('electronAPI', { getImageData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getImageData', sessionId, msgId), getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) => ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId, createTime, serverId), + getAllVoiceMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllVoiceMessages', sessionId), resolveVoiceCache: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:resolveVoiceCache', sessionId, msgId), getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => ipcRenderer.invoke('chat:getVoiceTranscript', sessionId, msgId, createTime), onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => { diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index eade809..dea984d 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -3551,6 +3551,67 @@ class ChatService { } } + /** + * 获取某会话的所有语音消息(localType=34),用于批量转写 + */ + async getAllVoiceMessages(sessionId: string): Promise<{ success: boolean; messages?: Message[]; error?: string }> { + try { + const connectResult = await this.ensureConnected() + if (!connectResult.success) { + return { success: false, error: connectResult.error || '数据库未连接' } + } + + // 获取会话表信息 + let tables = this.sessionTablesCache.get(sessionId) + if (!tables) { + const tableStats = await wcdbService.getMessageTableStats(sessionId) + if (!tableStats.success || !tableStats.tables || tableStats.tables.length === 0) { + return { success: false, error: '未找到会话消息表' } + } + tables = tableStats.tables + .map(t => ({ tableName: t.table_name || t.name, dbPath: t.db_path })) + .filter(t => t.tableName && t.dbPath) as Array<{ tableName: string; dbPath: string }> + if (tables.length > 0) { + this.sessionTablesCache.set(sessionId, tables) + setTimeout(() => { this.sessionTablesCache.delete(sessionId) }, this.sessionTablesCacheTtl) + } + } + + let allVoiceMessages: Message[] = [] + + for (const { tableName, dbPath } of tables) { + try { + const sql = `SELECT * FROM ${tableName} WHERE local_type = 34 ORDER BY create_time DESC` + const result = await wcdbService.execQuery('message', dbPath, sql) + if (result.success && result.rows && result.rows.length > 0) { + const mapped = this.mapRowsToMessages(result.rows as Record[]) + allVoiceMessages.push(...mapped) + } + } catch (e) { + console.error(`[ChatService] 查询语音消息失败 (${dbPath}):`, e) + } + } + + // 按 createTime 降序排序 + allVoiceMessages.sort((a, b) => b.createTime - a.createTime) + + // 去重 + const seen = new Set() + allVoiceMessages = allVoiceMessages.filter(msg => { + const key = `${msg.serverId}-${msg.localId}-${msg.createTime}-${msg.sortSeq}` + if (seen.has(key)) return false + seen.add(key) + return true + }) + + console.log(`[ChatService] 共找到 ${allVoiceMessages.length} 条语音消息(去重后)`) + return { success: true, messages: allVoiceMessages } + } catch (e) { + console.error('[ChatService] 获取所有语音消息失败:', e) + return { success: false, error: String(e) } + } + } + async getMessageById(sessionId: string, localId: number): Promise<{ success: boolean; message?: Message; error?: string }> { try { // 1. 尝试从缓存获取会话表信息 diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index 4b11448..e9b6174 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -2572,3 +2572,422 @@ } } } + +// 批量转写按钮 +.batch-transcribe-btn { + &:hover:not(:disabled) { + color: var(--primary-color); + } +} + +// 批量转写模态框基础样式 +.batch-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; + animation: batchFadeIn 0.2s ease-out; +} + +.batch-modal-content { + background: var(--bg-primary); + border-radius: 12px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + max-height: 90vh; + overflow-y: auto; + animation: batchSlideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1); +} + +@keyframes batchFadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes batchSlideUp { + from { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: translateY(0); } +} + +// 批量转写确认对话框 +.batch-confirm-modal { + width: 480px; + max-width: 90vw; + + .batch-modal-header { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1.5rem; + border-bottom: 1px solid var(--border-color); + + svg { color: var(--primary-color); } + + h3 { + margin: 0; + font-size: 18px; + font-weight: 600; + color: var(--text-primary); + } + } + + .batch-modal-body { + padding: 1.5rem; + + p { + margin: 0 0 1rem 0; + font-size: 14px; + color: var(--text-secondary); + line-height: 1.6; + } + + .batch-dates-list-wrap { + margin-bottom: 1rem; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 8px; + overflow: hidden; + + .batch-dates-actions { + display: flex; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--border-color); + background: var(--bg-secondary); + + .batch-dates-btn { + padding: 0.35rem 0.75rem; + font-size: 12px; + color: var(--primary-color); + background: transparent; + border: 1px solid var(--border-color); + border-radius: 6px; + cursor: pointer; + transition: all 0.2s; + + &:hover { + background: var(--bg-hover); + border-color: var(--primary-color); + } + } + } + + .batch-dates-list { + list-style: none; + margin: 0; + padding: 0; + max-height: 160px; + overflow-y: auto; + + li { + border-bottom: 1px solid var(--border-color); + &:last-child { border-bottom: none; } + } + + .batch-date-row { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.6rem 0.75rem; + cursor: pointer; + transition: background 0.15s; + + &:hover { background: var(--bg-hover); } + + input[type="checkbox"] { + accent-color: var(--primary-color); + cursor: pointer; + flex-shrink: 0; + } + + .batch-date-label { + flex: 1; + font-size: 14px; + color: var(--text-primary); + font-weight: 500; + } + + .batch-date-count { + font-size: 12px; + color: var(--text-tertiary); + flex-shrink: 0; + } + } + } + } + + .batch-info { + background: var(--bg-tertiary); + border-radius: 8px; + padding: 1rem; + margin-bottom: 1rem; + + .info-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem 0; + + &:not(:last-child) { + border-bottom: 1px solid var(--border-color); + } + + .label { + font-size: 13px; + color: var(--text-secondary); + } + + .value { + font-size: 14px; + font-weight: 600; + color: var(--primary-color); + } + } + } + + .batch-warning { + display: flex; + align-items: flex-start; + gap: 0.5rem; + padding: 0.75rem; + background: rgba(255, 152, 0, 0.1); + border-radius: 8px; + border: 1px solid rgba(255, 152, 0, 0.3); + + svg { + flex-shrink: 0; + margin-top: 2px; + color: #ff9800; + } + + span { + font-size: 13px; + color: var(--text-secondary); + line-height: 1.5; + } + } + } + + .batch-modal-footer { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + padding: 1rem 1.5rem; + border-top: 1px solid var(--border-color); + + button { + padding: 0.5rem 1.25rem; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + display: flex; + align-items: center; + gap: 0.5rem; + border: none; + + &.btn-secondary { + background: var(--bg-tertiary); + color: var(--text-primary); + &:hover { background: var(--border-color); } + } + + &.btn-primary, &.batch-transcribe-start-btn { + background: var(--primary-color); + color: white; + &:hover { opacity: 0.9; } + } + } + } +} + +// 批量转写进度对话框 +.batch-progress-modal { + width: 420px; + max-width: 90vw; + + .batch-modal-header { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1.5rem; + border-bottom: 1px solid var(--border-color); + + svg { color: var(--primary-color); } + + h3 { + margin: 0; + font-size: 18px; + font-weight: 600; + color: var(--text-primary); + } + } + + .batch-modal-body { + padding: 1.5rem; + + .progress-info { + .progress-text { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.75rem; + font-size: 14px; + color: var(--text-secondary); + + .progress-percent { + font-weight: 600; + color: var(--primary-color); + font-size: 16px; + } + } + + .progress-bar { + height: 8px; + background: var(--bg-tertiary); + border-radius: 4px; + overflow: hidden; + margin-bottom: 1rem; + + .progress-fill { + height: 100%; + background: linear-gradient(90deg, var(--primary-color), var(--primary-color)); + border-radius: 4px; + transition: width 0.3s ease; + } + } + } + + .batch-tip { + display: flex; + align-items: center; + justify-content: center; + padding: 0.75rem; + background: var(--bg-tertiary); + border-radius: 8px; + + span { + font-size: 13px; + color: var(--text-secondary); + } + } + } +} + +// 批量转写结果对话框 +.batch-result-modal { + width: 420px; + max-width: 90vw; + + .batch-modal-header { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1.5rem; + border-bottom: 1px solid var(--border-color); + + svg { color: #4caf50; } + + h3 { + margin: 0; + font-size: 18px; + font-weight: 600; + color: var(--text-primary); + } + } + + .batch-modal-body { + padding: 1.5rem; + + .result-summary { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-bottom: 1rem; + + .result-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1rem; + border-radius: 8px; + background: var(--bg-tertiary); + + svg { flex-shrink: 0; } + + .label { + font-size: 14px; + color: var(--text-secondary); + } + + .value { + margin-left: auto; + font-size: 18px; + font-weight: 600; + } + + &.success { + svg { color: #4caf50; } + .value { color: #4caf50; } + } + + &.fail { + svg { color: #f44336; } + .value { color: #f44336; } + } + } + } + + .result-tip { + display: flex; + align-items: flex-start; + gap: 0.5rem; + padding: 0.75rem; + background: rgba(255, 152, 0, 0.1); + border-radius: 8px; + border: 1px solid rgba(255, 152, 0, 0.3); + + svg { + flex-shrink: 0; + margin-top: 2px; + color: #ff9800; + } + + span { + font-size: 13px; + color: var(--text-secondary); + line-height: 1.5; + } + } + } + + .batch-modal-footer { + display: flex; + justify-content: flex-end; + padding: 1rem 1.5rem; + border-top: 1px solid var(--border-color); + + button { + padding: 0.5rem 1.5rem; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + border: none; + + &.btn-primary { + background: var(--primary-color); + color: white; + &:hover { opacity: 0.9; } + } + } + } +} diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 482b6b7..63d127c 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -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(null) + const [batchVoiceDates, setBatchVoiceDates] = useState([]) + const [batchSelectedDates, setBatchSelectedDates] = useState>(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() + 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() + 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 (
{/* 左侧会话列表 */} @@ -1293,6 +1454,18 @@ function ChatPage(_props: ChatPageProps) { )}
+ + +
+
    + {batchVoiceDates.map(dateStr => { + const count = batchCountByDate.get(dateStr) ?? 0 + const checked = batchSelectedDates.has(dateStr) + return ( +
  • + +
  • + ) + })} +
+ + )} +
+
+ 已选: + {batchSelectedDates.size} 天有语音,共 {batchSelectedMessageCount} 条语音 +
+
+ 预计耗时: + 约 {Math.ceil(batchSelectedMessageCount * 2 / 60)} 分钟 +
+
+
+ + 批量转写可能需要较长时间,转写过程中可以继续使用其他功能。已转写过的语音会自动跳过。 +
+ +
+ + +
+ + , + document.body + )} + + {/* 批量转写进度对话框 */} + {showBatchProgress && createPortal( +
+
e.stopPropagation()}> +
+ +

正在转写...

+
+
+
+
+ 已完成 {batchTranscribeProgress.current} / {batchTranscribeProgress.total} 条 + + {batchTranscribeProgress.total > 0 + ? Math.round((batchTranscribeProgress.current / batchTranscribeProgress.total) * 100) + : 0}% + +
+
+
0 + ? (batchTranscribeProgress.current / batchTranscribeProgress.total) * 100 + : 0}%` + }} + /> +
+
+
+ 转写过程中可以继续使用其他功能 +
+
+
+
, + document.body + )} + + {/* 批量转写结果对话框 */} + {showBatchResult && createPortal( +
setShowBatchResult(false)}> +
e.stopPropagation()}> +
+ +

转写完成

+
+
+
+
+ + 成功: + {batchResult.success} 条 +
+ {batchResult.fail > 0 && ( +
+ + 失败: + {batchResult.fail} 条 +
+ )} +
+ {batchResult.fail > 0 && ( +
+ + 部分语音转写失败,可能是语音文件损坏或网络问题 +
+ )} +
+
+ +
+
+
, + document.body + )}
) } diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 020b5c3..49aaf33 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -111,6 +111,7 @@ export interface ElectronAPI { }> getImageData: (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 }> + getAllVoiceMessages: (sessionId: string) => Promise<{ success: boolean; messages?: Message[]; error?: string }> resolveVoiceCache: (sessionId: string, msgId: string) => Promise<{ success: boolean; hasCache: boolean; data?: string }> getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => Promise<{ success: boolean; transcript?: string; error?: string }> onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => () => void