完善在线语音转写接入,并统一替换 AI / MCP 图标体系

本次提交主要围绕在线语音转写能力扩展与图标体系统一展开,重点补齐了阿里云 Qwen-ASR 的接入能力,并将聊天页、AI 摘要与侧边栏中的相关图标逐步切换到 lobe-icons 体系。

1. 在线语音转写能力增强
- 在现有 CPU / GPU 模式之外,完善在线 STT 模式的配置、持久化与主进程分发逻辑。
- 新增独立的在线转写服务 `voiceTranscribeServiceOnline`,统一管理在线 provider 的配置校验、请求组装、错误处理与结果返回。
- 保持原有转写缓存机制不变,继续复用 `sessionId + createTime` 作为缓存键,避免重复调用第三方服务。

2. 新增并完善阿里云 Qwen-ASR 支持
- 在线 STT 提供商扩展为:`OpenAI 兼容`、`阿里云 Qwen-ASR`、`自定义接口`。
- 阿里云作为独立 provider 接入,不与 OpenAI 兼容或自定义接口混用协议,避免后续逻辑继续耦合和膨胀。
- 阿里云请求改走 `chat/completions + input_audio` 协议,并支持流式返回。
- 主进程将阿里云流式增量结果转发到现有 `stt:partialResult` 通道,聊天页可实时显示逐步生成的转写文本。
- 阿里云模型信息同步收敛到当前可用范围,默认模型调整为 `qwen3-asr-flash`,并在设置页明确提示 `qwen3-asr-flash` 与 `qwen3-asr-flash-filetrans` 两个模型选项。

3. 在线模式设置页体验优化
- 设置页新增在线 STT 的完整配置区域,包括 provider 切换、接口 URL、API Key、模型名、语言、超时和批量并发数。
- 不同 provider 的提示文案、默认 URL、默认模型与占位内容按协议能力区分展示,减少误配。
- 在线配置测试入口支持按当前表单配置即时校验,而不是只能测试已保存配置。
- 在线模式相关布局、下拉、自定义选择器、数值步进器等样式补齐,避免退化成原生控件观感。

4. 聊天页转写交互优化
- 在线模式下,聊天页会根据当前 provider 做更准确的判断与展示,不再统一写死为 OpenAI 在线转写。
- 阿里云转写等待态改为使用千问图标作为唯一视觉标识,不再显示“正在转写”文字,突出 provider 识别性。
- 千问图标尺寸与呼吸动画一并优化,使等待态更明显、更稳定。

5. AI 摘要与 MCP 图标统一到 lobe-icons
- 引入 `@lobehub/icons` 及其依赖链,并新增前端共享组件 `AIProviderLogo`,统一管理 AI 提供商图标渲染。
- AI 摘要设置页与聊天页进入的 AI 摘要窗口,均改为优先使用 lobe-icons 渲染 provider 图标,仅在未命中映射时回退本地 logo。
- 为常用 provider 增加显式映射,包括 OpenAI、通义千问、Gemini、Kimi、硅基流动、小米 MiMo、腾讯元宝、智谱、DeepSeek、豆包、Ollama、xAI,以及自定义 provider 对应的 Clipdrop 图标。
- 侧边栏中的 `MCP 服务` 图标替换为 lobe-icons 提供的 `Model Context Protocol` 图标,统一整体视觉语言。

整体来看,这次提交解决了在线转写从“能配置”到“能稳定使用、能看出当前 provider、能流式显示”的关键链路问题,同时也把 AI / LLM 相关图标从分散的本地静态文件逐步收拢到统一的组件化图标库,后续继续扩展 provider 时会更容易维护。
This commit is contained in:
ILoveBingLu
2026-04-08 01:26:40 +08:00
parent c77a019a7d
commit e8ebedd62e
16 changed files with 8394 additions and 67 deletions

View File

@@ -26,6 +26,7 @@ import { videoService } from './services/videoService'
import { voiceTranscribeService } from './services/voiceTranscribeService'
import { voiceTranscribeServiceWhisper } from './services/voiceTranscribeServiceWhisper'
import { voiceTranscribeServiceOnline } from './services/voiceTranscribeServiceOnline'
import { systemAuthService } from './services/systemAuthService'
import { shortcutService } from './services/shortcutService'
import { httpApiService } from './services/httpApiService'
@@ -3366,6 +3367,11 @@ function registerIpcHandlers() {
whisperModelType as any,
'auto' // 自动识别语言
)
} else if (sttMode === 'online') {
console.log('[Main] 使用在线 STT 模式')
result = await voiceTranscribeServiceOnline.transcribeWavBuffer(wavData, (text) => {
win?.webContents.send('stt:partialResult', text)
})
} else {
// 使用 SenseVoice CPU 模式
console.log('[Main] 使用 SenseVoice CPU 模式')
@@ -3406,6 +3412,21 @@ function registerIpcHandlers() {
}
})
ipcMain.handle('stt-online:test-config', async (_, overrides?: {
provider?: 'openai-compatible' | 'aliyun-qwen-asr' | 'custom'
apiKey?: string
baseURL?: string
model?: string
language?: string
timeoutMs?: number
}) => {
try {
return await voiceTranscribeServiceOnline.testConfig(overrides)
} catch (e) {
return { success: false, error: String(e) }
}
})
// ========== Whisper GPU 加速 ==========
// 清除模型

View File

