feat: 尝试增加一下聊天里面的语音转文字功能

This commit is contained in:
xuncha
2026-01-17 05:14:14 +08:00
parent 095c8f0db6
commit 72e2d82158
18 changed files with 999 additions and 66 deletions

View File

@@ -1303,6 +1303,12 @@
cursor: pointer;
}
.voice-stack {
display: flex;
flex-direction: column;
gap: 6px;
}
.message-bubble.sent .voice-message {
background: rgba(255, 255, 255, 0.18);
}
@@ -1391,6 +1397,34 @@
color: #d9480f;
}
.voice-transcript {
max-width: 260px;
padding: 8px 12px;
border-radius: 14px;
font-size: 13px;
line-height: 1.5;
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border-color);
word-break: break-word;
white-space: pre-wrap;
}
.voice-transcript.sent {
background: rgba(255, 255, 255, 0.9);
color: var(--text-primary);
border-color: transparent;
}
.voice-transcript.received {
background: var(--card-bg);
}
.voice-transcript.error {
color: #d9480f;
cursor: pointer;
}
@keyframes voicePulse {
0% {
height: 6px;
@@ -1847,4 +1881,4 @@
opacity: 1;
transform: translateX(0);
}
}
}

View File

@@ -1280,6 +1280,7 @@ function ChatPage(_props: ChatPageProps) {
const emojiDataUrlCache = new Map<string, string>()
const imageDataUrlCache = new Map<string, string>()
const voiceDataUrlCache = new Map<string, string>()
const voiceTranscriptCache = new Map<string, string>()
const senderAvatarCache = new Map<string, { avatarUrl?: string; displayName?: string }>()
const senderAvatarLoading = new Map<string, Promise<{ avatarUrl?: string; displayName?: string } | null>>()
@@ -1312,6 +1313,9 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
const [voiceLoading, setVoiceLoading] = useState(false)
const [isVoicePlaying, setIsVoicePlaying] = useState(false)
const voiceAudioRef = useRef<HTMLAudioElement | null>(null)
const [voiceTranscriptLoading, setVoiceTranscriptLoading] = useState(false)
const [voiceTranscriptError, setVoiceTranscriptError] = useState(false)
const voiceTranscriptRequestedRef = useRef(false)
const [showImagePreview, setShowImagePreview] = useState(false)
// 从缓存获取表情包 data URL
@@ -1327,6 +1331,10 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
const [voiceDataUrl, setVoiceDataUrl] = useState<string | undefined>(
() => voiceDataUrlCache.get(voiceCacheKey)
)
const voiceTranscriptCacheKey = `voice-transcript:${message.localId}`
const [voiceTranscript, setVoiceTranscript] = useState<string | undefined>(
() => voiceTranscriptCache.get(voiceTranscriptCacheKey)
)
const formatTime = (timestamp: number): string => {
if (!Number.isFinite(timestamp) || timestamp <= 0) return '未知时间'
@@ -1604,6 +1612,37 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
}
}, [isVoice])
const requestVoiceTranscript = useCallback(async () => {
if (voiceTranscriptLoading || voiceTranscriptRequestedRef.current) return
voiceTranscriptRequestedRef.current = true
setVoiceTranscriptLoading(true)
setVoiceTranscriptError(false)
try {
const result = await window.electronAPI.chat.getVoiceTranscript(session.username, String(message.localId))
if (result.success) {
const transcriptText = (result.transcript || '').trim()
voiceTranscriptCache.set(voiceTranscriptCacheKey, transcriptText)
setVoiceTranscript(transcriptText)
} else {
setVoiceTranscriptError(true)
voiceTranscriptRequestedRef.current = false
}
} catch {
setVoiceTranscriptError(true)
voiceTranscriptRequestedRef.current = false
} finally {
setVoiceTranscriptLoading(false)
}
}, [message.localId, session.username, voiceTranscriptCacheKey, voiceTranscriptLoading])
useEffect(() => {
if (!isVoice) return
if (!voiceDataUrl) return
if (voiceTranscriptError) return
if (voiceTranscriptLoading || voiceTranscript !== undefined || voiceTranscriptRequestedRef.current) return
void requestVoiceTranscript()
}, [isVoice, voiceDataUrl, voiceTranscript, voiceTranscriptError, voiceTranscriptLoading, requestVoiceTranscript])
if (isSystem) {
return (
<div className="message-bubble system">
@@ -1762,34 +1801,57 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
}
const showDecryptHint = !voiceDataUrl && !voiceLoading && !isVoicePlaying
const showTranscript = Boolean(voiceDataUrl) && (voiceTranscriptLoading || voiceTranscriptError || voiceTranscript !== undefined)
const transcriptText = (voiceTranscript || '').trim()
const transcriptDisplay = voiceTranscriptLoading
? '转写中...'
: voiceTranscriptError
? '转写失败,点击重试'
: (transcriptText || '未识别到文字')
const handleTranscriptRetry = () => {
if (!voiceTranscriptError) return
voiceTranscriptRequestedRef.current = false
void requestVoiceTranscript()
}
return (
<div className={`voice-message ${isVoicePlaying ? 'playing' : ''}`} onClick={handleToggle}>
<button
className="voice-play-btn"
onClick={(e) => {
e.stopPropagation()
handleToggle()
}}
aria-label="播放语音"
type="button"
>
{isVoicePlaying ? <Pause size={16} /> : <Play size={16} />}
</button>
<div className="voice-wave">
<span />
<span />
<span />
<span />
<span />
</div>
<div className="voice-info">
<span className="voice-label"></span>
{durationText && <span className="voice-duration">{durationText}</span>}
{voiceLoading && <span className="voice-loading">...</span>}
{showDecryptHint && <span className="voice-hint"></span>}
{voiceError && <span className="voice-error"></span>}
<div className="voice-stack">
<div className={`voice-message ${isVoicePlaying ? 'playing' : ''}`} onClick={handleToggle}>
<button
className="voice-play-btn"
onClick={(e) => {
e.stopPropagation()
handleToggle()
}}
aria-label="播放语音"
type="button"
>
{isVoicePlaying ? <Pause size={16} /> : <Play size={16} />}
</button>
<div className="voice-wave">
<span />
<span />
<span />
<span />
<span />
</div>
<div className="voice-info">
<span className="voice-label"></span>
{durationText && <span className="voice-duration">{durationText}</span>}
{voiceLoading && <span className="voice-loading">...</span>}
{showDecryptHint && <span className="voice-hint"></span>}
{voiceError && <span className="voice-error"></span>}
</div>
</div>
{showTranscript && (
<div
className={`voice-transcript ${isSent ? 'sent' : 'received'}${voiceTranscriptError ? ' error' : ''}`}
onClick={handleTranscriptRetry}
title={voiceTranscriptError ? '点击重试语音转写' : undefined}
>
{transcriptDisplay}
</div>
)}
</div>
)
}

