mirror of
https://mirror.skon.top/github.com/ILoveBingLu/CipherTalk
synced 2026-04-30 13:51:50 +08:00
完善在线语音转写接入,并统一替换 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:
@@ -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 加速 ==========
|
||||
|
||||
// 清除模型
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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', // 默认只记录警告和错误
|
||||
|
||||
359
electron/services/voiceTranscribeServiceOnline.ts
Normal file
359
electron/services/voiceTranscribeServiceOnline.ts
Normal 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
7140
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
95
src/components/ai/AIProviderLogo.tsx
Normal file
95
src/components/ai/AIProviderLogo.tsx
Normal 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
|
||||
}
|
||||
@@ -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" />
|
||||
)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 发送方的样式调整
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
11
src/types/electron.d.ts
vendored
11
src/types/electron.d.ts
vendored
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user