@@ -454,6 +454,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
getModelStatus: () => ipcRenderer.invoke('stt:getModelStatus'),
downloadModel: () => ipcRenderer.invoke('stt:downloadModel'),
transcribe: (wavBase64: string, sessionId: string, createTime: number, force?: boolean) => ipcRenderer.invoke('stt:transcribe', wavBase64, sessionId, createTime, force),
testOnlineConfig: (overrides?: { provider?: 'openai-compatible' | 'aliyun-qwen-asr' | 'custom'; apiKey?: string; baseURL?: string; model?: string; language?: string; timeoutMs?: number }) =>
ipcRenderer.invoke('stt-online:test-config', overrides),
onDownloadProgress: (callback: (progress: { modelName: string; downloadedBytes: number; totalBytes?: number; percent?: number }) => void) => {
ipcRenderer.on('stt:downloadProgress', (_, progress) => callback(progress))
return () => ipcRenderer.removeAllListeners('stt:downloadProgress')

View File

@@ -65,8 +65,15 @@ interface ConfigSchema {
// STT 相关
sttLanguages: string[]
sttModelType: 'int8' | 'float32'
sttMode: 'cpu' | 'gpu' // STT 模式CPU (SenseVoice) 或 GPU (Whisper)
sttMode: 'cpu' | 'gpu' | 'online' // STT 模式CPU / GPU / 在线
whisperModelType: 'tiny' | 'base' | 'small' | 'medium' // Whisper 模型类型
sttOnlineProvider: 'openai-compatible' | 'aliyun-qwen-asr' | 'custom'
sttOnlineApiKey: string
sttOnlineBaseURL: string
sttOnlineModel: string
sttOnlineLanguage: string
sttOnlineTimeoutMs: number
sttOnlineMaxConcurrency: number
// 日志相关
logLevel: string
@@ -134,6 +141,13 @@ const defaults: ConfigSchema = {
sttModelType: 'int8',
sttMode: 'cpu', // 默认使用 CPU 模式
whisperModelType: 'small', // 默认使用 small 模型
sttOnlineProvider: 'openai-compatible',
sttOnlineApiKey: '',
sttOnlineBaseURL: 'https://api.openai.com/v1',
sttOnlineModel: 'gpt-4o-mini-transcribe',
sttOnlineLanguage: 'auto',
sttOnlineTimeoutMs: 60000,
sttOnlineMaxConcurrency: 2,
agreementVersion: 0,
activationData: '',
logLevel: 'WARN', // 默认只记录警告和错误

View File

@@ -0,0 +1,359 @@
import { ConfigService } from './config'
type OnlineProvider = 'openai-compatible' | 'aliyun-qwen-asr' | 'custom'
export interface OnlineTranscribeConfig {
provider: OnlineProvider
apiKey: string
baseURL: string
model: string
language: string
timeoutMs: number
}
type OnlineTranscribeOverrides = Partial<OnlineTranscribeConfig>
export class VoiceTranscribeServiceOnline {
private configService = new ConfigService()
private extractAliyunTextFromContent(content: any): string {
if (!content) return ''
if (typeof content === 'string') return content
if (Array.isArray(content)) {
return content
.map((item) => {
if (typeof item === 'string') return item
return item?.text || item?.transcript || item?.content || ''
})
.join('')
}
return String(content?.text || content?.transcript || content?.content || '')
}
private async transcribeWithAliyun(
wavData: Buffer,
config: OnlineTranscribeConfig,
signal: AbortSignal,
onPartial?: (text: string) => void
): Promise<{ success: boolean; transcript?: string; error?: string }> {
const dataUrl = `data:audio/wav;base64,${wavData.toString('base64')}`
const response = await fetch(this.resolveRequestUrl(config), {
method: 'POST',
headers: {
Authorization: `Bearer ${config.apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: config.model,
stream: true,
messages: [
{
role: 'user',
content: [
{
type: 'input_audio',
input_audio: {
data: dataUrl,
format: 'wav'
}
}
]
}
]
}),
signal
})
if (!response.ok) {
let payload: any = null
try {
payload = await response.json()
} catch {
payload = null
}
if (response.status === 401 || response.status === 403) {
return { success: false, error: '阿里云在线转写认证失败,请检查 API Key' }
}
if (response.status === 429) {
return { success: false, error: '阿里云在线转写请求过于频繁或额度不足,请稍后重试' }
}
const message = payload?.error?.message || payload?.message || `HTTP ${response.status}`
return { success: false, error: `阿里云在线转写失败: ${message}` }
}
if (!response.body) {
return { success: false, error: '阿里云在线转写未返回可读取的数据流' }
}
const reader = response.body.getReader()
const decoder = new TextDecoder('utf-8')
let buffer = ''
let transcript = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const events = buffer.split('\n\n')
buffer = events.pop() || ''
for (const event of events) {
const dataLines = event
.split('\n')
.map((line) => line.trim())
.filter((line) => line.startsWith('data:'))
for (const line of dataLines) {
const data = line.slice(5).trim()
if (!data || data === '[DONE]') continue
try {
const chunk = JSON.parse(data)
const delta = chunk?.choices?.[0]?.delta
const text = this.extractAliyunTextFromContent(delta?.content)
if (text) {
transcript += text
onPartial?.(transcript)
}
} catch {
// ignore malformed chunk
}
}
}
}
transcript = transcript.trim()
if (!transcript) {
return { success: false, error: '阿里云接口返回成功,但未提取到识别文本' }
}
return { success: true, transcript }
}
private resolveTranscriptionUrl(rawUrl: string): string {
const trimmed = rawUrl.trim().replace(/\/+$/, '')
if (!trimmed) return ''
try {
const url = new URL(trimmed)
if (url.pathname.endsWith('/audio/transcriptions')) {
return url.toString()
}
url.pathname = `${url.pathname.replace(/\/+$/, '')}/audio/transcriptions`
return url.toString()
} catch {
return trimmed
}
}
private resolveModelsUrl(rawUrl: string): string {
const trimmed = rawUrl.trim().replace(/\/+$/, '')
if (!trimmed) return ''
try {
const url = new URL(trimmed)
if (url.pathname.endsWith('/audio/transcriptions')) {
url.pathname = url.pathname.replace(/\/audio\/transcriptions$/, '/models')
} else {
url.pathname = `${url.pathname.replace(/\/+$/, '')}/models`
}
return url.toString()
} catch {
return trimmed
}
}
private resolveAliyunChatUrl(rawUrl: string): string {
const trimmed = rawUrl.trim().replace(/\/+$/, '')
if (!trimmed) return ''
try {
const url = new URL(trimmed)
if (url.pathname.endsWith('/chat/completions')) {
return url.toString()
}
url.pathname = `${url.pathname.replace(/\/+$/, '')}/chat/completions`
return url.toString()
} catch {
return trimmed
}
}
getConfig(): OnlineTranscribeConfig {
return {
provider: (this.configService.get('sttOnlineProvider') as OnlineProvider) || 'openai-compatible',
apiKey: String(this.configService.get('sttOnlineApiKey') || '').trim(),
baseURL: String(this.configService.get('sttOnlineBaseURL') || '').trim(),
model: String(this.configService.get('sttOnlineModel') || '').trim(),
language: String(this.configService.get('sttOnlineLanguage') || 'auto').trim() || 'auto',
timeoutMs: Number(this.configService.get('sttOnlineTimeoutMs') || 60000) || 60000
}
}
validateConfig(config = this.getConfig()): { valid: boolean; error?: string } {
if (!config.baseURL) {
return { valid: false, error: '请先配置在线转写接口 URL' }
}
try {
new URL(config.baseURL)
} catch {
return { valid: false, error: '在线转写接口 URL 格式无效' }
}
if (!config.apiKey) {
return { valid: false, error: '请先配置在线转写 API Key' }
}
if (!config.model) {
return { valid: false, error: '请先配置在线转写模型名称' }
}
if (!Number.isFinite(config.timeoutMs) || config.timeoutMs < 5000) {
return { valid: false, error: '在线转写超时时间配置无效' }
}
return { valid: true }
}
private resolveRequestUrl(config: OnlineTranscribeConfig): string {
if (config.provider === 'aliyun-qwen-asr') {
return this.resolveAliyunChatUrl(config.baseURL)
}
return config.provider === 'custom'
? config.baseURL.trim()
: this.resolveTranscriptionUrl(config.baseURL)
}
private resolveTestUrl(config: OnlineTranscribeConfig): string {
if (config.provider === 'aliyun-qwen-asr') {
return this.resolveModelsUrl(config.baseURL)
}
return config.provider === 'custom'
? config.baseURL.trim()
: this.resolveModelsUrl(config.baseURL)
}
async testConfig(overrides?: OnlineTranscribeOverrides): Promise<{ success: boolean; error?: string }> {
const config = { ...this.getConfig(), ...overrides }
const validation = this.validateConfig(config)
if (!validation.valid) {
return { success: false, error: validation.error }
}
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), config.timeoutMs)
try {
const response = await fetch(this.resolveTestUrl(config), {
method: 'GET',
headers: {
Authorization: `Bearer ${config.apiKey}`
},
signal: controller.signal
})
if (response.ok) {
return { success: true }
}
if (response.status === 401 || response.status === 403) {
return { success: false, error: '在线转写认证失败,请检查 API Key' }
}
if (config.provider === 'custom' && [400, 405, 415].includes(response.status)) {
return { success: true }
}
if (response.status === 404) {
return {
success: false,
error:
config.provider === 'custom'
? '自定义接口 URL 不可用,请确认你填写的是完整接口地址'
: config.provider === 'aliyun-qwen-asr'
? '阿里云接口 URL 不可用,请确认是否为 DashScope 兼容入口地址'
: '接口 URL 不可用,请确认它是否为 OpenAI 兼容接口或对应的 /v1 地址'
}
}
return { success: false, error: `在线转写配置测试失败: HTTP ${response.status}` }
} catch (e) {
if ((e as Error).name === 'AbortError') {
return { success: false, error: '在线转写配置测试超时,请检查网络或缩短接口链路' }
}
return { success: false, error: `在线转写配置测试失败: ${String(e)}` }
} finally {
clearTimeout(timeout)
}
}
async transcribeWavBuffer(
wavData: Buffer,
onPartial?: (text: string) => void
): Promise<{ success: boolean; transcript?: string; error?: string }> {
const config = this.getConfig()
const validation = this.validateConfig(config)
if (!validation.valid) {
return { success: false, error: validation.error }
}
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), config.timeoutMs)
try {
if (config.provider === 'aliyun-qwen-asr') {
return await this.transcribeWithAliyun(wavData, config, controller.signal, onPartial)
}
const form = new FormData()
const file = new Blob([new Uint8Array(wavData)], { type: 'audio/wav' })
form.append('file', file, 'voice.wav')
form.append('model', config.model)
if (config.language && config.language !== 'auto') {
form.append('language', config.language)
}
form.append('response_format', 'json')
const response = await fetch(this.resolveRequestUrl(config), {
method: 'POST',
headers: {
Authorization: `Bearer ${config.apiKey}`
},
body: form,
signal: controller.signal
})
let payload: any = null
try {
payload = await response.json()
} catch {
payload = null
}
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
return { success: false, error: '在线转写认证失败,请检查 API Key' }
}
if (response.status === 429) {
return { success: false, error: '在线转写请求过于频繁或额度不足,请稍后重试' }
}
const message = payload?.error?.message || payload?.message || `HTTP ${response.status}`
return { success: false, error: `在线转写失败: ${message}` }
}
const transcript = String(payload?.text || payload?.transcript || '').trim()
if (!transcript) {
return { success: false, error: '在线转写成功但未返回文本结果' }
}
return { success: true, transcript }
} catch (e) {
if ((e as Error).name === 'AbortError') {
return { success: false, error: '在线转写请求超时,请稍后重试' }
}
return { success: false, error: `在线转写失败: ${String(e)}` }
} finally {
clearTimeout(timeout)
}
}
}
export const voiceTranscribeServiceOnline = new VoiceTranscribeServiceOnline()

7140
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -33,6 +33,9 @@
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@lobehub/fluent-emoji": "^4.1.0",
"@lobehub/icons": "^5.3.0",
"@lobehub/ui": "^5.6.5",
"@modelcontextprotocol/sdk": "^1.27.1",
"@mui/material": "^7.3.9",
"@types/dompurify": "^3.0.5",
@@ -40,6 +43,7 @@
"@types/react-virtualized-auto-sizer": "^1.0.4",
"@types/react-window": "^1.8.8",
"@xmldom/xmldom": "^0.9.6",
"antd": "^6.3.5",
"better-sqlite3": "^12.5.0",
"dom-to-image-more": "^3.7.2",
"dompurify": "^3.3.1",
@@ -47,6 +51,7 @@
"echarts-for-react": "^3.0.2",
"electron-store": "^10.0.0",
"electron-updater": "^6.3.9",
"es-toolkit": "^1.45.1",
"ffmpeg-static": "^5.3.0",
"fzstd": "^0.1.1",
"html2canvas": "^1.4.1",

View File

@@ -10,7 +10,8 @@ import ListItemButton from '@mui/material/ListItemButton'
import ListItemIcon from '@mui/material/ListItemIcon'
import Tooltip from '@mui/material/Tooltip'
import Typography from '@mui/material/Typography'
import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, SquareChevronLeft, SquareChevronRight, Download, Aperture, Network, Boxes } from 'lucide-react'
import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, SquareChevronLeft, SquareChevronRight, Download, Aperture, Network } from 'lucide-react'
import { MCP } from '@lobehub/icons'
import { useAppStore } from '../stores/appStore'
const DRAWER_WIDTH = 220
@@ -82,7 +83,7 @@ function Sidebar() {
{ key: 'export', label: '导出数据', icon: <Download size={20} />, type: 'route', path: '/export' },
{ key: 'data-management', label: '数据管理', icon: <Database size={20} />, type: 'route', path: '/data-management' },
{ key: 'open-api', label: '开放接口', icon: <Network size={20} />, type: 'route', path: '/open-api' },
{ key: 'mcp', label: 'MCP 服务', icon: <Boxes size={20} />, type: 'route', path: '/mcp' },
{ key: 'mcp', label: 'MCP 服务', icon: <MCP size={20} />, type: 'route', path: '/mcp' },
]
const navItemSx = {

View File

@@ -0,0 +1,95 @@
import { Clipdrop, DeepSeek, Doubao, Gemini, Kimi, Ollama, OpenAI, ProviderIcon, Qwen, SiliconCloud, XiaomiMiMo, XAI, Yuanbao, Zhipu } from '@lobehub/icons'
type AIProviderLogoProps = {
providerId?: string
logo?: string
alt: string
className?: string
size?: number
}
const SUPPORTED_PROVIDER_IDS = new Set([
'openai',
'gemini',
'zhipu',
'qwen',
'deepseek',
'doubao',
'kimi',
'ollama',
'xai',
'tencent'
])
function normalizeProviderId(providerId?: string) {
if (!providerId) return ''
if (providerId === 'siliconflow') return 'siliconcloud'
if (providerId === 'xiaomi') return 'xiaomimimo'
return providerId
}
export default function AIProviderLogo({ providerId, logo, alt, className, size = 24 }: AIProviderLogoProps) {
const normalizedProviderId = normalizeProviderId(providerId)
if (normalizedProviderId === 'custom') {
return <Clipdrop size={size} className={className} />
}
if (normalizedProviderId === 'gemini') {
return <Gemini size={size} className={className} />
}
if (normalizedProviderId === 'kimi') {
return <Kimi size={size} className={className} />
}
if (normalizedProviderId === 'siliconcloud') {
return <SiliconCloud size={size} className={className} />
}
if (normalizedProviderId === 'xiaomimimo') {
return <XiaomiMiMo size={size} className={className} />
}
if (normalizedProviderId === 'tencent') {
return <Yuanbao size={size} className={className} />
}
if (normalizedProviderId === 'openai') {
return <OpenAI size={size} className={className} />
}
if (normalizedProviderId === 'qwen') {
return <Qwen size={size} className={className} />
}
if (normalizedProviderId === 'zhipu') {
return <Zhipu size={size} className={className} />
}
if (normalizedProviderId === 'deepseek') {
return <DeepSeek size={size} className={className} />
}
if (normalizedProviderId === 'doubao') {
return <Doubao size={size} className={className} />
}
if (normalizedProviderId === 'ollama') {
return <Ollama size={size} className={className} />
}
if (normalizedProviderId === 'xai') {
return <XAI size={size} className={className} />
}
if (normalizedProviderId && SUPPORTED_PROVIDER_IDS.has(normalizedProviderId)) {
return <ProviderIcon provider={normalizedProviderId} type="color" size={size} className={className} />
}
if (logo) {
return <img src={logo} alt={alt} className={className} />
}
return null
}

View File

@@ -3,6 +3,7 @@ import { Eye, EyeOff, Sparkles, Check, ChevronDown, ChevronUp, Zap, Star, FileTe
import { getAIProviders, type AIProviderInfo } from '../../types/ai'
import { marked } from 'marked'
import DOMPurify from 'dompurify'
import AIProviderLogo from './AIProviderLogo'
import './AISummarySettings.scss'
interface CustomSelectProps {
@@ -496,7 +497,13 @@ function AISummarySettings({
<div className="current-config-card">
<div className="config-provider-info">
{currentProvider?.logo ? (
<img src={currentProvider.logo} alt={currentProvider.displayName} className="provider-logo-large" />
<AIProviderLogo
providerId={currentProvider.id}
logo={currentProvider.logo}
alt={currentProvider.displayName}
className="provider-logo-large"
size={40}
/>
) : (
<div className="provider-logo-skeleton-large" />
)}
@@ -813,7 +820,13 @@ function AISummarySettings({
onClick={() => handleSelectProvider(p.id)}
>
{p.logo ? (
<img src={p.logo} alt={p.displayName} className="provider-logo" />
<AIProviderLogo
providerId={p.id}
logo={p.logo}
alt={p.displayName}
className="provider-logo"
size={18}
/>
) : (
<div className="provider-logo-skeleton" />
)}

View File

@@ -3,14 +3,15 @@ import { Copy, Download, RefreshCw, Loader2, Send, ArrowLeft, Trash2, LoaderPinw
import { marked } from 'marked'
import DOMPurify from 'dompurify'
import { TIME_RANGE_OPTIONS, type SummaryResult } from '../types/ai'
import AIProviderLogo from '../components/ai/AIProviderLogo'
import './AISummaryWindow.scss'
function AISummaryWindow() {
const [sessionId, setSessionId] = useState<string>('')
const [sessionName, setSessionName] = useState<string>('')
const [avatarUrl, setAvatarUrl] = useState<string>('')
const [aiProviderLogo, setAiProviderLogo] = useState<string>('')
const [resultProviderInfo, setResultProviderInfo] = useState<{ logo: string; displayName: string } | null>(null)
const [aiProviderInfo, setAiProviderInfo] = useState<{ id: string; logo: string; displayName: string } | null>(null)
const [resultProviderInfo, setResultProviderInfo] = useState<{ id: string; logo: string; displayName: string } | null>(null)
const [timeRangeDays, setTimeRangeDays] = useState<number>(7)
const [customDays, setCustomDays] = useState<string>('')
const [customRequirement, setCustomRequirement] = useState<string>('')
@@ -91,8 +92,12 @@ function AISummaryWindow() {
const providers = await getAIProviders()
const providerInfo = providers.find(p => p.id === currentProvider)
if (providerInfo?.logo) {
setAiProviderLogo(providerInfo.logo)
if (providerInfo) {
setAiProviderInfo({
id: providerInfo.id,
logo: providerInfo.logo || '',
displayName: providerInfo.displayName
})
}
} catch (e) {
console.error('加载 AI 提供商 logo 失败:', e)
@@ -108,6 +113,7 @@ function AISummaryWindow() {
if (providerInfo) {
setResultProviderInfo({
id: providerInfo.id,
logo: providerInfo.logo || '',
displayName: providerInfo.displayName
})
@@ -364,11 +370,17 @@ function AISummaryWindow() {
{avatarUrl && (
<img src={avatarUrl} alt="" className="session-avatar" />
)}
{aiProviderLogo && (
{aiProviderInfo && (
<>
<span className="multiply-symbol">×</span>
<div className="ai-provider-badge">
<img src={aiProviderLogo} alt="AI" className="ai-provider-logo" />
<AIProviderLogo
providerId={aiProviderInfo.id}
logo={aiProviderInfo.logo}
alt={aiProviderInfo.displayName}
className="ai-provider-logo"
size={24}
/>
</div>
</>
)}
@@ -661,7 +673,12 @@ function AISummaryWindow() {
<div className="disclaimer-content">
{resultProviderInfo.logo && (
<div className="ai-provider-badge-small">
<img src={resultProviderInfo.logo} alt="AI" />
<AIProviderLogo
providerId={resultProviderInfo.id}
logo={resultProviderInfo.logo}
alt={resultProviderInfo.displayName}
size={20}
/>
</div>
)}
<span className="disclaimer-text">

View File

@@ -1373,6 +1373,14 @@
.spin {
animation: spin 1s linear infinite;
}
.stt-provider-loading-icon {
display: block;
flex-shrink: 0;
width: 18px;
height: 18px;
animation: sttIconPulse 1.4s ease-in-out infinite;
}
}
.emoji-unavailable {
@@ -3523,6 +3531,18 @@ video::-webkit-media-controls-fullscreen-button {
}
}
@keyframes sttIconPulse {
0%, 100% {
transform: scale(1);
opacity: 0.75;
}
50% {
transform: scale(1.12);
opacity: 1;
}
}
.copy-toast.top-toast.success {
border-color: rgba(16, 185, 129, 0.28);
box-shadow: 0 8px 24px rgba(16, 185, 129, 0.18);
@@ -3613,6 +3633,18 @@ video::-webkit-media-controls-fullscreen-button {
max-width: 100%;
word-wrap: break-word;
animation: fadeIn 0.3s ease;
.stt-provider-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
line-height: 1;
img {
display: block;
}
}
}
// 发送方的样式调整

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { createPortal } from 'react-dom'
import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, Info, Calendar, Database, Hash, Image as ImageIcon, Play, Video, Copy, ZoomIn, CheckSquare, Check, Edit, Link, Sparkles, FileText, FileArchive, Users, Mic, CheckCircle, XCircle, Download, Phone, Aperture, MapPin, UserRound } from 'lucide-react'
import { Qwen } from '@lobehub/icons'
import { useChatStore } from '../stores/chatStore'
import { useUpdateStatusStore } from '../stores/updateStatusStore'
import ChatBackground from '../components/ChatBackground'
@@ -38,6 +39,28 @@ interface SessionDetail {
messageTables: { dbName: string; tableName: string; count: number }[]
}
async function checkOnlineSttConfigReady() {
const [apiKey, baseURL, model] = await Promise.all([
window.electronAPI.config.get('sttOnlineApiKey'),
window.electronAPI.config.get('sttOnlineBaseURL'),
window.electronAPI.config.get('sttOnlineModel')
])
const missing: string[] = []
if (!String(baseURL || '').trim()) missing.push('接口 URL')
if (!String(apiKey || '').trim()) missing.push('API Key')
if (!String(model || '').trim()) missing.push('模型名称')
if (missing.length > 0) {
return {
ready: false,
error: `在线转写配置不完整:缺少${missing.join('、')},请先到设置页完善在线模式配置`
}
}
return { ready: true }
}
// 头像组件 - 支持骨架屏加载和懒加载
function SessionAvatar({ session, size = 48 }: { session: ChatSession; size?: number }) {
const [imageLoaded, setImageLoaded] = useState(false)
@@ -1022,6 +1045,7 @@ function ChatPage(_props: ChatPageProps) {
const sttMode = await window.electronAPI.config.get('sttMode') || 'cpu'
let modelExists = false
let concurrency = 5
if (sttMode === 'gpu') {
const whisperModelType = (await window.electronAPI.config.get('whisperModelType') as string) || 'small'
const modelStatus = await window.electronAPI.sttWhisper.checkModel(whisperModelType)
@@ -1033,6 +1057,16 @@ function ChatPage(_props: ChatPageProps) {
setShowBatchProgress(false)
return
}
} else if (sttMode === 'online') {
const onlineReady = await checkOnlineSttConfigReady()
if (!onlineReady.ready) {
alert(onlineReady.error)
setIsBatchTranscribing(false)
setShowBatchProgress(false)
return
}
const savedConcurrency = Number(await window.electronAPI.config.get('sttOnlineMaxConcurrency')) || 2
concurrency = Math.max(1, Math.min(10, Math.floor(savedConcurrency)))
} else {
const modelStatus = await window.electronAPI.stt.getModelStatus()
modelExists = !!(modelStatus.success && modelStatus.exists)
@@ -1051,8 +1085,6 @@ function ChatPage(_props: ChatPageProps) {
let completedCount = 0
// 并发数量限制(避免同时处理太多导致内存溢出)
const concurrency = 5
// 转写单条语音的函数
const transcribeOne = async (msg: any) => {
try {
@@ -1114,7 +1146,7 @@ function ChatPage(_props: ChatPageProps) {
// 显示结果对话框
setBatchResult({ success: successCount, fail: failCount })
setShowBatchResult(true)
}, [sessions, currentSessionId, batchSelectedDates, batchVoiceMessages])
}, [sessions, currentSessionId, batchSelectedDates, batchVoiceMessages, checkOnlineSttConfigReady])
// 批量转写:按日期的消息数量
const batchCountByDate = useMemo(() => {
@@ -2799,6 +2831,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h
const [sttTranscript, setSttTranscript] = useState<string | null>(null)
const [sttLoading, setSttLoading] = useState(false)
const [sttError, setSttError] = useState<string | null>(null)
const [sttProvider, setSttProvider] = useState<'aliyun-qwen-asr' | null>(null)
const [isEditingStt, setIsEditingStt] = useState(false)
const [editContent, setEditContent] = useState('')
const [imageHasUpdate, setImageHasUpdate] = useState(false)
@@ -3141,6 +3174,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h
let modelName = ''
if (sttMode === 'gpu') {
setSttProvider(null)
// 检查 Whisper 模型
const whisperModelType = (await window.electronAPI.config.get('whisperModelType') as string) || 'small'
console.log('[ChatPage] 读取到的 Whisper 模型类型:', whisperModelType)
@@ -3182,7 +3216,24 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h
setSttLoading(false)
return
}
} else if (sttMode === 'online') {
const onlineReady = await checkOnlineSttConfigReady()
modelExists = onlineReady.ready
const onlineProvider = await window.electronAPI.config.get('sttOnlineProvider')
setSttProvider(onlineProvider === 'aliyun-qwen-asr' ? 'aliyun-qwen-asr' : null)
modelName = onlineProvider === 'aliyun-qwen-asr'
? '阿里云 Qwen-ASR'
: onlineProvider === 'custom'
? '自定义在线接口'
: 'OpenAI 兼容在线转写'
if (!modelExists) {
setSttError(onlineReady.error || '在线转写配置不完整,请先到设置页补齐')
setSttLoading(false)
return
}
} else {
setSttProvider(null)
// 检查 SenseVoice 模型
const modelStatus = await window.electronAPI.stt.getModelStatus()
modelExists = !!(modelStatus.success && modelStatus.exists)
@@ -3246,9 +3297,9 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h
setVoiceDataUrl(`data:audio/wav;base64,${wavBase64}`)
}
// 监听实时结果(CPU 模式支持)
// 监听实时结果CPU 模式与阿里云在线模式支持)
let removeListener: (() => void) | undefined
if (sttMode === 'cpu') {
if (sttMode === 'cpu' || sttMode === 'online') {
removeListener = window.electronAPI.stt.onPartialResult((text) => {
setSttTranscript(text)
})
@@ -3270,7 +3321,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h
} finally {
setSttLoading(false)
}
}, [sttLoading, sttTranscript, voiceDataUrl, session.username, message.localId, message.createTime])
}, [sttLoading, sttTranscript, voiceDataUrl, session.username, message.localId, message.createTime, checkOnlineSttConfigReady])
// 群聊中获取发送者信息
const [isLoadingSender, setIsLoadingSender] = useState(false)
@@ -4011,7 +4062,11 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h
title={sttError || '点击转文字'}
>
{sttLoading ? (
<Loader2 size={12} className="spin" />
sttProvider === 'aliyun-qwen-asr' ? (
<Qwen.Color className="stt-provider-loading-icon" size={18} />
) : (
<Loader2 size={12} className="spin" />
)
) : sttError ? (
<AlertCircle size={12} />
) : (
@@ -4021,7 +4076,9 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h
<path d="M12 4v16" />
</svg>
)}
<span>{sttLoading ? '转写中' : sttError ? '重试' : '转文字'}</span>
{(sttProvider !== 'aliyun-qwen-asr' || !sttLoading) && (
<span>{sttLoading ? '转写中' : sttError ? '重试' : '转文字'}</span>
)}
</button>
)}
{sttError && (

View File

@@ -2907,6 +2907,228 @@ to {
}
}
.stt-online-settings {
padding: 16px 20px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
.advanced-params-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
margin-top: 12px;
@media (max-width: 768px) {
grid-template-columns: 1fr;
}
}
.param-item {
display: flex;
flex-direction: column;
gap: 6px;
> label {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 2px;
}
}
.param-select {
height: 40px;
width: 100%;
min-width: 0;
padding: 0 12px;
border: 1px solid var(--border-color);
border-radius: 10px;
background: var(--bg-primary);
color: var(--text-primary);
&:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(var(--primary-rgb), 0.1);
}
}
.custom-select {
position: relative;
}
.custom-select-trigger {
width: 100%;
height: 40px;
padding: 0 14px;
border: 1px solid var(--border-color);
border-radius: 10px;
background: var(--bg-primary);
color: var(--text-primary);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: color-mix(in srgb, var(--primary) 55%, var(--border-color));
background: var(--bg-tertiary);
}
&:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(var(--primary-rgb), 0.1);
}
svg {
color: var(--text-secondary);
flex-shrink: 0;
transition: transform 0.2s ease;
}
&.is-open {
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(var(--primary-rgb), 0.1);
svg {
transform: rotate(180deg);
}
}
}
.custom-select-menu {
position: absolute;
top: calc(100% + 8px);
left: 0;
right: 0;
z-index: 20;
padding: 8px;
background: color-mix(in srgb, var(--bg-primary) 92%, black 8%);
border: 1px solid color-mix(in srgb, var(--primary) 40%, var(--border-color));
border-radius: 14px;
box-shadow: 0 18px 36px rgba(0, 0, 0, 0.22);
backdrop-filter: blur(18px);
animation: fadeInUp 0.18s ease;
}
.custom-select-option {
width: 100%;
min-height: 40px;
padding: 0 12px;
border: none;
border-radius: 10px;
background: transparent;
color: var(--text-primary);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
text-align: left;
cursor: pointer;
transition: all 0.16s ease;
&:hover {
background: color-mix(in srgb, var(--primary) 14%, transparent);
}
&.is-active {
background: color-mix(in srgb, var(--primary) 20%, transparent);
color: var(--primary);
font-weight: 600;
}
svg {
flex-shrink: 0;
}
}
.number-control {
display: flex;
align-items: center;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 10px;
overflow: hidden;
height: 40px;
transition: all 0.2s;
&:focus-within {
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(var(--primary-rgb), 0.1);
}
.control-btn {
width: 36px;
height: 100%;
border: none;
background: var(--bg-secondary);
color: var(--text-secondary);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
flex-shrink: 0;
transition: all 0.2s;
&:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
}
.control-btn.minus {
border-right: 1px solid var(--border-color);
}
.control-btn.plus {
border-left: 1px solid var(--border-color);
}
.value-display {
flex: 1;
min-width: 0;
height: 100%;
display: flex;
align-items: center;
padding: 0 12px;
input {
width: 100%;
min-width: 0;
height: auto;
padding: 0;
margin: 0;
border: none;
background: transparent;
color: var(--text-primary);
text-align: center;
font-weight: 500;
&:focus {
outline: none;
box-shadow: none;
}
}
}
}
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(-6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.api-settings {
.api-status-grid {
display: grid;

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useRef } from 'react'
import { useSearchParams, useLocation } from 'react-router-dom'
import { useAppStore } from '../stores/appStore'
import { useThemeStore, themes } from '../stores/themeStore'
@@ -12,7 +12,7 @@ import {
Eye, EyeOff, Key, FolderSearch, FolderOpen, Search,
RotateCcw, Trash2, Save, Plug, X, Check, Sun, Moon, Monitor,
Palette, Database, ImageIcon, Download, HardDrive, Info, RefreshCw, Shield, Clock, CheckCircle, AlertCircle, Mic,
Zap, Layers, User, Sparkles, Github, Fingerprint, Lock, ShieldCheck, Minus, Plus, Smile
Zap, Layers, User, Sparkles, Github, Fingerprint, Lock, ShieldCheck, Minus, Plus, Smile, ChevronDown
} from 'lucide-react'
import { useAuthStore } from '../stores/authStore'
import './SettingsPage.scss'
@@ -43,6 +43,36 @@ const sttModelTypeOptions = [
{ value: 'float32', label: 'float32 完整版', size: '920 MB', desc: '更高精度,体积较大' }
]
const sttOnlineLanguageOptions = [
{ value: 'auto', label: '自动识别' },
{ value: 'zh', label: '中文' },
{ value: 'en', label: '英语' },
{ value: 'ja', label: '日语' },
{ value: 'ko', label: '韩语' },
{ value: 'yue', label: '粤语' }
]
const sttOnlineProviderOptions = [
{ value: 'openai-compatible', label: 'OpenAI 兼容' },
{ value: 'aliyun-qwen-asr', label: '阿里云 Qwen-ASR' },
{ value: 'custom', label: '自定义接口' }
] as const
const STT_ONLINE_DEFAULTS = {
'openai-compatible': {
baseURL: 'https://api.openai.com/v1',
model: 'gpt-4o-mini-transcribe'
},
'aliyun-qwen-asr': {
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
model: 'qwen3-asr-flash'
},
custom: {
baseURL: '',
model: ''
}
} as const
function SettingsPage() {
const [searchParams] = useSearchParams()
const location = useLocation()
@@ -161,6 +191,15 @@ function SettingsPage() {
const [isLoadingCacheSize, setIsLoadingCacheSize] = useState(false)
const [sttLanguages, setSttLanguagesState] = useState<string[]>([])
const [sttModelType, setSttModelType] = useState<'int8' | 'float32'>('int8')
const [sttMode, setSttMode] = useState<'cpu' | 'gpu' | 'online'>('cpu')
const [sttOnlineProvider, setSttOnlineProvider] = useState<'openai-compatible' | 'aliyun-qwen-asr' | 'custom'>('openai-compatible')
const [sttOnlineApiKey, setSttOnlineApiKey] = useState('')
const [sttOnlineBaseURL, setSttOnlineBaseURL] = useState('https://api.openai.com/v1')
const [sttOnlineModel, setSttOnlineModel] = useState('gpt-4o-mini-transcribe')
const [sttOnlineLanguage, setSttOnlineLanguage] = useState('auto')
const [sttOnlineTimeoutMs, setSttOnlineTimeoutMs] = useState(60000)
const [sttOnlineMaxConcurrency, setSttOnlineMaxConcurrency] = useState(2)
const [showSttOnlineLanguageDropdown, setShowSttOnlineLanguageDropdown] = useState(false)
const [quoteStyle, setQuoteStyle] = useState<'default' | 'wechat'>('default')
const [skipIntegrityCheck, setSkipIntegrityCheck] = useState(false)
const [exportDefaultDateRange, setExportDefaultDateRange] = useState<number>(0)
@@ -198,9 +237,26 @@ function SettingsPage() {
// 配置变化状态
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)
const [initialConfig, setInitialConfig] = useState<any>(null)
const sttOnlineLanguageRef = useRef<HTMLDivElement>(null)
const isMac = platformInfo.platform === 'darwin'
const biometricLabel = isMac ? 'Touch ID' : 'Windows Hello'
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (!sttOnlineLanguageRef.current?.contains(event.target as Node)) {
setShowSttOnlineLanguageDropdown(false)
}
}
if (showSttOnlineLanguageDropdown) {
document.addEventListener('mousedown', handleClickOutside)
}
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [showSttOnlineLanguageDropdown])
const getAccountDisplayName = (account?: AccountProfile | null) => {
if (!account) return '未命名账号'
@@ -300,6 +356,14 @@ function SettingsPage() {
const savedExportPath = await configService.getExportPath()
const savedSttLanguages = await configService.getSttLanguages()
const savedSttModelType = await configService.getSttModelType()
const savedSttMode = await configService.getSttMode()
const savedSttOnlineProvider = await configService.getSttOnlineProvider()
const savedSttOnlineApiKey = await configService.getSttOnlineApiKey()
const savedSttOnlineBaseURL = await configService.getSttOnlineBaseURL()
const savedSttOnlineModel = await configService.getSttOnlineModel()
const savedSttOnlineLanguage = await configService.getSttOnlineLanguage()
const savedSttOnlineTimeoutMs = await configService.getSttOnlineTimeoutMs()
const savedSttOnlineMaxConcurrency = await configService.getSttOnlineMaxConcurrency()
const savedSkipIntegrityCheck = await configService.getSkipIntegrityCheck()
const savedAutoUpdateDatabase = await configService.getAutoUpdateDatabase()
@@ -317,6 +381,14 @@ function SettingsPage() {
setSttLanguagesState(['zh'])
}
setSttModelType(savedSttModelType)
setSttMode(savedSttMode)
setSttOnlineProvider(savedSttOnlineProvider)
setSttOnlineApiKey(savedSttOnlineApiKey)
setSttOnlineBaseURL(savedSttOnlineBaseURL)
setSttOnlineModel(savedSttOnlineModel)
setSttOnlineLanguage(savedSttOnlineLanguage)
setSttOnlineTimeoutMs(savedSttOnlineTimeoutMs)
setSttOnlineMaxConcurrency(savedSttOnlineMaxConcurrency)
setSkipIntegrityCheck(savedSkipIntegrityCheck)
setAutoUpdateDatabase(savedAutoUpdateDatabase)
@@ -373,6 +445,14 @@ function SettingsPage() {
exportPath: savedExportPath || '',
sttLanguages: savedSttLanguages && savedSttLanguages.length > 0 ? savedSttLanguages : ['zh'],
sttModelType: savedSttModelType,
sttMode: savedSttMode,
sttOnlineProvider: savedSttOnlineProvider,
sttOnlineApiKey: savedSttOnlineApiKey,
sttOnlineBaseURL: savedSttOnlineBaseURL,
sttOnlineModel: savedSttOnlineModel,
sttOnlineLanguage: savedSttOnlineLanguage,
sttOnlineTimeoutMs: savedSttOnlineTimeoutMs,
sttOnlineMaxConcurrency: savedSttOnlineMaxConcurrency,
skipIntegrityCheck: savedSkipIntegrityCheck,
autoUpdateDatabase: savedAutoUpdateDatabase,
autoUpdateCheckInterval: savedCheckInterval,
@@ -422,6 +502,14 @@ function SettingsPage() {
exportPath,
sttLanguages,
sttModelType,
sttMode,
sttOnlineProvider,
sttOnlineApiKey,
sttOnlineBaseURL,
sttOnlineModel,
sttOnlineLanguage,
sttOnlineTimeoutMs,
sttOnlineMaxConcurrency,
skipIntegrityCheck,
autoUpdateDatabase,
autoUpdateCheckInterval,
@@ -448,7 +536,8 @@ function SettingsPage() {
setHasUnsavedChanges(hasChanges)
}, [
decryptKey, dbPath, wxid, cachePath, imageXorKey, imageAesKey, exportPath,
sttLanguages, sttModelType, skipIntegrityCheck, autoUpdateDatabase,
sttLanguages, sttModelType, sttMode, sttOnlineProvider, sttOnlineApiKey, sttOnlineBaseURL,
sttOnlineModel, sttOnlineLanguage, sttOnlineTimeoutMs, sttOnlineMaxConcurrency, skipIntegrityCheck, autoUpdateDatabase,
autoUpdateCheckInterval, autoUpdateMinInterval, autoUpdateDebounceTime,
quoteStyle, exportDefaultDateRange, exportDefaultAvatars,
aiProvider, aiApiKey, aiModel, aiDefaultTimeRange, aiSummaryDetail,
@@ -1285,6 +1374,15 @@ function SettingsPage() {
await configService.setAiEnableThinking(aiEnableThinking)
await configService.setAiMessageLimit(aiMessageLimit)
await configService.setSttMode(sttMode)
await configService.setSttOnlineProvider(sttOnlineProvider)
await configService.setSttOnlineApiKey(sttOnlineApiKey)
await configService.setSttOnlineBaseURL(sttOnlineBaseURL)
await configService.setSttOnlineModel(sttOnlineModel)
await configService.setSttOnlineLanguage(sttOnlineLanguage)
await configService.setSttOnlineTimeoutMs(sttOnlineTimeoutMs)
await configService.setSttOnlineMaxConcurrency(sttOnlineMaxConcurrency)
// 保存关闭行为配置
await configService.setCloseToTray(closeToTray)
@@ -1308,6 +1406,14 @@ function SettingsPage() {
exportPath,
sttLanguages,
sttModelType,
sttMode,
sttOnlineProvider,
sttOnlineApiKey,
sttOnlineBaseURL,
sttOnlineModel,
sttOnlineLanguage,
sttOnlineTimeoutMs,
sttOnlineMaxConcurrency,
skipIntegrityCheck,
autoUpdateDatabase,
autoUpdateCheckInterval,
@@ -1931,9 +2037,6 @@ function SettingsPage() {
const [isDownloadingGpuComponents, setIsDownloadingGpuComponents] = useState(false)
const [gpuDownloadProgress, setGpuDownloadProgress] = useState({ overallProgress: 0, currentFile: '' })
// ========== STT 模式切换 ==========
const [sttMode, setSttMode] = useState<'cpu' | 'gpu'>('cpu')
// 加载 STT 模型状态
useEffect(() => {
if (activeTab === 'stt') {
@@ -1945,14 +2048,37 @@ function SettingsPage() {
}, [activeTab])
const loadSttMode = async () => {
const savedMode = await window.electronAPI.config.get('sttMode') as 'cpu' | 'gpu' | undefined
const savedMode = await configService.getSttMode()
setSttMode(savedMode || 'cpu')
}
const handleSttModeChange = async (mode: 'cpu' | 'gpu') => {
const handleSttModeChange = async (mode: 'cpu' | 'gpu' | 'online') => {
setSttMode(mode)
await window.electronAPI.config.set('sttMode', mode)
showMessage(mode === 'cpu' ? '已切换到 CPU 模式 (SenseVoice)' : '已切换到 GPU 模式 (Whisper)', true)
await configService.setSttMode(mode)
showMessage(
mode === 'cpu'
? '已切换到 CPU 模式 (SenseVoice)'
: mode === 'gpu'
? '已切换到 GPU 模式 (Whisper)'
: '已切换到在线模式 (OpenAI 兼容)',
true
)
}
const handleTestOnlineSttConfig = async () => {
const result = await window.electronAPI.stt.testOnlineConfig({
provider: sttOnlineProvider,
apiKey: sttOnlineApiKey,
baseURL: sttOnlineBaseURL,
model: sttOnlineModel,
language: sttOnlineLanguage,
timeoutMs: sttOnlineTimeoutMs
})
if (result.success) {
showMessage('在线转写配置测试成功', true)
} else {
showMessage(result.error || '在线转写配置测试失败', false)
}
}
// 监听 STT 下载进度
@@ -2172,6 +2298,12 @@ function SettingsPage() {
>
<Zap size={16} /> GPU
</button>
<button
className={`mode-btn ${sttMode === 'online' ? 'active' : ''}`}
onClick={() => handleSttModeChange('online')}
>
<Plug size={16} /> 线
</button>
</div>
{/* CPU 模式 - SenseVoice */}
@@ -2653,18 +2785,194 @@ function SettingsPage() {
</>
)}
<h3 className="section-title" style={{ marginTop: '2rem' }}>使</h3>
<div className="stt-instructions">
<ol>
<li> CPU GPU </li>
<li></li>
<li></li>
<li>"转文字"</li>
</ol>
<p className="note">
<strong></strong>
</p>
</div>
{sttMode === 'online' && (
<div className="stt-online-settings">
<h3 className="section-title">线</h3>
<p className="section-desc">
使线 API
</p>
<div className="form-group">
<label></label>
<span className="form-hint">
{sttOnlineProvider === 'openai-compatible'
? '选择 OpenAI 兼容时会自动补全标准路径'
: sttOnlineProvider === 'aliyun-qwen-asr'
? '阿里云走 DashScope 兼容入口,但内部使用 chat/completions + input_audio 协议'
: '自定义接口会直接使用你填写的完整 URL'}
</span>
<div className="theme-mode-toggle" style={{ marginBottom: 0 }}>
{sttOnlineProviderOptions.map((option) => (
<button
key={option.value}
type="button"
className={`mode-btn ${sttOnlineProvider === option.value ? 'active' : ''}`}
onClick={() => {
setSttOnlineProvider(option.value)
if (option.value === 'aliyun-qwen-asr') {
if (!sttOnlineBaseURL || sttOnlineBaseURL === STT_ONLINE_DEFAULTS['openai-compatible'].baseURL) {
setSttOnlineBaseURL(STT_ONLINE_DEFAULTS['aliyun-qwen-asr'].baseURL)
}
if (!sttOnlineModel || sttOnlineModel === STT_ONLINE_DEFAULTS['openai-compatible'].model) {
setSttOnlineModel(STT_ONLINE_DEFAULTS['aliyun-qwen-asr'].model)
}
} else if (option.value === 'openai-compatible') {
if (!sttOnlineBaseURL || sttOnlineBaseURL === STT_ONLINE_DEFAULTS['aliyun-qwen-asr'].baseURL) {
setSttOnlineBaseURL(STT_ONLINE_DEFAULTS['openai-compatible'].baseURL)
}
if (!sttOnlineModel || sttOnlineModel === STT_ONLINE_DEFAULTS['aliyun-qwen-asr'].model) {
setSttOnlineModel(STT_ONLINE_DEFAULTS['openai-compatible'].model)
}
}
}}
>
{option.label}
</button>
))}
</div>
</div>
<div className="form-group">
<label> URL</label>
<span className="form-hint">
{sttOnlineProvider === 'openai-compatible'
? '支持填写完整接口 URL如 `https://api.openai.com/v1/audio/transcriptions`;也兼容只填 `/v1` 基地址'
: sttOnlineProvider === 'aliyun-qwen-asr'
? '建议填写 DashScope 兼容入口,如 `https://dashscope.aliyuncs.com/compatible-mode/v1`'
: '请输入完整接口 URL系统会按你填写的地址原样发起请求'}
</span>
<input
type="text"
value={sttOnlineBaseURL}
onChange={(e) => setSttOnlineBaseURL(e.target.value)}
placeholder={
sttOnlineProvider === 'openai-compatible'
? 'https://api.openai.com/v1/audio/transcriptions'
: sttOnlineProvider === 'aliyun-qwen-asr'
? 'https://dashscope.aliyuncs.com/compatible-mode/v1'
: 'https://your-api.example.com/full/path'
}
/>
</div>
<div className="form-group">
<label>API Key</label>
<span className="form-hint">线</span>
<input
type="password"
value={sttOnlineApiKey}
onChange={(e) => setSttOnlineApiKey(e.target.value)}
placeholder="请输入在线 STT API Key"
/>
</div>
<div className="form-group">
<label></label>
<span className="form-hint">
{sttOnlineProvider === 'aliyun-qwen-asr'
? '阿里云当前可用模型为 `qwen3-asr-flash` 与 `qwen3-asr-flash-filetrans`,默认使用 `qwen3-asr-flash`'
: '默认使用 `gpt-4o-mini-transcribe`,也可替换为兼容模型名'}
</span>
<input
type="text"
value={sttOnlineModel}
onChange={(e) => setSttOnlineModel(e.target.value)}
placeholder={sttOnlineProvider === 'aliyun-qwen-asr' ? 'qwen3-asr-flash' : 'gpt-4o-mini-transcribe'}
/>
</div>
<div className="advanced-params-grid">
<div className="param-item">
<label></label>
<div className="custom-select" ref={sttOnlineLanguageRef}>
<button
type="button"
className={`custom-select-trigger ${showSttOnlineLanguageDropdown ? 'is-open' : ''}`}
onClick={() => setShowSttOnlineLanguageDropdown(prev => !prev)}
>
<span>{sttOnlineLanguageOptions.find(option => option.value === sttOnlineLanguage)?.label || '自动识别'}</span>
<ChevronDown size={16} />
</button>
{showSttOnlineLanguageDropdown && (
<div className="custom-select-menu">
{sttOnlineLanguageOptions.map((option) => (
<button
key={option.value}
type="button"
className={`custom-select-option ${sttOnlineLanguage === option.value ? 'is-active' : ''}`}
onClick={() => {
setSttOnlineLanguage(option.value)
setShowSttOnlineLanguageDropdown(false)
}}
>
<span>{option.label}</span>
{sttOnlineLanguage === option.value && <Check size={14} />}
</button>
))}
</div>
)}
</div>
</div>
<div className="param-item">
<label></label>
<div className="number-control">
<button className="control-btn minus" type="button" onClick={() => setSttOnlineTimeoutMs(prev => Math.max(5000, prev - 5000))}>
<Minus size={14} />
</button>
<div className="value-display">
<input
type="number"
value={sttOnlineTimeoutMs}
onChange={(e) => setSttOnlineTimeoutMs(Math.max(5000, Number(e.target.value) || 60000))}
/>
</div>
<button className="control-btn plus" type="button" onClick={() => setSttOnlineTimeoutMs(prev => Math.min(300000, prev + 5000))}>
<Plus size={14} />
</button>
</div>
</div>
<div className="param-item">
<label></label>
<div className="number-control">
<button className="control-btn minus" type="button" onClick={() => setSttOnlineMaxConcurrency(prev => Math.max(1, prev - 1))}>
<Minus size={14} />
</button>
<div className="value-display">
<input
type="number"
value={sttOnlineMaxConcurrency}
onChange={(e) => setSttOnlineMaxConcurrency(Math.max(1, Math.min(10, Number(e.target.value) || 2)))}
/>
</div>
<button className="control-btn plus" type="button" onClick={() => setSttOnlineMaxConcurrency(prev => Math.min(10, prev + 1))}>
<Plus size={14} />
</button>
</div>
</div>
</div>
<div className="btn-row" style={{ marginTop: '1rem' }}>
<button className="btn btn-secondary" onClick={handleTestOnlineSttConfig}>
<Plug size={16} /> 线
</button>
</div>
<div className="stt-instructions" style={{ marginTop: '1.5rem' }}>
<ol>
<li>线 STT </li>
<li></li>
<li></li>
</ol>
<p className="note">
<strong></strong>线使
</p>
</div>
</div>
)}
</div>
)

View File

@@ -17,6 +17,14 @@ export const CONFIG_KEYS = {
AGREEMENT_VERSION: 'agreementVersion',
STT_LANGUAGES: 'sttLanguages',
STT_MODEL_TYPE: 'sttModelType',
STT_MODE: 'sttMode',
STT_ONLINE_PROVIDER: 'sttOnlineProvider',
STT_ONLINE_API_KEY: 'sttOnlineApiKey',
STT_ONLINE_BASE_URL: 'sttOnlineBaseURL',
STT_ONLINE_MODEL: 'sttOnlineModel',
STT_ONLINE_LANGUAGE: 'sttOnlineLanguage',
STT_ONLINE_TIMEOUT_MS: 'sttOnlineTimeoutMs',
STT_ONLINE_MAX_CONCURRENCY: 'sttOnlineMaxConcurrency',
QUOTE_STYLE: 'quoteStyle',
SKIP_INTEGRITY_CHECK: 'skipIntegrityCheck',
EXPORT_DEFAULT_DATE_RANGE: 'exportDefaultDateRange',
@@ -279,6 +287,78 @@ export async function setSttModelType(type: 'int8' | 'float32'): Promise<void> {
await config.set(CONFIG_KEYS.STT_MODEL_TYPE, type)
}
export async function getSttMode(): Promise<'cpu' | 'gpu' | 'online'> {
const value = await config.get(CONFIG_KEYS.STT_MODE)
return (value as 'cpu' | 'gpu' | 'online') || 'cpu'
}
export async function setSttMode(mode: 'cpu' | 'gpu' | 'online'): Promise<void> {
await config.set(CONFIG_KEYS.STT_MODE, mode)
}
export async function getSttOnlineProvider(): Promise<'openai-compatible' | 'aliyun-qwen-asr' | 'custom'> {
const value = await config.get(CONFIG_KEYS.STT_ONLINE_PROVIDER)
return (value as 'openai-compatible' | 'aliyun-qwen-asr' | 'custom') || 'openai-compatible'
}
export async function setSttOnlineProvider(provider: 'openai-compatible' | 'aliyun-qwen-asr' | 'custom'): Promise<void> {
await config.set(CONFIG_KEYS.STT_ONLINE_PROVIDER, provider)
}
export async function getSttOnlineApiKey(): Promise<string> {
const value = await config.get(CONFIG_KEYS.STT_ONLINE_API_KEY)
return (value as string) || ''
}
export async function setSttOnlineApiKey(apiKey: string): Promise<void> {
await config.set(CONFIG_KEYS.STT_ONLINE_API_KEY, apiKey.trim())
}
export async function getSttOnlineBaseURL(): Promise<string> {
const value = await config.get(CONFIG_KEYS.STT_ONLINE_BASE_URL)
return (value as string) || 'https://api.openai.com/v1'
}
export async function setSttOnlineBaseURL(baseURL: string): Promise<void> {
await config.set(CONFIG_KEYS.STT_ONLINE_BASE_URL, baseURL.trim())
}
export async function getSttOnlineModel(): Promise<string> {
const value = await config.get(CONFIG_KEYS.STT_ONLINE_MODEL)
return (value as string) || 'gpt-4o-mini-transcribe'
}
export async function setSttOnlineModel(model: string): Promise<void> {
await config.set(CONFIG_KEYS.STT_ONLINE_MODEL, model.trim())
}
export async function getSttOnlineLanguage(): Promise<string> {
const value = await config.get(CONFIG_KEYS.STT_ONLINE_LANGUAGE)
return (value as string) || 'auto'
}
export async function setSttOnlineLanguage(language: string): Promise<void> {
await config.set(CONFIG_KEYS.STT_ONLINE_LANGUAGE, language.trim() || 'auto')
}
export async function getSttOnlineTimeoutMs(): Promise<number> {
const value = await config.get(CONFIG_KEYS.STT_ONLINE_TIMEOUT_MS)
return Number(value) || 60000
}
export async function setSttOnlineTimeoutMs(timeoutMs: number): Promise<void> {
await config.set(CONFIG_KEYS.STT_ONLINE_TIMEOUT_MS, Math.max(5000, Math.min(300000, Math.floor(timeoutMs))))
}
export async function getSttOnlineMaxConcurrency(): Promise<number> {
const value = await config.get(CONFIG_KEYS.STT_ONLINE_MAX_CONCURRENCY)
return Number(value) || 2
}
export async function setSttOnlineMaxConcurrency(concurrency: number): Promise<void> {
await config.set(CONFIG_KEYS.STT_ONLINE_MAX_CONCURRENCY, Math.max(1, Math.min(10, Math.floor(concurrency))))
}
// 获取用户同意的协议版本
export async function getAgreementVersion(): Promise<number> {

View File

@@ -906,6 +906,17 @@ export interface ElectronAPI {
cached?: boolean
error?: string
}>
testOnlineConfig: (overrides?: {
provider?: 'openai-compatible' | 'aliyun-qwen-asr' | 'custom'
apiKey?: string
baseURL?: string
model?: string
language?: string
timeoutMs?: number
}) => Promise<{
success: boolean
error?: string
}>
onDownloadProgress: (callback: (progress: {
modelName: string
downloadedBytes: number