View File

@@ -203,6 +203,23 @@
cursor: pointer;
}
}
select {
width: 100%;
padding: 10px 16px;
border: 1px solid var(--border-color);
border-radius: 9999px;
font-size: 14px;
background: var(--bg-primary);
color: var(--text-primary);
margin-bottom: 10px;
cursor: pointer;
&:focus {
outline: none;
border-color: var(--primary);
}
}
.input-with-toggle {
position: relative;
@@ -235,6 +252,93 @@
}
}
.whisper-section {
.whisper-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
}
.whisper-field {
display: flex;
flex-direction: column;
}
.field-label {
font-size: 12px;
color: var(--text-tertiary);
margin-bottom: 6px;
}
.whisper-status-line {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--text-secondary);
margin: 4px 0 10px;
.status {
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 500;
background: var(--bg-tertiary);
color: var(--text-secondary);
}
.status.ok {
background: rgba(16, 185, 129, 0.12);
color: #059669;
}
.status.warn {
background: rgba(245, 158, 11, 0.12);
color: #d97706;
}
.path {
flex: 1;
min-width: 0;
font-size: 11px;
color: var(--text-tertiary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.whisper-progress {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
max-width: 320px;
.progress-bar {
flex: 1;
height: 6px;
background: var(--bg-tertiary);
border-radius: 999px;
overflow: hidden;
.progress-fill {
height: 100%;
background: var(--primary);
border-radius: 999px;
transition: width 0.2s ease;
}
}
span {
font-size: 12px;
color: var(--text-secondary);
min-width: 36px;
text-align: right;
}
}
}
.log-toggle-line {
display: flex;
align-items: center;

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react'
import { useState, useEffect, useRef } from 'react'
import { useAppStore } from '../stores/appStore'
import { useThemeStore, themes } from '../stores/themeStore'
import { useAnalyticsStore } from '../stores/analyticsStore'
@@ -7,15 +7,29 @@ import * as configService from '../services/config'
import {
Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy,
RotateCcw, Trash2, Save, Plug, Check, Sun, Moon,
Palette, Database, Download, HardDrive, Info, RefreshCw, ChevronDown
Palette, Database, Download, HardDrive, Info, RefreshCw, ChevronDown, Mic
} from 'lucide-react'
import './SettingsPage.scss'
type SettingsTab = 'appearance' | 'database' | 'cache' | 'about'
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 },
{ id: 'whisper', label: '语音识别模型', icon: Mic },
{ id: 'cache', label: '缓存', icon: HardDrive },
{ id: 'about', label: '关于', icon: Info }
]
@@ -41,6 +55,12 @@ function SettingsPage() {
const wxidDropdownRef = useRef<HTMLDivElement>(null)
const [cachePath, setCachePath] = useState('')
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 [isLoading, setIsLoadingState] = useState(false)
const [isTesting, setIsTesting] = useState(false)
@@ -102,6 +122,9 @@ function SettingsPage() {
const savedLogEnabled = await configService.getLogEnabled()
const savedImageXorKey = await configService.getImageXorKey()
const savedImageAesKey = await configService.getImageAesKey()
const savedWhisperModelName = await configService.getWhisperModelName()
const savedWhisperModelDir = await configService.getWhisperModelDir()
const savedWhisperSource = await configService.getWhisperDownloadSource()
if (savedKey) setDecryptKey(savedKey)
if (savedPath) setDbPath(savedPath)
@@ -112,6 +135,9 @@ function SettingsPage() {
}
if (savedImageAesKey) setImageAesKey(savedImageAesKey)
setLogEnabled(savedLogEnabled)
if (savedWhisperModelName) setWhisperModelName(savedWhisperModelName)
if (savedWhisperModelDir) setWhisperModelDir(savedWhisperModelDir)
if (savedWhisperSource) setWhisperDownloadSource(savedWhisperSource)
} catch (e) {
console.error('加载配置失败:', e)
}
@@ -119,6 +145,20 @@ function SettingsPage() {
const refreshWhisperStatus = async (modelNameValue = whisperModelName, modelDirValue = whisperModelDir) => {
try {
const result = await window.electronAPI.whisper?.getModelStatus({
modelName: modelNameValue,
downloadDir: modelDirValue || undefined
})
if (result?.success) {
setWhisperModelStatus({ exists: Boolean(result.exists), path: result.path })
}
} catch {
setWhisperModelStatus(null)
}
}
const loadAppVersion = async () => {
try {
const version = await window.electronAPI.app.getVersion()
@@ -136,6 +176,20 @@ function SettingsPage() {
return () => removeListener?.()
}, [])
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])
const handleCheckUpdate = async () => {
setIsCheckingUpdate(true)
setUpdateInfo(null)
@@ -143,9 +197,9 @@ function SettingsPage() {
const result = await window.electronAPI.app.checkForUpdates()
if (result.hasUpdate) {
setUpdateInfo(result)
showMessage(`发现新版${result.version}`, true)
showMessage(`发现新版${result.version}`, true)
} else {
showMessage('当前已是最新版', true)
showMessage('当前已是最新版', true)
}
} catch (e) {
showMessage(`检查更新失败: ${e}`, false)
@@ -257,6 +311,60 @@ function SettingsPage() {
const handleSelectWhisperModelDir = async () => {
try {
const result = await dialog.openFile({ title: '选择 Whisper 模型下载目录', properties: ['openDirectory'] })
if (!result.canceled && result.filePaths.length > 0) {
const dir = result.filePaths[0]
setWhisperModelDir(dir)
await configService.setWhisperModelDir(dir)
showMessage('已选择 Whisper 模型目录', true)
}
} catch (e) {
showMessage('选择目录失败', false)
}
}
const handleWhisperModelChange = async (value: string) => {
setWhisperModelName(value)
setWhisperDownloadProgress(0)
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
})
if (result.success) {
setWhisperDownloadProgress(100)
showMessage('Whisper 模型下载完成', true)
await refreshWhisperStatus(whisperModelName, whisperModelDir)
} else {
showMessage(result.error || 'Whisper 模型下载失败', false)
}
} catch (e) {
showMessage(`Whisper 模型下载失败: ${e}`, false)
} finally {
setIsWhisperDownloading(false)
}
}
const handleResetWhisperModelDir = async () => {
setWhisperModelDir('')
await configService.setWhisperModelDir('')
}
const handleAutoGetDbKey = async () => {
if (isFetchingDbKey) return
setIsFetchingDbKey(true)
@@ -367,6 +475,9 @@ function SettingsPage() {
} else {
await configService.setImageAesKey('')
}
await configService.setWhisperModelName(whisperModelName)
await configService.setWhisperModelDir(whisperModelDir)
await configService.setWhisperDownloadSource(whisperDownloadSource)
await configService.setOnboardingDone(true)
showMessage('配置保存成功,正在测试连接...', true)
@@ -387,7 +498,7 @@ function SettingsPage() {
}
const handleClearConfig = async () => {
const confirmed = window.confirm('确定要清除当前配置吗?清除后需要重新完成首次配置')
const confirmed = window.confirm('确定要清除当前配置吗?清除后需要重新完成首次配置')
if (!confirmed) return
setIsLoadingState(true)
setLoading(true, '正在清除配置...')
@@ -402,6 +513,12 @@ function SettingsPage() {
setWxid('')
setCachePath('')
setLogEnabled(false)
setWhisperModelName('base')
setWhisperModelDir('')
setWhisperDownloadSource('tsinghua')
setWhisperModelStatus(null)
setWhisperDownloadProgress(0)
setIsWhisperDownloading(false)
setDbConnected(false)
await window.electronAPI.window.openOnboardingWindow()
} catch (e) {
@@ -608,16 +725,6 @@ function SettingsPage() {
{isFetchingImageKey && <div className="form-hint status-text">...</div>}
</div>
<div className="form-group">
<label> <span className="optional">()</span></label>
<span className="form-hint">使</span>
<input type="text" placeholder="留空使用默认目录" value={cachePath} onChange={(e) => setCachePath(e.target.value)} />
<div className="btn-row">
<button className="btn btn-secondary" onClick={handleSelectCachePath}><FolderOpen size={16} /> </button>
<button className="btn btn-secondary" onClick={() => setCachePath('')}><RotateCcw size={16} /> </button>
</div>
</div>
<div className="form-group">
<label></label>
<span className="form-hint"> WCDB 便</span>
@@ -650,12 +757,82 @@ function SettingsPage() {
</div>
</div>
)
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>
<span className="form-hint"></span>
<input
type="text"
placeholder="留空使用默认目录"
value={whisperModelDir}
onChange={(e) => setWhisperModelDir(e.target.value)}
onBlur={() => configService.setWhisperModelDir(whisperModelDir)}
/>
<div className="btn-row">
<button className="btn btn-secondary" onClick={handleSelectWhisperModelDir}><FolderOpen size={16} /> </button>
<button className="btn btn-secondary" onClick={handleResetWhisperModelDir}><RotateCcw size={16} /> </button>
</div>
<div className="whisper-status-line">
<span className={`status ${whisperModelStatus?.exists ? 'ok' : 'warn'}`}>
{whisperModelStatus?.exists ? '已下载' : '未下载'}
</span>
{whisperModelStatus?.path && <span className="path">{whisperModelStatus.path}</span>}
</div>
{isWhisperDownloading ? (
<div className="whisper-progress">
<div className="progress-bar">
<div className="progress-fill" style={{ width: `${whisperDownloadProgress}%` }} />
</div>
<span>{whisperDownloadProgress.toFixed(0)}%</span>
</div>
) : (
<button className="btn btn-primary" onClick={handleDownloadWhisperModel}>
<Download size={16} />
</button>
)}
</div>
</div>
)
const renderCacheTab = () => (
<div className="tab-content">
<p className="section-desc"></p>
<div className="form-group">
<label> <span className="optional">()</span></label>
<span className="form-hint">使</span>
<input type="text" placeholder="留空使用默认目录" value={cachePath} onChange={(e) => setCachePath(e.target.value)} />
<div className="btn-row">
<button className="btn btn-secondary" onClick={handleSelectCachePath}><FolderOpen size={16} /> </button>
<button className="btn btn-secondary" onClick={() => setCachePath('')}><RotateCcw size={16} /> </button>
</div>
</div>
<div className="btn-row">
<button className="btn btn-secondary" onClick={handleClearAnalyticsCache} disabled={isClearingCache}>
<Trash2 size={16} />
@@ -664,8 +841,7 @@ function SettingsPage() {
<Trash2 size={16} />
</button>
<button className="btn btn-danger" onClick={handleClearAllCache} disabled={isClearingCache}>
<Trash2 size={16} />
</button>
<Trash2 size={16} /> </button>
</div>
<div className="divider" />
<p className="section-desc"></p>
@@ -690,7 +866,7 @@ function SettingsPage() {
<div className="about-update">
{updateInfo?.hasUpdate ? (
<>
<p className="update-hint"> v{updateInfo.version} </p>
<p className="update-hint"> v{updateInfo.version} </p>
{isDownloading ? (
<div className="download-progress">
<div className="progress-bar">
@@ -747,7 +923,7 @@ function SettingsPage() {
onClick={() => handleSelectWxid(opt.wxid)}
>
<span className="wxid-id">{opt.wxid}</span>
<span className="wxid-date">: {new Date(opt.modifiedTime).toLocaleString()}</span>
<span className="wxid-date"> {new Date(opt.modifiedTime).toLocaleString()}</span>
</div>
))}
</div>
@@ -782,6 +958,7 @@ function SettingsPage() {
<div className="settings-body">
{activeTab === 'appearance' && renderAppearanceTab()}
{activeTab === 'database' && renderDatabaseTab()}
{activeTab === 'whisper' && renderWhisperTab()}
{activeTab === 'cache' && renderCacheTab()}
{activeTab === 'about' && renderAboutTab()}
</div>
@@ -790,3 +967,5 @@ function SettingsPage() {
}
export default SettingsPage

View File

@@ -17,7 +17,10 @@ export const CONFIG_KEYS = {
ONBOARDING_DONE: 'onboardingDone',
LLM_MODEL_PATH: 'llmModelPath',
IMAGE_XOR_KEY: 'imageXorKey',
IMAGE_AES_KEY: 'imageAesKey'
IMAGE_AES_KEY: 'imageAesKey',
WHISPER_MODEL_NAME: 'whisperModelName',
WHISPER_MODEL_DIR: 'whisperModelDir',
WHISPER_DOWNLOAD_SOURCE: 'whisperDownloadSource'
} as const
// 获取解密密钥
@@ -144,6 +147,39 @@ export async function setLlmModelPath(path: string): Promise<void> {
await config.set(CONFIG_KEYS.LLM_MODEL_PATH, path)
}
// 获取 Whisper 模型名称
export async function getWhisperModelName(): Promise<string | null> {
const value = await config.get(CONFIG_KEYS.WHISPER_MODEL_NAME)
return (value as string) || null
}
// 设置 Whisper 模型名称
export async function setWhisperModelName(name: string): Promise<void> {
await config.set(CONFIG_KEYS.WHISPER_MODEL_NAME, name)
}
// 获取 Whisper 模型目录
export async function getWhisperModelDir(): Promise<string | null> {
const value = await config.get(CONFIG_KEYS.WHISPER_MODEL_DIR)
return (value as string) || null
}
// 设置 Whisper 模型目录
export async function setWhisperModelDir(dir: string): Promise<void> {
await config.set(CONFIG_KEYS.WHISPER_MODEL_DIR, dir)
}
// 获取 Whisper 下载源
export async function getWhisperDownloadSource(): Promise<string | null> {
const value = await config.get(CONFIG_KEYS.WHISPER_DOWNLOAD_SOURCE)
return (value as string) || null
}
// 设置 Whisper 下载源
export async function setWhisperDownloadSource(source: string): Promise<void> {
await config.set(CONFIG_KEYS.WHISPER_DOWNLOAD_SOURCE, source)
}
// 清除所有配置
export async function clearConfig(): Promise<void> {
await config.clear()

View File

@@ -95,6 +95,7 @@ export interface ElectronAPI {
}>
getImageData: (sessionId: string, msgId: string) => Promise<{ success: boolean; data?: string; error?: string }>
getVoiceData: (sessionId: string, msgId: string) => Promise<{ success: boolean; data?: string; error?: string }>
getVoiceTranscript: (sessionId: string, msgId: string) => Promise<{ success: boolean; transcript?: string; error?: string }>
}
image: {
@@ -295,6 +296,11 @@ export interface ElectronAPI {
error?: string
}>
}
whisper: {
downloadModel: (payload: { modelName: string; downloadDir?: string; source?: string }) => Promise<{ success: boolean; path?: string; error?: string }>
getModelStatus: (payload: { modelName: string; downloadDir?: string }) => Promise<{ success: boolean; exists?: boolean; path?: string; sizeBytes?: number; error?: string }>
onDownloadProgress: (callback: (payload: { modelName: string; downloadedBytes: number; totalBytes?: number; percent?: number }) => void) => () => void
}
}
export interface ExportOptions {