mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-24 23:06:51 +00:00
从密语给批量语音转文字搬过来了
This commit is contained in:
@@ -896,6 +896,9 @@ function registerIpcHandlers() {
|
|||||||
ipcMain.handle('chat:getVoiceData', async (_, sessionId: string, msgId: string, createTime?: number, serverId?: string | number) => {
|
ipcMain.handle('chat:getVoiceData', async (_, sessionId: string, msgId: string, createTime?: number, serverId?: string | number) => {
|
||||||
return chatService.getVoiceData(sessionId, msgId, createTime, serverId)
|
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) => {
|
ipcMain.handle('chat:resolveVoiceCache', async (_, sessionId: string, msgId: string) => {
|
||||||
return chatService.resolveVoiceCache(sessionId, msgId)
|
return chatService.resolveVoiceCache(sessionId, msgId)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
getImageData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getImageData', sessionId, msgId),
|
getImageData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getImageData', sessionId, msgId),
|
||||||
getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) =>
|
getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) =>
|
||||||
ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId, createTime, serverId),
|
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),
|
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),
|
getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => ipcRenderer.invoke('chat:getVoiceTranscript', sessionId, msgId, createTime),
|
||||||
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => {
|
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => {
|
||||||
|
|||||||
@@ -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<string, any>[])
|
||||||
|
allVoiceMessages.push(...mapped)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[ChatService] 查询语音消息失败 (${dbPath}):`, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按 createTime 降序排序
|
||||||
|
allVoiceMessages.sort((a, b) => b.createTime - a.createTime)
|
||||||
|
|
||||||
|
// 去重
|
||||||
|
const seen = new Set<string>()
|
||||||
|
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 }> {
|
async getMessageById(sessionId: string, localId: number): Promise<{ success: boolean; message?: Message; error?: string }> {
|
||||||
try {
|
try {
|
||||||
// 1. 尝试从缓存获取会话表信息
|
// 1. 尝试从缓存获取会话表信息
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
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 { createPortal } from 'react-dom'
|
||||||
import { useChatStore } from '../stores/chatStore'
|
import { useChatStore } from '../stores/chatStore'
|
||||||
import type { ChatSession, Message } from '../types/models'
|
import type { ChatSession, Message } from '../types/models'
|
||||||
@@ -174,6 +174,18 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
const [showVoiceTranscribeDialog, setShowVoiceTranscribeDialog] = useState(false)
|
const [showVoiceTranscribeDialog, setShowVoiceTranscribeDialog] = useState(false)
|
||||||
const [pendingVoiceTranscriptRequest, setPendingVoiceTranscriptRequest] = useState<{ sessionId: string; messageId: string } | null>(null)
|
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 isEnrichingRef = useRef(false)
|
||||||
const enrichCancelledRef = useRef(false)
|
const enrichCancelledRef = useRef(false)
|
||||||
@@ -1183,6 +1195,155 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
setShowVoiceTranscribeDialog(true)
|
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 (
|
return (
|
||||||
<div className={`chat-page ${isResizing ? 'resizing' : ''}`}>
|
<div className={`chat-page ${isResizing ? 'resizing' : ''}`}>
|
||||||
{/* 左侧会话列表 */}
|
{/* 左侧会话列表 */}
|
||||||
@@ -1293,6 +1454,18 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="header-actions">
|
<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
|
<button
|
||||||
className="icon-btn jump-to-time-btn"
|
className="icon-btn jump-to-time-btn"
|
||||||
onClick={() => setShowJumpDialog(true)}
|
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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
1
src/types/electron.d.ts
vendored
1
src/types/electron.d.ts
vendored
@@ -111,6 +111,7 @@ export interface ElectronAPI {
|
|||||||
}>
|
}>
|
||||||
getImageData: (sessionId: string, msgId: string) => Promise<{ success: boolean; data?: string; error?: string }>
|
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 }>
|
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 }>
|
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 }>
|
getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => Promise<{ success: boolean; transcript?: string; error?: string }>
|
||||||
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => () => void
|
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => () => void
|
||||||
|
|||||||
Reference in New Issue
Block a user