This commit is contained in:
1600822305 2025-04-10 12:30:22 +08:00
parent 4a2f1d5cf6
commit bbe08e2a6c
21 changed files with 2790 additions and 331 deletions

View File

@ -27,6 +27,11 @@ files:
- '!node_modules/@tavily/core/node_modules/js-tiktoken'
- '!node_modules/pdf-parse/lib/pdf.js/{v1.9.426,v1.10.88,v2.0.550}'
- '!node_modules/mammoth/{mammoth.browser.js,mammoth.browser.min.js}'
# 包含 ASR 服务器文件
- src/renderer/src/assets/asr-server/**/*
# 包含打包后的ASR服务器可执行文件
- cherry-asr-server.exe
- index.html
asarUnpack:
- resources/**
- '**/*.{node,dll,metal,exp,lib}'

View File

@ -76,6 +76,17 @@ export default defineConfig({
},
optimizeDeps: {
exclude: []
},
build: {
rollupOptions: {
input: {
index: resolve('src/renderer/index.html'),
},
},
// 复制ASR服务器文件
assetsInlineLimit: 0,
// 确保复制assets目录下的所有文件
copyPublicDir: true,
}
}
})

View File

@ -0,0 +1,198 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Edge ASR (External)</title>
<style>
body {
font-family: sans-serif;
padding: 1em;
}
#status {
margin-top: 1em;
font-style: italic;
color: #555;
}
#result {
margin-top: 0.5em;
border: 1px solid #ccc;
padding: 0.5em;
min-height: 50px;
background: #f9f9f9;
}
</style>
</head>
<body>
<h1>Edge ASR 中继页面</h1>
<p>这个页面需要在 Edge 浏览器中保持打开,以便 Electron 应用使用其语音识别功能。</p>
<div id="status">正在连接到服务器...</div>
<div id="result"></div>
<script>
const statusDiv = document.getElementById('status');
const resultDiv = document.getElementById('result');
const ws = new WebSocket('ws://localhost:8080'); // Use the defined port
let recognition = null;
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
function updateStatus(message) {
console.log(`[Browser Page Status] ${message}`);
statusDiv.textContent = message;
}
ws.onopen = () => {
updateStatus('已连接到服务器,等待指令...');
ws.send(JSON.stringify({ type: 'identify', role: 'browser' }));
};
ws.onmessage = (event) => {
let data;
try {
data = JSON.parse(event.data);
console.log('[Browser Page] Received command:', data);
} catch (e) {
console.error('[Browser Page] Received non-JSON message:', event.data);
return;
}
if (data.type === 'start') {
startRecognition();
} else if (data.type === 'stop') {
stopRecognition();
} else {
console.warn('[Browser Page] Received unknown command type:', data.type);
}
};
ws.onerror = (error) => {
console.error('[Browser Page] WebSocket Error:', error);
updateStatus('WebSocket 连接错误!请检查服务器是否运行。');
};
ws.onclose = () => {
console.log('[Browser Page] WebSocket Connection Closed');
updateStatus('与服务器断开连接。请刷新页面或重启服务器。');
stopRecognition();
};
function setupRecognition() {
if (!SpeechRecognition) {
updateStatus('错误:此浏览器不支持 Web Speech API。');
return false;
}
if (recognition && recognition.recognizing) {
console.log('[Browser Page] Recognition already active.');
return true;
}
recognition = new SpeechRecognition();
recognition.lang = 'zh-CN';
recognition.continuous = true;
recognition.interimResults = true;
recognition.onstart = () => {
updateStatus("🎤 正在识别...");
console.log('[Browser Page] SpeechRecognition started.');
};
recognition.onresult = (event) => {
let interim_transcript = '';
let final_transcript = '';
for (let i = event.resultIndex; i < event.results.length; ++i) {
if (event.results[i].isFinal) {
final_transcript += event.results[i][0].transcript;
} else {
interim_transcript += event.results[i][0].transcript;
}
}
const resultText = final_transcript || interim_transcript;
resultDiv.textContent = resultText;
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'result', data: { text: resultText, isFinal: !!final_transcript } }));
}
};
recognition.onerror = (event) => {
console.error(`[Browser Page] SpeechRecognition Error - Type: ${event.error}, Message: ${event.message}`);
updateStatus(`识别错误: ${event.error}`);
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'error', data: { error: event.error, message: event.message || `Recognition error: ${event.error}` } }));
}
};
recognition.onend = () => {
console.log('[Browser Page] SpeechRecognition ended.');
if (!statusDiv.textContent.includes('错误') && !statusDiv.textContent.includes('停止')) {
updateStatus("识别已停止。等待指令...");
}
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'status', message: 'stopped' }));
}
recognition = null;
};
return true;
}
function startRecognition() {
if (!SpeechRecognition) {
updateStatus('错误:浏览器不支持 Web Speech API。');
return;
}
if (recognition) {
console.log('[Browser Page] Recognition already exists, stopping first.');
stopRecognition();
}
if (!setupRecognition()) return;
console.log('[Browser Page] Attempting to start recognition...');
try {
navigator.mediaDevices.getUserMedia({ audio: true })
.then(stream => {
console.log('[Browser Page] Microphone access granted.');
stream.getTracks().forEach(track => track.stop());
if (recognition) {
recognition.start();
} else {
updateStatus('错误Recognition 实例丢失。');
console.error('[Browser Page] Recognition instance lost before start.');
}
})
.catch(err => {
console.error('[Browser Page] Microphone access error:', err);
updateStatus(`错误: 无法访问麦克风 (${err.name})`);
recognition = null;
});
} catch (e) {
console.error('[Browser Page] Error calling recognition.start():', e);
updateStatus(`启动识别时出错: ${e.message}`);
recognition = null;
}
}
function stopRecognition() {
if (recognition) {
console.log('[Browser Page] Stopping recognition...');
updateStatus("正在停止识别...");
try {
recognition.stop();
} catch (e) {
console.error('[Browser Page] Error calling recognition.stop():', e);
recognition = null;
updateStatus("停止时出错,已强制重置。");
}
} else {
console.log('[Browser Page] Recognition not active, nothing to stop.');
updateStatus("识别未运行。");
}
}
</script>
</body>
</html>

146
public/asr-server/server.js Normal file
View File

@ -0,0 +1,146 @@
const http = require('http')
const WebSocket = require('ws')
const express = require('express')
const path = require('path') // Need path module
const app = express()
const port = 8080 // Define the port
// 提供网页给 Edge 浏览器
app.get('/', (req, res) => {
// Use path.join for cross-platform compatibility
res.sendFile(path.join(__dirname, 'index.html'))
})
const server = http.createServer(app)
const wss = new WebSocket.Server({ server })
let browserConnection = null
let electronConnection = null
wss.on('connection', (ws) => {
console.log('[Server] WebSocket client connected') // Add log
ws.on('message', (message) => {
let data
try {
// Ensure message is treated as string before parsing
data = JSON.parse(message.toString())
console.log('[Server] Received message:', data) // Log parsed data
} catch (e) {
console.error('[Server] Failed to parse message or message is not JSON:', message.toString(), e)
return // Ignore non-JSON messages
}
// 识别客户端类型
if (data.type === 'identify') {
if (data.role === 'browser') {
browserConnection = ws
console.log('[Server] Browser identified and connected')
// Notify Electron that the browser is ready
if (electronConnection && electronConnection.readyState === WebSocket.OPEN) {
electronConnection.send(JSON.stringify({ type: 'status', message: 'browser_ready' }));
console.log('[Server] Sent browser_ready status to Electron');
}
// Notify Electron if it's already connected
if (electronConnection) {
electronConnection.send(JSON.stringify({ type: 'status', message: 'Browser connected' }))
}
ws.on('close', () => {
console.log('[Server] Browser disconnected')
browserConnection = null
// Notify Electron
if (electronConnection) {
electronConnection.send(JSON.stringify({ type: 'status', message: 'Browser disconnected' }))
}
})
ws.on('error', (error) => {
console.error('[Server] Browser WebSocket error:', error)
browserConnection = null // Assume disconnected on error
if (electronConnection) {
electronConnection.send(JSON.stringify({ type: 'status', message: 'Browser connection error' }))
}
})
} else if (data.role === 'electron') {
electronConnection = ws
console.log('[Server] Electron identified and connected')
// If browser is already connected when Electron connects, notify Electron immediately
if (browserConnection && browserConnection.readyState === WebSocket.OPEN) {
electronConnection.send(JSON.stringify({ type: 'status', message: 'browser_ready' }));
console.log('[Server] Sent initial browser_ready status to Electron');
}
ws.on('close', () => {
console.log('[Server] Electron disconnected')
electronConnection = null
// Maybe send stop to browser if electron disconnects?
// if (browserConnection) browserConnection.send(JSON.stringify({ type: 'stop' }));
})
ws.on('error', (error) => {
console.error('[Server] Electron WebSocket error:', error)
electronConnection = null // Assume disconnected on error
})
}
}
// Electron 控制开始/停止
else if (data.type === 'start' && ws === electronConnection) {
if (browserConnection && browserConnection.readyState === WebSocket.OPEN) {
console.log('[Server] Relaying START command to browser')
browserConnection.send(JSON.stringify({ type: 'start' }))
} else {
console.log('[Server] Cannot relay START: Browser not connected')
// Optionally notify Electron back
electronConnection.send(JSON.stringify({ type: 'error', message: 'Browser not connected for ASR' }))
}
} else if (data.type === 'stop' && ws === electronConnection) {
if (browserConnection && browserConnection.readyState === WebSocket.OPEN) {
console.log('[Server] Relaying STOP command to browser')
browserConnection.send(JSON.stringify({ type: 'stop' }))
} else {
console.log('[Server] Cannot relay STOP: Browser not connected')
}
}
// 浏览器发送识别结果
else if (data.type === 'result' && ws === browserConnection) {
if (electronConnection && electronConnection.readyState === WebSocket.OPEN) {
// console.log('[Server] Relaying RESULT to Electron:', data.data); // Log less frequently if needed
electronConnection.send(JSON.stringify({ type: 'result', data: data.data }))
} else {
// console.log('[Server] Cannot relay RESULT: Electron not connected');
}
}
// 浏览器发送状态更新 (例如 'stopped')
else if (data.type === 'status' && ws === browserConnection) {
if (electronConnection && electronConnection.readyState === WebSocket.OPEN) {
console.log('[Server] Relaying STATUS to Electron:', data.message) // Log status being relayed
electronConnection.send(JSON.stringify({ type: 'status', message: data.message }))
} else {
console.log('[Server] Cannot relay STATUS: Electron not connected')
}
} else {
console.log('[Server] Received unknown message type or from unknown source:', data)
}
})
ws.on('error', (error) => {
// Generic error handling for connection before identification
console.error('[Server] Initial WebSocket connection error:', error)
// Attempt to clean up based on which connection it might be (if identified)
if (ws === browserConnection) {
browserConnection = null
if (electronConnection)
electronConnection.send(JSON.stringify({ type: 'status', message: 'Browser connection error' }))
} else if (ws === electronConnection) {
electronConnection = null
}
})
})
server.listen(port, () => {
console.log(`[Server] Server running at http://localhost:${port}`)
})
// Handle server errors
server.on('error', (error) => {
console.error(`[Server] Failed to start server:`, error)
process.exit(1) // Exit if server fails to start
})

View File

@ -1,4 +1,6 @@
import fs from 'node:fs'
import { spawn, ChildProcess } from 'node:child_process'
import path from 'node:path'
import { isMac, isWin } from '@main/constant'
import { getBinaryPath, isBinaryExists, runInstallScript } from '@main/utils/process'
@ -29,6 +31,9 @@ import { decrypt, encrypt } from './utils/aes'
import { getConfigDir, getFilesDir } from './utils/file'
import { compress, decompress } from './utils/zip'
// 存储ASR服务器进程
let asrServerProcess: ChildProcess | null = null
const fileManager = new FileStorage()
const backupManager = new BackupManager()
const exportService = new ExportService(fileManager)
@ -291,4 +296,103 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.Nutstore_GetDirectoryContents, (_, token: string, path: string) =>
NutstoreService.getDirectoryContents(token, path)
)
// 启动ASR服务器
ipcMain.handle('start-asr-server', async () => {
try {
if (asrServerProcess) {
return { success: true, pid: asrServerProcess.pid }
}
// 获取服务器文件路径
console.log('App path:', app.getAppPath())
// 在开发环境和生产环境中使用不同的路径
let serverPath = ''
let isExeFile = false
// 首先检查是否有打包后的exe文件
const exePath = path.join(app.getAppPath(), 'resources', 'cherry-asr-server.exe')
if (fs.existsSync(exePath)) {
serverPath = exePath
isExeFile = true
console.log('检测到打包后的exe文件:', serverPath)
} else if (process.env.NODE_ENV === 'development') {
// 开发环境
serverPath = path.join(app.getAppPath(), 'src', 'renderer', 'src', 'assets', 'asr-server', 'server.js')
} else {
// 生产环境
serverPath = path.join(app.getAppPath(), 'public', 'asr-server', 'server.js')
}
console.log('ASR服务器路径:', serverPath)
// 检查文件是否存在
if (!fs.existsSync(serverPath)) {
return { success: false, error: '服务器文件不存在' }
}
// 启动服务器进程
if (isExeFile) {
// 如果是exe文件直接启动
asrServerProcess = spawn(serverPath, [], {
stdio: 'pipe',
detached: false
})
} else {
// 如果是js文件使用node启动
asrServerProcess = spawn('node', [serverPath], {
stdio: 'pipe',
detached: false
})
}
// 处理服务器输出
asrServerProcess.stdout?.on('data', (data) => {
console.log(`[ASR Server] ${data.toString()}`)
})
asrServerProcess.stderr?.on('data', (data) => {
console.error(`[ASR Server Error] ${data.toString()}`)
})
// 处理服务器退出
asrServerProcess.on('close', (code) => {
console.log(`[ASR Server] 进程退出,退出码: ${code}`)
asrServerProcess = null
})
// 等待一段时间确保服务器启动
await new Promise(resolve => setTimeout(resolve, 1000))
return { success: true, pid: asrServerProcess.pid }
} catch (error) {
console.error('启动ASR服务器失败:', error)
return { success: false, error: (error as Error).message }
}
})
// 停止ASR服务器
ipcMain.handle('stop-asr-server', async (_event, pid) => {
try {
if (!asrServerProcess) {
return { success: true }
}
// 检查PID是否匹配
if (asrServerProcess.pid !== pid) {
console.warn(`请求停止的PID (${pid}) 与当前运行的ASR服务器PID (${asrServerProcess.pid}) 不匹配`)
}
// 杀死进程
asrServerProcess.kill()
// 等待一段时间确保进程已经退出
await new Promise(resolve => setTimeout(resolve, 500))
asrServerProcess = null
return { success: true }
} catch (error) {
console.error('停止ASR服务器失败:', error)
return { success: false, error: (error as Error).message }
}
})
}

View File

@ -0,0 +1,368 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Edge ASR (External)</title>
<style>
body {
font-family: sans-serif;
padding: 1em;
}
#status {
margin-top: 1em;
font-style: italic;
color: #555;
}
#result {
margin-top: 0.5em;
border: 1px solid #ccc;
padding: 0.5em;
min-height: 50px;
background: #f9f9f9;
}
</style>
</head>
<body>
<h1>Edge ASR 中继页面</h1>
<p>这个页面需要在 Edge 浏览器中保持打开,以便 Electron 应用使用其语音识别功能。</p>
<div id="status">正在连接到服务器...</div>
<div id="result"></div>
<script>
const statusDiv = document.getElementById('status');
const resultDiv = document.getElementById('result');
const ws = new WebSocket('ws://localhost:8080'); // Use the defined port
let recognition = null;
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
function updateStatus(message) {
console.log(`[Browser Page Status] ${message}`);
statusDiv.textContent = message;
}
ws.onopen = () => {
updateStatus('已连接到服务器,等待指令...');
ws.send(JSON.stringify({ type: 'identify', role: 'browser' }));
};
ws.onmessage = (event) => {
let data;
try {
data = JSON.parse(event.data);
console.log('[Browser Page] Received command:', data);
} catch (e) {
console.error('[Browser Page] Received non-JSON message:', event.data);
return;
}
if (data.type === 'start') {
startRecognition();
} else if (data.type === 'stop') {
stopRecognition();
} else {
console.warn('[Browser Page] Received unknown command type:', data.type);
}
};
ws.onerror = (error) => {
console.error('[Browser Page] WebSocket Error:', error);
updateStatus('WebSocket 连接错误!请检查服务器是否运行。');
};
ws.onclose = () => {
console.log('[Browser Page] WebSocket Connection Closed');
updateStatus('与服务器断开连接。请刷新页面或重启服务器。');
stopRecognition();
};
function setupRecognition() {
if (!SpeechRecognition) {
updateStatus('错误:此浏览器不支持 Web Speech API。');
return false;
}
if (recognition && recognition.recognizing) {
console.log('[Browser Page] Recognition already active.');
return true;
}
recognition = new SpeechRecognition();
recognition.lang = 'zh-CN';
recognition.continuous = true;
recognition.interimResults = true;
// 增加以下设置提高语音识别的可靠性
recognition.maxAlternatives = 3; // 返回多个可能的识别结果
// 设置较短的语音识别时间,使用户能更快地看到结果
// 注意:这个属性不是标准的,可能不是所有浏览器都支持
try {
// @ts-ignore
recognition.audioStart = 0.1; // 尝试设置较低的起始音量阈值
} catch (e) {
console.log('[Browser Page] audioStart property not supported');
}
recognition.onstart = () => {
updateStatus("🎤 正在识别...");
console.log('[Browser Page] SpeechRecognition started.');
};
recognition.onresult = (event) => {
console.log('[Browser Page] Recognition result event:', event);
let interim_transcript = '';
let final_transcript = '';
// 输出识别结果的详细信息便于调试
for (let i = event.resultIndex; i < event.results.length; ++i) {
const confidence = event.results[i][0].confidence;
console.log(`[Browser Page] Result ${i}: ${event.results[i][0].transcript} (Confidence: ${confidence.toFixed(2)})`);
if (event.results[i].isFinal) {
final_transcript += event.results[i][0].transcript;
} else {
interim_transcript += event.results[i][0].transcript;
}
}
const resultText = final_transcript || interim_transcript;
resultDiv.textContent = resultText;
// 更新状态显示
if (resultText) {
updateStatus(`🎤 正在识别... (已捕捉到语音)`);
}
if (ws.readyState === WebSocket.OPEN) {
console.log(`[Browser Page] Sending ${final_transcript ? 'final' : 'interim'} result to server:`, resultText);
ws.send(JSON.stringify({ type: 'result', data: { text: resultText, isFinal: !!final_transcript } }));
}
};
recognition.onerror = (event) => {
console.error(`[Browser Page] SpeechRecognition Error - Type: ${event.error}, Message: ${event.message}`);
// 根据错误类型提供更友好的错误提示
let errorMessage = '';
switch (event.error) {
case 'no-speech':
errorMessage = '未检测到语音,请确保麦克风工作正常并尝试说话。';
// 尝试重新启动语音识别
setTimeout(() => {
if (recognition) {
try {
recognition.start();
console.log('[Browser Page] Restarting recognition after no-speech error');
} catch (e) {
console.error('[Browser Page] Failed to restart recognition:', e);
}
}
}, 1000);
break;
case 'audio-capture':
errorMessage = '无法捕获音频,请确保麦克风已连接并已授权。';
break;
case 'not-allowed':
errorMessage = '浏览器不允许使用麦克风,请检查权限设置。';
break;
case 'network':
errorMessage = '网络错误导致语音识别失败。';
break;
case 'aborted':
errorMessage = '语音识别被用户或系统中止。';
break;
default:
errorMessage = `识别错误: ${event.error}`;
}
updateStatus(`错误: ${errorMessage}`);
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'error',
data: {
error: event.error,
message: errorMessage || event.message || `Recognition error: ${event.error}`
}
}));
}
};
recognition.onend = () => {
console.log('[Browser Page] SpeechRecognition ended.');
// 检查是否是由于错误或用户手动停止导致的结束
const isErrorOrStopped = statusDiv.textContent.includes('错误') || statusDiv.textContent.includes('停止');
if (!isErrorOrStopped) {
// 如果不是由于错误或手动停止,则自动重新启动语音识别
updateStatus("识别暂停,正在重新启动...");
// 保存当前的recognition对象
const currentRecognition = recognition;
// 尝试重新启动语音识别
setTimeout(() => {
try {
if (currentRecognition && currentRecognition === recognition) {
currentRecognition.start();
console.log('[Browser Page] Automatically restarting recognition');
} else {
// 如果recognition对象已经变化重新创建一个
setupRecognition();
if (recognition) {
recognition.start();
console.log('[Browser Page] Created new recognition instance and started');
}
}
} catch (e) {
console.error('[Browser Page] Failed to restart recognition:', e);
updateStatus("识别已停止。等待指令...");
}
}, 300);
} else {
updateStatus("识别已停止。等待指令...");
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'status', message: 'stopped' }));
}
// 只有在手动停止或错误时才重置recognition对象
recognition = null;
}
};
return true;
}
function startRecognition() {
if (!SpeechRecognition) {
updateStatus('错误:浏览器不支持 Web Speech API。');
return;
}
// 显示正在准备的状态
updateStatus('正在准备麦克风...');
if (recognition) {
console.log('[Browser Page] Recognition already exists, stopping first.');
stopRecognition();
}
if (!setupRecognition()) return;
console.log('[Browser Page] Attempting to start recognition...');
try {
// 设置更长的超时时间,确保有足够的时间获取麦克风权限
const micPermissionTimeout = setTimeout(() => {
updateStatus('获取麦克风权限超时,请刷新页面重试。');
}, 10000); // 10秒超时
navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
}
})
.then(stream => {
clearTimeout(micPermissionTimeout);
console.log('[Browser Page] Microphone access granted.');
// 检查麦克风音量级别
const audioContext = new AudioContext();
const analyser = audioContext.createAnalyser();
const microphone = audioContext.createMediaStreamSource(stream);
const javascriptNode = audioContext.createScriptProcessor(2048, 1, 1);
analyser.smoothingTimeConstant = 0.8;
analyser.fftSize = 1024;
microphone.connect(analyser);
analyser.connect(javascriptNode);
javascriptNode.connect(audioContext.destination);
javascriptNode.onaudioprocess = function () {
const array = new Uint8Array(analyser.frequencyBinCount);
analyser.getByteFrequencyData(array);
let values = 0;
const length = array.length;
for (let i = 0; i < length; i++) {
values += (array[i]);
}
const average = values / length;
console.log('[Browser Page] Microphone volume level:', average);
// 如果音量太低,显示提示
if (average < 5) {
updateStatus('麦克风音量很低,请说话或检查麦克风设置。');
} else {
updateStatus('🎤 正在识别...');
}
// 只检查一次就断开连接
microphone.disconnect();
analyser.disconnect();
javascriptNode.disconnect();
};
// 释放测试用的音频流
setTimeout(() => {
stream.getTracks().forEach(track => track.stop());
audioContext.close();
}, 1000);
// 启动语音识别
if (recognition) {
recognition.start();
updateStatus('🎤 正在识别...');
} else {
updateStatus('错误Recognition 实例丢失。');
console.error('[Browser Page] Recognition instance lost before start.');
}
})
.catch(err => {
clearTimeout(micPermissionTimeout);
console.error('[Browser Page] Microphone access error:', err);
let errorMsg = `无法访问麦克风 (${err.name})`;
if (err.name === 'NotAllowedError') {
errorMsg = '麦克风访问被拒绝。请在浏览器设置中允许麦克风访问权限。';
} else if (err.name === 'NotFoundError') {
errorMsg = '未找到麦克风设备。请确保麦克风已连接。';
}
updateStatus(`错误: ${errorMsg}`);
recognition = null;
});
} catch (e) {
console.error('[Browser Page] Error calling recognition.start():', e);
updateStatus(`启动识别时出错: ${e.message}`);
recognition = null;
}
}
function stopRecognition() {
if (recognition) {
console.log('[Browser Page] Stopping recognition...');
updateStatus("正在停止识别...");
try {
recognition.stop();
} catch (e) {
console.error('[Browser Page] Error calling recognition.stop():', e);
recognition = null;
updateStatus("停止时出错,已强制重置。");
}
} else {
console.log('[Browser Page] Recognition not active, nothing to stop.');
updateStatus("识别未运行。");
}
}
</script>
</body>
</html>

View File

@ -0,0 +1,27 @@
{
"name": "cherry-asr-server",
"version": "1.0.0",
"description": "Cherry Studio ASR Server",
"main": "server.js",
"bin": "server.js",
"scripts": {
"start": "node server.js",
"build": "pkg ."
},
"pkg": {
"targets": [
"node16-win-x64"
],
"outputPath": "dist",
"assets": [
"index.html"
]
},
"dependencies": {
"express": "^4.18.2",
"ws": "^8.13.0"
},
"devDependencies": {
"pkg": "^5.8.1"
}
}

View File

@ -0,0 +1,172 @@
const http = require('http')
const WebSocket = require('ws')
const express = require('express')
const path = require('path') // Need path module
const app = express()
const port = 8080 // Define the port
// 获取index.html文件的路径
function getIndexHtmlPath() {
// 在开发环境中,直接使用相对路径
const devPath = path.join(__dirname, 'index.html');
// 在pkg打包后文件会被包含在可执行文件中
// 使用process.pkg检测是否是打包环境
if (process.pkg) {
// 在打包环境中,使用绝对路径
return path.join(path.dirname(process.execPath), 'index.html');
}
// 如果文件存在,返回开发路径
try {
if (require('fs').existsSync(devPath)) {
return devPath;
}
} catch (e) {
console.error('Error checking file existence:', e);
}
// 如果都不存在,尝试使用当前目录
return path.join(process.cwd(), 'index.html');
}
// 提供网页给 Edge 浏览器
app.get('/', (req, res) => {
const indexPath = getIndexHtmlPath();
console.log(`Serving index.html from: ${indexPath}`);
res.sendFile(indexPath);
})
const server = http.createServer(app)
const wss = new WebSocket.Server({ server })
let browserConnection = null
let electronConnection = null
wss.on('connection', (ws) => {
console.log('[Server] WebSocket client connected') // Add log
ws.on('message', (message) => {
let data
try {
// Ensure message is treated as string before parsing
data = JSON.parse(message.toString())
console.log('[Server] Received message:', data) // Log parsed data
} catch (e) {
console.error('[Server] Failed to parse message or message is not JSON:', message.toString(), e)
return // Ignore non-JSON messages
}
// 识别客户端类型
if (data.type === 'identify') {
if (data.role === 'browser') {
browserConnection = ws
console.log('[Server] Browser identified and connected')
// Notify Electron that the browser is ready
if (electronConnection && electronConnection.readyState === WebSocket.OPEN) {
electronConnection.send(JSON.stringify({ type: 'status', message: 'browser_ready' }));
console.log('[Server] Sent browser_ready status to Electron');
}
// Notify Electron if it's already connected
if (electronConnection) {
electronConnection.send(JSON.stringify({ type: 'status', message: 'Browser connected' }))
}
ws.on('close', () => {
console.log('[Server] Browser disconnected')
browserConnection = null
// Notify Electron
if (electronConnection) {
electronConnection.send(JSON.stringify({ type: 'status', message: 'Browser disconnected' }))
}
})
ws.on('error', (error) => {
console.error('[Server] Browser WebSocket error:', error)
browserConnection = null // Assume disconnected on error
if (electronConnection) {
electronConnection.send(JSON.stringify({ type: 'status', message: 'Browser connection error' }))
}
})
} else if (data.role === 'electron') {
electronConnection = ws
console.log('[Server] Electron identified and connected')
// If browser is already connected when Electron connects, notify Electron immediately
if (browserConnection && browserConnection.readyState === WebSocket.OPEN) {
electronConnection.send(JSON.stringify({ type: 'status', message: 'browser_ready' }));
console.log('[Server] Sent initial browser_ready status to Electron');
}
ws.on('close', () => {
console.log('[Server] Electron disconnected')
electronConnection = null
// Maybe send stop to browser if electron disconnects?
// if (browserConnection) browserConnection.send(JSON.stringify({ type: 'stop' }));
})
ws.on('error', (error) => {
console.error('[Server] Electron WebSocket error:', error)
electronConnection = null // Assume disconnected on error
})
}
}
// Electron 控制开始/停止
else if (data.type === 'start' && ws === electronConnection) {
if (browserConnection && browserConnection.readyState === WebSocket.OPEN) {
console.log('[Server] Relaying START command to browser')
browserConnection.send(JSON.stringify({ type: 'start' }))
} else {
console.log('[Server] Cannot relay START: Browser not connected')
// Optionally notify Electron back
electronConnection.send(JSON.stringify({ type: 'error', message: 'Browser not connected for ASR' }))
}
} else if (data.type === 'stop' && ws === electronConnection) {
if (browserConnection && browserConnection.readyState === WebSocket.OPEN) {
console.log('[Server] Relaying STOP command to browser')
browserConnection.send(JSON.stringify({ type: 'stop' }))
} else {
console.log('[Server] Cannot relay STOP: Browser not connected')
}
}
// 浏览器发送识别结果
else if (data.type === 'result' && ws === browserConnection) {
if (electronConnection && electronConnection.readyState === WebSocket.OPEN) {
// console.log('[Server] Relaying RESULT to Electron:', data.data); // Log less frequently if needed
electronConnection.send(JSON.stringify({ type: 'result', data: data.data }))
} else {
// console.log('[Server] Cannot relay RESULT: Electron not connected');
}
}
// 浏览器发送状态更新 (例如 'stopped')
else if (data.type === 'status' && ws === browserConnection) {
if (electronConnection && electronConnection.readyState === WebSocket.OPEN) {
console.log('[Server] Relaying STATUS to Electron:', data.message) // Log status being relayed
electronConnection.send(JSON.stringify({ type: 'status', message: data.message }))
} else {
console.log('[Server] Cannot relay STATUS: Electron not connected')
}
} else {
console.log('[Server] Received unknown message type or from unknown source:', data)
}
})
ws.on('error', (error) => {
// Generic error handling for connection before identification
console.error('[Server] Initial WebSocket connection error:', error)
// Attempt to clean up based on which connection it might be (if identified)
if (ws === browserConnection) {
browserConnection = null
if (electronConnection)
electronConnection.send(JSON.stringify({ type: 'status', message: 'Browser connection error' }))
} else if (ws === electronConnection) {
electronConnection = null
}
})
})
server.listen(port, () => {
console.log(`[Server] Server running at http://localhost:${port}`)
})
// Handle server errors
server.on('error', (error) => {
console.error(`[Server] Failed to start server:`, error)
process.exit(1) // Exit if server fails to start
})

View File

@ -0,0 +1,226 @@
import { AudioOutlined, LoadingOutlined } from '@ant-design/icons'
import { useSettings } from '@renderer/hooks/useSettings'
import ASRService from '@renderer/services/ASRService'
import { Button, Tooltip } from 'antd'
import { FC, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface Props {
onTranscribed: (text: string) => void
disabled?: boolean
style?: React.CSSProperties
}
const ASRButton: FC<Props> = ({ onTranscribed, disabled = false, style }) => {
const { t } = useTranslation()
const { asrEnabled } = useSettings()
const [isRecording, setIsRecording] = useState(false)
const [isProcessing, setIsProcessing] = useState(false)
const [countdown, setCountdown] = useState(0)
const [isCountingDown, setIsCountingDown] = useState(false)
const handleASR = useCallback(async () => {
if (!asrEnabled) {
window.message.error({ content: t('settings.asr.error.not_enabled'), key: 'asr-error' })
return
}
if (isRecording) {
// 停止录音并处理
setIsRecording(false)
setIsProcessing(true)
try {
// 添加事件监听器监听服务器发送的stopped消息
const originalCallback = ASRService.resultCallback
const stopCallback = (text: string) => {
// 如果是空字符串,只重置状态,不调用原始回调
if (text === '') {
setIsProcessing(false)
return
}
// 否则调用原始回调并重置状态
if (originalCallback) originalCallback(text)
setIsProcessing(false)
}
await ASRService.stopRecording(stopCallback)
} catch (error) {
console.error('ASR error:', error)
setIsProcessing(false)
}
} else {
// 开始录音
// 显示3秒倒计时同时立即开始录音
setIsCountingDown(true)
setCountdown(3)
setIsRecording(true)
// 立即发送开始信号
try {
await ASRService.startRecording(onTranscribed)
} catch (error) {
console.error('Failed to start recording:', error)
setIsRecording(false)
setIsCountingDown(false)
return
}
// 倒计时结束后只隐藏倒计时显示
setTimeout(() => {
setIsCountingDown(false)
}, 3000) // 3秒倒计时
}
}, [asrEnabled, isRecording, onTranscribed, t])
const handleCancel = useCallback(() => {
if (isCountingDown) {
// 如果在倒计时中,取消倒计时和录音
setIsCountingDown(false)
setCountdown(0)
// 同时取消录音,因为录音已经开始
ASRService.cancelRecording()
setIsRecording(false)
} else if (isRecording) {
// 如果已经在录音,取消录音
ASRService.cancelRecording()
setIsRecording(false)
}
}, [isRecording, isCountingDown])
// 倒计时效果
useEffect(() => {
if (isCountingDown && countdown > 0) {
const timer = setTimeout(() => {
setCountdown(countdown - 1)
}, 1000)
return () => clearTimeout(timer)
}
return undefined // 添加返回值以解决TS7030错误
}, [countdown, isCountingDown])
if (!asrEnabled) {
return null
}
return (
<Tooltip title={isRecording ? t('settings.asr.stop') : isCountingDown ? `${t('settings.asr.preparing')} (${countdown})` : t('settings.asr.start')}>
<ButtonWrapper>
<StyledButton
type={isRecording || isCountingDown ? 'primary' : 'default'}
icon={isProcessing ? <LoadingOutlined /> : isCountingDown ? null : <AudioOutlined />}
onClick={handleASR}
onDoubleClick={handleCancel}
disabled={disabled || isProcessing || (isCountingDown && countdown > 0)}
style={style}
className={isCountingDown ? 'counting-down' : ''}
>
{isCountingDown && (
<CountdownNumber>{countdown}</CountdownNumber>
)}
</StyledButton>
{isCountingDown && (
<CountdownIndicator>
{t('settings.asr.preparing')} ({countdown})
</CountdownIndicator>
)}
</ButtonWrapper>
</Tooltip>
)
}
const ButtonWrapper = styled.div`
position: relative;
display: inline-block;
`
const CountdownIndicator = styled.div`
position: absolute;
top: -25px;
left: 50%;
transform: translateX(-50%);
background-color: var(--color-primary);
color: white;
padding: 2px 8px;
border-radius: 10px;
font-size: 12px;
white-space: nowrap;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
animation: pulse 1s infinite;
z-index: 10;
@keyframes pulse {
0% { opacity: 0.7; }
50% { opacity: 1; }
100% { opacity: 0.7; }
}
&:after {
content: '';
position: absolute;
bottom: -5px;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 5px solid var(--color-primary);
}
`
const CountdownNumber = styled.span`
font-size: 18px;
font-weight: bold;
animation: zoom 1s infinite;
@keyframes zoom {
0% { transform: scale(0.8); }
50% { transform: scale(1.2); }
100% { transform: scale(0.8); }
}
`
const StyledButton = styled(Button)`
min-width: 30px;
height: 30px;
font-size: 16px;
border-radius: 50%;
transition: all 0.3s ease;
color: var(--color-icon);
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
padding: 0;
&.anticon,
&.iconfont {
transition: all 0.3s ease;
color: var(--color-icon);
}
&:hover {
background-color: var(--color-background-soft);
.anticon,
.iconfont {
color: var(--color-text-1);
}
}
&.active {
background-color: var(--color-primary) !important;
.anticon,
.iconfont {
color: var(--color-white-soft);
}
&:hover {
background-color: var(--color-primary);
}
}
&.counting-down {
font-weight: bold;
background-color: var(--color-primary);
color: var(--color-white-soft);
}
`
export default ASRButton

View File

@ -1377,6 +1377,52 @@
"test": "Test Speech",
"help": "Text-to-speech functionality supports converting text to natural-sounding speech.",
"learn_more": "Learn more"
},
"asr": {
"title": "Speech Recognition",
"tab_title": "Speech Recognition",
"enable": "Enable Speech Recognition",
"enable.help": "Enable to convert speech to text",
"service_type": "Service Type",
"service_type.browser": "Browser",
"service_type.local": "Local Server",
"api_key": "API Key",
"api_key.placeholder": "Enter OpenAI API key",
"api_url": "API URL",
"api_url.placeholder": "Example: https://api.openai.com/v1/audio/transcriptions",
"model": "Model",
"browser.info": "Use the browser's built-in speech recognition feature, no additional setup required",
"local.info": "Use local server and browser for speech recognition, need to start the server and open the browser page first",
"local.browser_tip": "Please open this page in Edge browser and keep the browser window open",
"local.test_connection": "Test Connection",
"local.connection_success": "Connection successful",
"local.connection_failed": "Connection failed, please make sure the server is running",
"server.start": "Start Server",
"server.stop": "Stop Server",
"server.starting": "Starting server...",
"server.started": "Server started",
"server.stopping": "Stopping server...",
"server.stopped": "Server stopped",
"server.already_running": "Server is already running",
"server.not_running": "Server is not running",
"server.start_failed": "Failed to start server",
"server.stop_failed": "Failed to stop server",
"open_browser": "Open Browser Page",
"test": "Test Speech Recognition",
"test_info": "Please use the speech recognition button in the input box to test",
"start": "Start Recording",
"stop": "Stop Recording",
"preparing": "Preparing",
"recording": "Recording...",
"processing": "Processing speech...",
"success": "Speech recognition successful",
"completed": "Speech recognition completed",
"canceled": "Recording canceled",
"error": {
"not_enabled": "Speech recognition is not enabled",
"start_failed": "Failed to start recording",
"transcribe_failed": "Failed to transcribe speech"
}
}
},
"translate": {

View File

@ -1356,6 +1356,52 @@
},
"help": "OpenAIのTTS APIを使用するには、APIキーが必要です。Edge TTSはブラウザの機能を使用するため、APIキーは不要です。",
"learn_more": "詳細はこちら"
},
"asr": {
"title": "音声認識",
"tab_title": "音声認識",
"enable": "音声認識を有効にする",
"enable.help": "音声をテキストに変換する機能を有効にします",
"service_type": "サービスタイプ",
"service_type.browser": "ブラウザ",
"service_type.local": "ローカルサーバー",
"api_key": "APIキー",
"api_key.placeholder": "OpenAI APIキーを入力",
"api_url": "API URL",
"api_url.placeholder": "例https://api.openai.com/v1/audio/transcriptions",
"model": "モデル",
"browser.info": "ブラウザの内蔵音声認識機能を使用します。追加設定は不要です",
"local.info": "ローカルサーバーとブラウザを使用して音声認識を行います。サーバーを起動してブラウザページを開く必要があります",
"local.browser_tip": "このページをEdgeブラウザで開き、ブラウザウィンドウを開いたままにしてください",
"local.test_connection": "接続テスト",
"local.connection_success": "接続成功",
"local.connection_failed": "接続失敗。サーバーが起動していることを確認してください",
"server.start": "サーバー起動",
"server.stop": "サーバー停止",
"server.starting": "サーバーを起動中...",
"server.started": "サーバーが起動しました",
"server.stopping": "サーバーを停止中...",
"server.stopped": "サーバーが停止しました",
"server.already_running": "サーバーは既に実行中です",
"server.not_running": "サーバーは実行されていません",
"server.start_failed": "サーバーの起動に失敗しました",
"server.stop_failed": "サーバーの停止に失敗しました",
"open_browser": "ブラウザページを開く",
"test": "音声認識テスト",
"test_info": "入力ボックスの音声認識ボタンを使用してテストしてください",
"start": "録音開始",
"stop": "録音停止",
"preparing": "準備中",
"recording": "録音中...",
"processing": "音声処理中...",
"success": "音声認識成功",
"completed": "音声認識完了",
"canceled": "録音キャンセル",
"error": {
"not_enabled": "音声認識が有効になっていません",
"start_failed": "録音の開始に失敗しました",
"transcribe_failed": "音声の文字起こしに失敗しました"
}
}
},
"translate": {

View File

@ -1335,8 +1335,14 @@
"title": "隐私设置",
"enable_privacy_mode": "匿名发送错误报告和数据统计"
},
"voice": {
"title": "语音功能",
"help": "语音功能包括文本转语音(TTS)和语音识别(ASR)。",
"learn_more": "了解更多"
},
"tts": {
"title": "语音设置",
"title": "语音合成",
"tab_title": "语音合成",
"enable": "启用语音合成",
"enable.help": "启用后可以将文本转换为语音",
"reset": "重置",
@ -1376,7 +1382,61 @@
"max_text_length": "最大文本长度",
"test": "测试语音",
"help": "语音合成功能支持将文本转换为自然语音。",
"learn_more": "了解更多"
"learn_more": "了解更多",
"error": {
"not_enabled": "语音合成功能未启用",
"no_api_key": "未设置API密钥",
"no_edge_voice": "未选择Edge TTS音色",
"browser_not_support": "浏览器不支持语音合成"
}
},
"asr": {
"title": "语音识别",
"tab_title": "语音识别",
"enable": "启用语音识别",
"enable.help": "启用后可以将语音转换为文本",
"service_type": "服务类型",
"service_type.browser": "浏览器",
"service_type.local": "本地服务器",
"api_key": "API密钥",
"api_key.placeholder": "请输入OpenAI API密钥",
"api_url": "API地址",
"api_url.placeholder": "例如https://api.openai.com/v1/audio/transcriptions",
"model": "模型",
"browser.info": "使用浏览器内置的语音识别功能,无需额外设置",
"local.info": "使用本地服务器和浏览器进行语音识别,需要先启动服务器并打开浏览器页面",
"local.browser_tip": "请在Edge浏览器中打开此页面并保持浏览器窗口打开",
"local.test_connection": "测试连接",
"local.connection_success": "连接成功",
"local.connection_failed": "连接失败,请确保服务器已启动",
"server.start": "启动服务器",
"server.stop": "停止服务器",
"server.starting": "正在启动服务器...",
"server.started": "服务器已启动",
"server.stopping": "正在停止服务器...",
"server.stopped": "服务器已停止",
"server.already_running": "服务器已经在运行中",
"server.not_running": "服务器未运行",
"server.start_failed": "启动服务器失败",
"server.stop_failed": "停止服务器失败",
"open_browser": "打开浏览器页面",
"test": "测试语音识别",
"test_info": "请在输入框中使用语音识别按钮进行测试",
"start": "开始录音",
"stop": "停止录音",
"preparing": "准备中",
"recording": "正在录音...",
"processing": "正在处理语音...",
"success": "语音识别成功",
"completed": "语音识别完成",
"canceled": "已取消录音",
"error": {
"not_enabled": "语音识别功能未启用",
"no_api_key": "未设置API密钥",
"browser_not_support": "浏览器不支持语音识别",
"start_failed": "开始录音失败",
"transcribe_failed": "语音识别失败"
}
}
},
"translate": {

View File

@ -14,6 +14,7 @@ import {
TranslationOutlined
} from '@ant-design/icons'
import { QuickPanelListItem, QuickPanelView, useQuickPanel } from '@renderer/components/QuickPanel'
import ASRButton from '@renderer/components/ASRButton'
import TranslateButton from '@renderer/components/TranslateButton'
import { isGenerateImageModel, isVisionModel, isWebSearchModel } from '@renderer/config/models'
import db from '@renderer/databases'
@ -1008,6 +1009,19 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
</ToolbarMenu>
<ToolbarMenu>
<TranslateButton text={text} onTranslated={onTranslated} isLoading={isTranslating} />
<ASRButton onTranscribed={(transcribedText) => {
// 如果是空字符串,不做任何处理
if (!transcribedText) return
// 将识别的文本添加到当前输入框
setText((prevText) => {
// 如果当前有文本,添加空格后再添加识别的文本
if (prevText.trim()) {
return prevText + ' ' + transcribedText
}
return transcribedText
})
}} />
{loading && (
<Tooltip placement="top" title={t('chat.input.pause')} arrow>
<ToolbarButton type="text" onClick={onPause} style={{ marginRight: -2, marginTop: 1 }}>

View File

@ -16,7 +16,7 @@ import { UploadOutlined } from '@ant-design/icons'
import ObsidianExportPopup from '@renderer/components/Popups/ObsidianExportPopup'
import SelectModelPopup from '@renderer/components/Popups/SelectModelPopup'
import TextEditPopup from '@renderer/components/Popups/TextEditPopup'
import TTSButton from '@renderer/components/TTSButton'
// import TTSButton from '@renderer/components/TTSButton' // 暂时不使用
import { isReasoningModel } from '@renderer/config/models'
import { TranslateLanguageOptions } from '@renderer/config/translate'
import { useMessageOperations, useTopicLoading } from '@renderer/hooks/useMessageOperations'
@ -154,14 +154,14 @@ const MessageMenubar: FC<Props> = (props) => {
const imageUrls: string[] = []
let match
let content = editedText
while ((match = imageRegex.exec(editedText)) !== null) {
imageUrls.push(match[1])
content = content.replace(match[0], '')
}
// 更新消息内容,保留图片信息
await editMessage(message.id, {
await editMessage(message.id, {
content: content.trim(),
metadata: {
...message.metadata,
@ -171,9 +171,9 @@ const MessageMenubar: FC<Props> = (props) => {
} : undefined
}
})
resendMessage && handleResendUserMessage({
...message,
resendMessage && handleResendUserMessage({
...message,
content: content.trim(),
metadata: {
...message.metadata,

View File

@ -127,7 +127,7 @@ const SettingsPage: FC = () => {
<MenuItemLink to="/settings/tts">
<MenuItem className={isRoute('/settings/tts')}>
<SoundOutlined />
{t('settings.tts.title')}
{t('settings.voice.title')}
</MenuItem>
</MenuItemLink>
<MenuItemLink to="/settings/about">

View File

@ -0,0 +1,271 @@
import { InfoCircleOutlined, GlobalOutlined, PlayCircleOutlined, StopOutlined } from '@ant-design/icons'
import { useTheme } from '@renderer/context/ThemeProvider'
import ASRService from '@renderer/services/ASRService'
import ASRServerService from '@renderer/services/ASRServerService'
import { useAppDispatch } from '@renderer/store'
import {
setAsrApiKey,
setAsrApiUrl,
setAsrEnabled,
setAsrModel,
setAsrServiceType
} from '@renderer/store/settings'
import { Button, Form, Input, Select, Space, Switch } from 'antd'
import { FC, useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useSelector } from 'react-redux'
import styled from 'styled-components'
const ASRSettings: FC = () => {
const { t } = useTranslation()
const { isDark } = useTheme()
const dispatch = useAppDispatch()
// 服务器状态
const [isServerRunning, setIsServerRunning] = useState(false)
// 从 Redux 获取 ASR 设置
const asrEnabled = useSelector((state: any) => state.settings.asrEnabled)
const asrServiceType = useSelector((state: any) => state.settings.asrServiceType || 'openai')
const asrApiKey = useSelector((state: any) => state.settings.asrApiKey)
const asrApiUrl = useSelector((state: any) => state.settings.asrApiUrl)
const asrModel = useSelector((state: any) => state.settings.asrModel || 'whisper-1')
// 检查服务器状态
useEffect(() => {
if (asrServiceType === 'local') {
setIsServerRunning(ASRServerService.isRunning())
}
return undefined // 添加返回值以解决TS7030错误
}, [asrServiceType])
// 服务类型选项
const serviceTypeOptions = [
{ label: 'OpenAI', value: 'openai' },
{ label: t('settings.asr.service_type.browser'), value: 'browser' },
{ label: t('settings.asr.service_type.local'), value: 'local' }
]
// 模型选项
const modelOptions = [
{ label: 'whisper-1', value: 'whisper-1' }
]
return (
<Container>
<Form layout="vertical">
{/* ASR开关 */}
<Form.Item>
<Space>
<Switch checked={asrEnabled} onChange={(checked) => dispatch(setAsrEnabled(checked))} />
<span>{t('settings.asr.enable')}</span>
<Tooltip title={t('settings.asr.enable.help')}>
<InfoCircleOutlined style={{ color: 'var(--color-text-3)' }} />
</Tooltip>
</Space>
</Form.Item>
{/* 服务类型选择 */}
<Form.Item label={t('settings.asr.service_type')} style={{ marginBottom: 16 }}>
<Select
value={asrServiceType}
onChange={(value) => dispatch(setAsrServiceType(value))}
options={serviceTypeOptions}
disabled={!asrEnabled}
style={{ width: '100%' }}
/>
</Form.Item>
{/* OpenAI ASR设置 */}
{asrServiceType === 'openai' && (
<>
{/* API密钥 */}
<Form.Item label={t('settings.asr.api_key')} style={{ marginBottom: 16 }}>
<Input.Password
value={asrApiKey}
onChange={(e) => dispatch(setAsrApiKey(e.target.value))}
placeholder={t('settings.asr.api_key.placeholder')}
disabled={!asrEnabled}
/>
</Form.Item>
{/* API地址 */}
<Form.Item label={t('settings.asr.api_url')} style={{ marginBottom: 16 }}>
<Input
value={asrApiUrl}
onChange={(e) => dispatch(setAsrApiUrl(e.target.value))}
placeholder={t('settings.asr.api_url.placeholder')}
disabled={!asrEnabled}
/>
</Form.Item>
{/* 模型选择 */}
<Form.Item label={t('settings.asr.model')} style={{ marginBottom: 16 }}>
<Select
value={asrModel}
onChange={(value) => dispatch(setAsrModel(value))}
options={modelOptions}
disabled={!asrEnabled}
style={{ width: '100%' }}
/>
</Form.Item>
</>
)}
{/* 浏览器ASR设置 */}
{asrServiceType === 'browser' && (
<Form.Item>
<Alert type="info" message={t('settings.asr.browser.info')} />
</Form.Item>
)}
{/* 本地服务器ASR设置 */}
{asrServiceType === 'local' && (
<>
<Form.Item>
<Alert type="info" message={t('settings.asr.local.info')} />
</Form.Item>
<Form.Item>
<Space direction="vertical" style={{ width: '100%' }}>
<Space>
<Button
type="primary"
icon={<PlayCircleOutlined />}
onClick={async () => {
const success = await ASRServerService.startServer()
if (success) {
setIsServerRunning(true)
}
}}
disabled={!asrEnabled || isServerRunning}
>
{t('settings.asr.server.start')}
</Button>
<Button
danger
icon={<StopOutlined />}
onClick={async () => {
const success = await ASRServerService.stopServer()
if (success) {
setIsServerRunning(false)
}
}}
disabled={!asrEnabled || !isServerRunning}
>
{t('settings.asr.server.stop')}
</Button>
</Space>
<Button
type="primary"
icon={<GlobalOutlined />}
onClick={() => ASRServerService.openServerPage()}
disabled={!asrEnabled || !isServerRunning}
>
{t('settings.asr.open_browser')}
</Button>
<Button
onClick={() => {
// 尝试连接到WebSocket服务器
ASRService.connectToWebSocketServer?.().then(connected => {
if (connected) {
window.message.success({ content: t('settings.asr.local.connection_success'), key: 'ws-connect' })
} else {
window.message.error({ content: t('settings.asr.local.connection_failed'), key: 'ws-connect' })
}
}).catch(error => {
console.error('Failed to connect to WebSocket server:', error)
window.message.error({ content: t('settings.asr.local.connection_failed'), key: 'ws-connect' })
})
}}
disabled={!asrEnabled || !isServerRunning}
>
{t('settings.asr.local.test_connection')}
</Button>
<BrowserTip>{t('settings.asr.local.browser_tip')}</BrowserTip>
</Space>
</Form.Item>
</>
)}
{/* 测试按钮 */}
<Form.Item>
<Space>
<Button
type="primary"
disabled={!asrEnabled}
onClick={() => window.message.info({ content: t('settings.asr.test_info'), key: 'asr-test' })}>
{t('settings.asr.test')}
</Button>
</Space>
</Form.Item>
</Form>
</Container>
)
}
const Container = styled.div`
padding: 0 0 20px 0;
`
const Tooltip = styled.div`
position: relative;
display: inline-block;
cursor: help;
&:hover::after {
content: attr(title);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
padding: 5px 10px;
background-color: var(--color-background-soft);
border: 1px solid var(--color-border);
border-radius: 4px;
white-space: nowrap;
z-index: 1;
font-size: 12px;
}
`
const Alert = styled.div<{ type: 'info' | 'warning' | 'error' | 'success' }>`
padding: 10px 15px;
border-radius: 4px;
margin-bottom: 16px;
background-color: ${(props) =>
props.type === 'info'
? 'var(--color-info-bg)'
: props.type === 'warning'
? 'var(--color-warning-bg)'
: props.type === 'error'
? 'var(--color-error-bg)'
: 'var(--color-success-bg)'};
border: 1px solid
${(props) =>
props.type === 'info'
? 'var(--color-info-border)'
: props.type === 'warning'
? 'var(--color-warning-border)'
: props.type === 'error'
? 'var(--color-error-border)'
: 'var(--color-success-border)'};
color: ${(props) =>
props.type === 'info'
? 'var(--color-info-text)'
: props.type === 'warning'
? 'var(--color-warning-text)'
: props.type === 'error'
? 'var(--color-error-text)'
: 'var(--color-success-text)'};
`
const BrowserTip = styled.div`
font-size: 12px;
color: var(--color-text-3);
margin-top: 8px;
`
export default ASRSettings

View File

@ -1,4 +1,4 @@
import { PlusOutlined, ReloadOutlined, SoundOutlined } from '@ant-design/icons'
import { AudioOutlined, PlusOutlined, ReloadOutlined, SoundOutlined } from '@ant-design/icons'
import { useTheme } from '@renderer/context/ThemeProvider'
import TTSService from '@renderer/services/TTSService'
import store, { useAppDispatch } from '@renderer/store'
@ -17,7 +17,7 @@ import {
setTtsServiceType,
setTtsVoice
} from '@renderer/store/settings'
import { Button, Form, Input, message, Select, Space, Switch, Tag } from 'antd'
import { Button, Form, Input, message, Select, Space, Switch, Tag, Tabs } from 'antd'
import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useSelector } from 'react-redux'
@ -33,6 +33,8 @@ import {
SettingTitle
} from '..'
import ASRSettings from './ASRSettings'
const CustomVoiceInput = styled.div`
display: flex;
flex-direction: column;
@ -378,341 +380,367 @@ const TTSSettings: FC = () => {
<SettingTitle>
<Space>
<SoundOutlined />
{t('settings.tts.title')}
{t('settings.voice.title')}
</Space>
</SettingTitle>
<SettingDivider />
<SettingGroup>
<SettingRow>
<SettingRowTitle>{t('settings.tts.enable')}</SettingRowTitle>
<Switch checked={ttsEnabled} onChange={(checked) => dispatch(setTtsEnabled(checked))} />
</SettingRow>
<SettingHelpText>{t('settings.tts.enable.help')}</SettingHelpText>
</SettingGroup>
<Tabs
defaultActiveKey="tts"
items={[
{
key: 'tts',
label: (
<span>
<SoundOutlined /> {t('settings.tts.tab_title')}
</span>
),
children: (
<div>
<SettingGroup>
<SettingRow>
<SettingRowTitle>{t('settings.tts.enable')}</SettingRowTitle>
<Switch checked={ttsEnabled} onChange={(checked) => dispatch(setTtsEnabled(checked))} />
</SettingRow>
<SettingHelpText>{t('settings.tts.enable.help')}</SettingHelpText>
</SettingGroup>
{/* 重置按钮 */}
<SettingGroup>
<SettingRow>
<SettingRowTitle>{t('settings.tts.reset_title')}</SettingRowTitle>
<Button
danger
onClick={() => {
if (window.confirm(t('settings.tts.reset_confirm'))) {
dispatch(resetTtsCustomValues())
window.message.success({ content: t('settings.tts.reset_success'), key: 'reset-tts' })
}
}}>
{t('settings.tts.reset')}
</Button>
</SettingRow>
<SettingHelpText>{t('settings.tts.reset_help')}</SettingHelpText>
</SettingGroup>
<SettingGroup>
<SettingRowTitle>{t('settings.tts.api_settings')}</SettingRowTitle>
<Form layout="vertical" style={{ width: '100%', marginTop: 16 }}>
{/* TTS服务类型选择 */}
<Form.Item label={t('settings.tts.service_type')} style={{ marginBottom: 16 }}>
<FlexContainer>
<Select
value={ttsServiceType}
onChange={(value: string) => {
console.log('切换TTS服务类型为:', value)
// 先将新的服务类型写入Redux状态
dispatch(setTtsServiceType(value))
{/* 重置按钮 */}
<SettingGroup>
<SettingRow>
<SettingRowTitle>{t('settings.tts.reset_title')}</SettingRowTitle>
<Button
danger
onClick={() => {
if (window.confirm(t('settings.tts.reset_confirm'))) {
dispatch(resetTtsCustomValues())
window.message.success({ content: t('settings.tts.reset_success'), key: 'reset-tts' })
}
}}>
{t('settings.tts.reset')}
</Button>
</SettingRow>
<SettingHelpText>{t('settings.tts.reset_help')}</SettingHelpText>
</SettingGroup>
<SettingGroup>
<SettingRowTitle>{t('settings.tts.api_settings')}</SettingRowTitle>
<Form layout="vertical" style={{ width: '100%', marginTop: 16 }}>
{/* TTS服务类型选择 */}
<Form.Item label={t('settings.tts.service_type')} style={{ marginBottom: 16 }}>
<FlexContainer>
<Select
value={ttsServiceType}
onChange={(value: string) => {
console.log('切换TTS服务类型为:', value)
// 先将新的服务类型写入Redux状态
dispatch(setTtsServiceType(value))
// 等待一下,确保状态已更新
setTimeout(() => {
// 验证状态是否正确更新
const currentType = store.getState().settings.ttsServiceType
console.log('更新后的TTS服务类型:', currentType)
// 等待一下,确保状态已更新
setTimeout(() => {
// 验证状态是否正确更新
const currentType = store.getState().settings.ttsServiceType
console.log('更新后的TTS服务类型:', currentType)
// 如果状态没有正确更新,再次尝试
if (currentType !== value) {
console.log('状态未正确更新,再次尝试')
dispatch(setTtsServiceType(value))
}
}, 100)
}}
options={[
{ label: t('settings.tts.service_type.openai'), value: 'openai' },
{ label: t('settings.tts.service_type.edge'), value: 'edge' }
]}
disabled={!ttsEnabled}
style={{ flex: 1 }}
/>
<Button
icon={<ReloadOutlined />}
onClick={() => {
// 强制刷新当前服务类型设置
const currentType = store.getState().settings.ttsServiceType
console.log('强制刷新TTS服务类型:', currentType)
dispatch(setTtsServiceType(currentType))
window.message.success({
content: t('settings.tts.service_type.refreshed', { defaultValue: '已刷新TTS服务类型设置' }),
key: 'tts-refresh'
})
}}
disabled={!ttsEnabled}
title={t('settings.tts.service_type.refresh', { defaultValue: '刷新TTS服务类型设置' })}
/>
</FlexContainer>
</Form.Item>
// 如果状态没有正确更新,再次尝试
if (currentType !== value) {
console.log('状态未正确更新,再次尝试')
dispatch(setTtsServiceType(value))
}
}, 100)
}}
options={[
{ label: t('settings.tts.service_type.openai'), value: 'openai' },
{ label: t('settings.tts.service_type.edge'), value: 'edge' }
]}
disabled={!ttsEnabled}
style={{ flex: 1 }}
/>
<Button
icon={<ReloadOutlined />}
onClick={() => {
// 强制刷新当前服务类型设置
const currentType = store.getState().settings.ttsServiceType
console.log('强制刷新TTS服务类型:', currentType)
dispatch(setTtsServiceType(currentType))
window.message.success({
content: t('settings.tts.service_type.refreshed', { defaultValue: '已刷新TTS服务类型设置' }),
key: 'tts-refresh'
})
}}
disabled={!ttsEnabled}
title={t('settings.tts.service_type.refresh', { defaultValue: '刷新TTS服务类型设置' })}
/>
</FlexContainer>
</Form.Item>
{/* OpenAI TTS设置 */}
{ttsServiceType === 'openai' && (
<>
<Form.Item label={t('settings.tts.api_key')} style={{ marginBottom: 16 }}>
<Input.Password
value={ttsApiKey}
onChange={(e) => dispatch(setTtsApiKey(e.target.value))}
placeholder={t('settings.tts.api_key.placeholder')}
disabled={!ttsEnabled}
/>
</Form.Item>
<Form.Item label={t('settings.tts.api_url')} style={{ marginBottom: 16 }}>
<Input
value={ttsApiUrl}
onChange={(e) => dispatch(setTtsApiUrl(e.target.value))}
placeholder={t('settings.tts.api_url.placeholder')}
disabled={!ttsEnabled}
/>
</Form.Item>
</>
)}
{/* OpenAI TTS设置 */}
{ttsServiceType === 'openai' && (
<>
<Form.Item label={t('settings.tts.api_key')} style={{ marginBottom: 16 }}>
<Input.Password
value={ttsApiKey}
onChange={(e) => dispatch(setTtsApiKey(e.target.value))}
placeholder={t('settings.tts.api_key.placeholder')}
disabled={!ttsEnabled}
/>
</Form.Item>
<Form.Item label={t('settings.tts.api_url')} style={{ marginBottom: 16 }}>
<Input
value={ttsApiUrl}
onChange={(e) => dispatch(setTtsApiUrl(e.target.value))}
placeholder={t('settings.tts.api_url.placeholder')}
disabled={!ttsEnabled}
/>
</Form.Item>
</>
)}
{/* Edge TTS设置 */}
{ttsServiceType === 'edge' && (
<Form.Item label={t('settings.tts.edge_voice')} style={{ marginBottom: 16 }}>
<VoiceSelectContainer>
<Select
value={ttsEdgeVoice}
onChange={(value) => dispatch(setTtsEdgeVoice(value))}
options={
availableVoices.length > 0
? availableVoices
: [{ label: t('settings.tts.edge_voice.loading'), value: '' }]
}
disabled={!ttsEnabled}
style={{ flex: 1 }}
showSearch
optionFilterProp="label"
placeholder={
availableVoices.length === 0
? t('settings.tts.edge_voice.loading')
: t('settings.tts.voice.placeholder')
}
notFoundContent={
availableVoices.length === 0
? t('settings.tts.edge_voice.loading')
: t('settings.tts.edge_voice.not_found')
}
/>
<Button
icon={<ReloadOutlined />}
onClick={refreshVoices}
disabled={!ttsEnabled}
title={t('settings.tts.edge_voice.refresh')}
/>
</VoiceSelectContainer>
{availableVoices.length === 0 && <LoadingText>{t('settings.tts.edge_voice.loading')}</LoadingText>}
</Form.Item>
)}
{/* Edge TTS设置 */}
{ttsServiceType === 'edge' && (
<Form.Item label={t('settings.tts.edge_voice')} style={{ marginBottom: 16 }}>
<VoiceSelectContainer>
<Select
value={ttsEdgeVoice}
onChange={(value) => dispatch(setTtsEdgeVoice(value))}
options={
availableVoices.length > 0
? availableVoices
: [{ label: t('settings.tts.edge_voice.loading'), value: '' }]
}
disabled={!ttsEnabled}
style={{ flex: 1 }}
showSearch
optionFilterProp="label"
placeholder={
availableVoices.length === 0
? t('settings.tts.edge_voice.loading')
: t('settings.tts.voice.placeholder')
}
notFoundContent={
availableVoices.length === 0
? t('settings.tts.edge_voice.loading')
: t('settings.tts.edge_voice.not_found')
}
/>
<Button
icon={<ReloadOutlined />}
onClick={refreshVoices}
disabled={!ttsEnabled}
title={t('settings.tts.edge_voice.refresh')}
/>
</VoiceSelectContainer>
{availableVoices.length === 0 && <LoadingText>{t('settings.tts.edge_voice.loading')}</LoadingText>}
</Form.Item>
)}
{/* OpenAI TTS的音色和模型设置 */}
{ttsServiceType === 'openai' && (
<>
{/* 音色选择 */}
<Form.Item label={t('settings.tts.voice')} style={{ marginBottom: 8 }}>
<Select
value={ttsVoice}
onChange={(value) => dispatch(setTtsVoice(value))}
options={ttsCustomVoices.map((voice: any) => {
// 确保voice是字符串
const voiceStr = typeof voice === 'string' ? voice : String(voice)
return { label: voiceStr, value: voiceStr }
})}
disabled={!ttsEnabled}
style={{ width: '100%' }}
placeholder={t('settings.tts.voice.placeholder')}
showSearch
optionFilterProp="label"
allowClear
/>
</Form.Item>
{/* OpenAI TTS的音色和模型设置 */}
{ttsServiceType === 'openai' && (
<>
{/* 音色选择 */}
<Form.Item label={t('settings.tts.voice')} style={{ marginBottom: 8 }}>
<Select
value={ttsVoice}
onChange={(value) => dispatch(setTtsVoice(value))}
options={ttsCustomVoices.map((voice: any) => {
// 确保voice是字符串
const voiceStr = typeof voice === 'string' ? voice : String(voice)
return { label: voiceStr, value: voiceStr }
})}
disabled={!ttsEnabled}
style={{ width: '100%' }}
placeholder={t('settings.tts.voice.placeholder')}
showSearch
optionFilterProp="label"
allowClear
/>
</Form.Item>
{/* 自定义音色列表 */}
<TagsContainer>
{ttsCustomVoices && ttsCustomVoices.length > 0 ? (
ttsCustomVoices.map((voice: any, index: number) => {
// 确保voice是字符串
const voiceStr = typeof voice === 'string' ? voice : String(voice)
return (
<Tag
key={`${voiceStr}-${index}`}
closable
onClose={() => handleRemoveVoice(voiceStr)}
style={{ padding: '4px 8px' }}>
{voiceStr}
</Tag>
)
})
) : (
<EmptyText>{t('settings.tts.voice_empty')}</EmptyText>
)}
</TagsContainer>
{/* 自定义音色列表 */}
<TagsContainer>
{ttsCustomVoices && ttsCustomVoices.length > 0 ? (
ttsCustomVoices.map((voice: any, index: number) => {
// 确保voice是字符串
const voiceStr = typeof voice === 'string' ? voice : String(voice)
return (
<Tag
key={`${voiceStr}-${index}`}
closable
onClose={() => handleRemoveVoice(voiceStr)}
style={{ padding: '4px 8px' }}>
{voiceStr}
</Tag>
)
})
) : (
<EmptyText>{t('settings.tts.voice_empty')}</EmptyText>
)}
</TagsContainer>
{/* 添加自定义音色 */}
<CustomVoiceInput>
<InputGroup>
<Input
placeholder={t('settings.tts.voice_input_placeholder')}
value={newVoice}
onChange={(e) => setNewVoice(e.target.value)}
disabled={!ttsEnabled}
style={{ flex: 1 }}
/>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={handleAddVoice}
disabled={!ttsEnabled || !newVoice}>
{t('settings.tts.voice_add')}
</Button>
</InputGroup>
</CustomVoiceInput>
{/* 添加自定义音色 */}
<CustomVoiceInput>
<InputGroup>
<Input
placeholder={t('settings.tts.voice_input_placeholder')}
value={newVoice}
onChange={(e) => setNewVoice(e.target.value)}
disabled={!ttsEnabled}
style={{ flex: 1 }}
/>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={handleAddVoice}
disabled={!ttsEnabled || !newVoice}>
{t('settings.tts.voice_add')}
</Button>
</InputGroup>
</CustomVoiceInput>
{/* 模型选择 */}
<Form.Item label={t('settings.tts.model')} style={{ marginBottom: 8, marginTop: 16 }}>
<Select
value={ttsModel}
onChange={(value) => dispatch(setTtsModel(value))}
options={ttsCustomModels.map((model: any) => {
// 确保model是字符串
const modelStr = typeof model === 'string' ? model : String(model)
return { label: modelStr, value: modelStr }
})}
disabled={!ttsEnabled}
style={{ width: '100%' }}
placeholder={t('settings.tts.model.placeholder')}
showSearch
optionFilterProp="label"
allowClear
/>
</Form.Item>
{/* 模型选择 */}
<Form.Item label={t('settings.tts.model')} style={{ marginBottom: 8, marginTop: 16 }}>
<Select
value={ttsModel}
onChange={(value) => dispatch(setTtsModel(value))}
options={ttsCustomModels.map((model: any) => {
// 确保model是字符串
const modelStr = typeof model === 'string' ? model : String(model)
return { label: modelStr, value: modelStr }
})}
disabled={!ttsEnabled}
style={{ width: '100%' }}
placeholder={t('settings.tts.model.placeholder')}
showSearch
optionFilterProp="label"
allowClear
/>
</Form.Item>
{/* 自定义模型列表 */}
<TagsContainer>
{ttsCustomModels && ttsCustomModels.length > 0 ? (
ttsCustomModels.map((model: any, index: number) => {
// 确保model是字符串
const modelStr = typeof model === 'string' ? model : String(model)
return (
<Tag
key={`${modelStr}-${index}`}
closable
onClose={() => handleRemoveModel(modelStr)}
style={{ padding: '4px 8px' }}>
{modelStr}
</Tag>
)
})
) : (
<EmptyText>{t('settings.tts.model_empty')}</EmptyText>
)}
</TagsContainer>
{/* 自定义模型列表 */}
<TagsContainer>
{ttsCustomModels && ttsCustomModels.length > 0 ? (
ttsCustomModels.map((model: any, index: number) => {
// 确保model是字符串
const modelStr = typeof model === 'string' ? model : String(model)
return (
<Tag
key={`${modelStr}-${index}`}
closable
onClose={() => handleRemoveModel(modelStr)}
style={{ padding: '4px 8px' }}>
{modelStr}
</Tag>
)
})
) : (
<EmptyText>{t('settings.tts.model_empty')}</EmptyText>
)}
</TagsContainer>
{/* 添加自定义模型 */}
<CustomVoiceInput>
<InputGroup>
<Input
placeholder={t('settings.tts.model_input_placeholder')}
value={newModel}
onChange={(e) => setNewModel(e.target.value)}
disabled={!ttsEnabled}
style={{ flex: 1 }}
/>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={handleAddModel}
disabled={!ttsEnabled || !newModel}>
{t('settings.tts.model_add')}
</Button>
</InputGroup>
</CustomVoiceInput>
</>
)}
{/* 添加自定义模型 */}
<CustomVoiceInput>
<InputGroup>
<Input
placeholder={t('settings.tts.model_input_placeholder')}
value={newModel}
onChange={(e) => setNewModel(e.target.value)}
disabled={!ttsEnabled}
style={{ flex: 1 }}
/>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={handleAddModel}
disabled={!ttsEnabled || !newModel}>
{t('settings.tts.model_add')}
</Button>
</InputGroup>
</CustomVoiceInput>
</>
)}
{/* TTS过滤选项 */}
<Form.Item label={t('settings.tts.filter_options')} style={{ marginTop: 24, marginBottom: 8 }}>
<FilterOptionItem>
<Switch
checked={ttsFilterOptions.filterThinkingProcess}
onChange={(checked) => dispatch(setTtsFilterOptions({ filterThinkingProcess: checked }))}
disabled={!ttsEnabled}
/>{' '}
{t('settings.tts.filter.thinking_process')}
</FilterOptionItem>
<FilterOptionItem>
<Switch
checked={ttsFilterOptions.filterMarkdown}
onChange={(checked) => dispatch(setTtsFilterOptions({ filterMarkdown: checked }))}
disabled={!ttsEnabled}
/>{' '}
{t('settings.tts.filter.markdown')}
</FilterOptionItem>
<FilterOptionItem>
<Switch
checked={ttsFilterOptions.filterCodeBlocks}
onChange={(checked) => dispatch(setTtsFilterOptions({ filterCodeBlocks: checked }))}
disabled={!ttsEnabled}
/>{' '}
{t('settings.tts.filter.code_blocks')}
</FilterOptionItem>
<FilterOptionItem>
<Switch
checked={ttsFilterOptions.filterHtmlTags}
onChange={(checked) => dispatch(setTtsFilterOptions({ filterHtmlTags: checked }))}
disabled={!ttsEnabled}
/>{' '}
{t('settings.tts.filter.html_tags')}
</FilterOptionItem>
<FilterOptionItem>
<LengthLabel>{t('settings.tts.max_text_length')}:</LengthLabel>
<Select
value={ttsFilterOptions.maxTextLength}
onChange={(value) => dispatch(setTtsFilterOptions({ maxTextLength: value }))}
disabled={!ttsEnabled}
style={{ width: 120 }}
options={[
{ label: '1000', value: 1000 },
{ label: '2000', value: 2000 },
{ label: '4000', value: 4000 },
{ label: '8000', value: 8000 },
{ label: '16000', value: 16000 }
]}
/>
</FilterOptionItem>
</Form.Item>
{/* TTS过滤选项 */}
<Form.Item label={t('settings.tts.filter_options')} style={{ marginTop: 24, marginBottom: 8 }}>
<FilterOptionItem>
<Switch
checked={ttsFilterOptions.filterThinkingProcess}
onChange={(checked) => dispatch(setTtsFilterOptions({ filterThinkingProcess: checked }))}
disabled={!ttsEnabled}
/>{' '}
{t('settings.tts.filter.thinking_process')}
</FilterOptionItem>
<FilterOptionItem>
<Switch
checked={ttsFilterOptions.filterMarkdown}
onChange={(checked) => dispatch(setTtsFilterOptions({ filterMarkdown: checked }))}
disabled={!ttsEnabled}
/>{' '}
{t('settings.tts.filter.markdown')}
</FilterOptionItem>
<FilterOptionItem>
<Switch
checked={ttsFilterOptions.filterCodeBlocks}
onChange={(checked) => dispatch(setTtsFilterOptions({ filterCodeBlocks: checked }))}
disabled={!ttsEnabled}
/>{' '}
{t('settings.tts.filter.code_blocks')}
</FilterOptionItem>
<FilterOptionItem>
<Switch
checked={ttsFilterOptions.filterHtmlTags}
onChange={(checked) => dispatch(setTtsFilterOptions({ filterHtmlTags: checked }))}
disabled={!ttsEnabled}
/>{' '}
{t('settings.tts.filter.html_tags')}
</FilterOptionItem>
<FilterOptionItem>
<LengthLabel>{t('settings.tts.max_text_length')}:</LengthLabel>
<Select
value={ttsFilterOptions.maxTextLength}
onChange={(value) => dispatch(setTtsFilterOptions({ maxTextLength: value }))}
disabled={!ttsEnabled}
style={{ width: 120 }}
options={[
{ label: '1000', value: 1000 },
{ label: '2000', value: 2000 },
{ label: '4000', value: 4000 },
{ label: '8000', value: 8000 },
{ label: '16000', value: 16000 }
]}
/>
</FilterOptionItem>
</Form.Item>
<Form.Item style={{ marginTop: 16 }}>
<Button
type="primary"
onClick={testTTS}
disabled={
!ttsEnabled ||
(ttsServiceType === 'openai' && (!ttsApiKey || !ttsVoice || !ttsModel)) ||
(ttsServiceType === 'edge' && !ttsEdgeVoice)
}>
{t('settings.tts.test')}
</Button>
</Form.Item>
</Form>
</SettingGroup>
<Form.Item style={{ marginTop: 16 }}>
<Button
type="primary"
onClick={testTTS}
disabled={
!ttsEnabled ||
(ttsServiceType === 'openai' && (!ttsApiKey || !ttsVoice || !ttsModel)) ||
(ttsServiceType === 'edge' && !ttsEdgeVoice)
}>
{t('settings.tts.test')}
</Button>
</Form.Item>
</Form>
</SettingGroup>
</div>
)
},
{
key: 'asr',
label: (
<span>
<AudioOutlined /> {t('settings.asr.tab_title')}
</span>
),
children: <ASRSettings />
}
]}
/>
<SettingHelpText style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: 5 }}>
<span>{t('settings.tts.help')}</span>
<a href="https://platform.openai.com/docs/guides/text-to-speech" target="_blank" rel="noopener noreferrer">
{t('settings.tts.learn_more')}
<span>{t('settings.voice.help')}</span>
<a href="https://platform.openai.com/docs/guides/speech-to-text" target="_blank" rel="noopener noreferrer">
{t('settings.voice.learn_more')}
</a>
</SettingHelpText>
</SettingContainer>

View File

@ -0,0 +1,129 @@
import i18n from '@renderer/i18n'
// 使用window.electron而不是直接导入electron模块
// 这样可以避免__dirname不可用的问题
class ASRServerService {
private serverProcess: any = null
private isServerRunning = false
/**
* ASR服务器
* @returns Promise<boolean>
*/
startServer = async (): Promise<boolean> => {
if (this.isServerRunning) {
console.log('[ASRServerService] 服务器已经在运行中')
window.message.info({ content: i18n.t('settings.asr.server.already_running'), key: 'asr-server' })
return true
}
try {
console.log('[ASRServerService] 正在启动ASR服务器...')
window.message.loading({ content: i18n.t('settings.asr.server.starting'), key: 'asr-server' })
// 使用IPC调用主进程启动服务器
const result = await window.electron.ipcRenderer.invoke('start-asr-server')
if (result.success) {
this.isServerRunning = true
this.serverProcess = result.pid
console.log('[ASRServerService] ASR服务器启动成功PID:', result.pid)
window.message.success({ content: i18n.t('settings.asr.server.started'), key: 'asr-server' })
return true
} else {
console.error('[ASRServerService] ASR服务器启动失败:', result.error)
window.message.error({
content: i18n.t('settings.asr.server.start_failed') + ': ' + result.error,
key: 'asr-server'
})
return false
}
} catch (error) {
console.error('[ASRServerService] 启动ASR服务器时出错:', error)
window.message.error({
content: i18n.t('settings.asr.server.start_failed') + ': ' + (error as Error).message,
key: 'asr-server'
})
return false
}
}
/**
* ASR服务器
* @returns Promise<boolean>
*/
stopServer = async (): Promise<boolean> => {
if (!this.isServerRunning || !this.serverProcess) {
console.log('[ASRServerService] 服务器未运行')
window.message.info({ content: i18n.t('settings.asr.server.not_running'), key: 'asr-server' })
return true
}
try {
console.log('[ASRServerService] 正在停止ASR服务器...')
window.message.loading({ content: i18n.t('settings.asr.server.stopping'), key: 'asr-server' })
// 使用IPC调用主进程停止服务器
const result = await window.electron.ipcRenderer.invoke('stop-asr-server', this.serverProcess)
if (result.success) {
this.isServerRunning = false
this.serverProcess = null
console.log('[ASRServerService] ASR服务器已停止')
window.message.success({ content: i18n.t('settings.asr.server.stopped'), key: 'asr-server' })
return true
} else {
console.error('[ASRServerService] ASR服务器停止失败:', result.error)
window.message.error({
content: i18n.t('settings.asr.server.stop_failed') + ': ' + result.error,
key: 'asr-server'
})
return false
}
} catch (error) {
console.error('[ASRServerService] 停止ASR服务器时出错:', error)
window.message.error({
content: i18n.t('settings.asr.server.stop_failed') + ': ' + (error as Error).message,
key: 'asr-server'
})
return false
}
}
/**
* ASR服务器是否正在运行
* @returns boolean
*/
isRunning = (): boolean => {
return this.isServerRunning
}
/**
* ASR服务器网页URL
* @returns string URL
*/
getServerUrl = (): string => {
return 'http://localhost:8080'
}
/**
* ASR服务器文件路径
* @returns string
*/
getServerFilePath = (): string => {
// 使用相对路径因为window.electron.app.getAppPath()不可用
return process.env.NODE_ENV === 'development'
? 'src/renderer/src/assets/asr-server/server.js'
: 'public/asr-server/server.js'
}
/**
* ASR服务器网页
*/
openServerPage = (): void => {
window.open(this.getServerUrl(), '_blank')
}
}
export default new ASRServerService()

View File

@ -0,0 +1,560 @@
import i18n from '@renderer/i18n'
import store from '@renderer/store'
/**
* ASR服务
*/
class ASRService {
private mediaRecorder: MediaRecorder | null = null
private audioChunks: Blob[] = []
private isRecording = false
private stream: MediaStream | null = null
// WebSocket相关
private ws: WebSocket | null = null
private wsConnected = false
private browserReady = false
private reconnectAttempt = 0
private maxReconnectAttempts = 5
private reconnectTimeout: NodeJS.Timeout | null = null
/**
*
* @returns Promise<void>
*/
/**
* WebSocket服务器
* @returns Promise<boolean>
*/
connectToWebSocketServer = async (): Promise<boolean> => {
return new Promise((resolve) => {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
console.log('[ASRService] WebSocket已连接')
resolve(true)
return
}
if (this.ws && this.ws.readyState === WebSocket.CONNECTING) {
console.log('[ASRService] WebSocket正在连接中')
// 等待连接完成
this.ws.onopen = () => {
console.log('[ASRService] WebSocket连接成功')
this.wsConnected = true
this.reconnectAttempt = 0
this.ws?.send(JSON.stringify({ type: 'identify', role: 'electron' }))
resolve(true)
}
this.ws.onerror = () => {
console.error('[ASRService] WebSocket连接失败')
this.wsConnected = false
resolve(false)
}
return
}
// 关闭之前的连接
if (this.ws) {
try {
this.ws.close()
} catch (e) {
console.error('[ASRService] 关闭WebSocket连接失败:', e)
}
}
// 创建新连接
try {
console.log('[ASRService] 正在连接WebSocket服务器...')
window.message.loading({ content: '正在连接语音识别服务...', key: 'ws-connect' })
this.ws = new WebSocket('ws://localhost:8080')
this.wsConnected = false
this.browserReady = false
this.ws.onopen = () => {
console.log('[ASRService] WebSocket连接成功')
window.message.success({ content: '语音识别服务连接成功', key: 'ws-connect' })
this.wsConnected = true
this.reconnectAttempt = 0
this.ws?.send(JSON.stringify({ type: 'identify', role: 'electron' }))
resolve(true)
}
this.ws.onclose = () => {
console.log('[ASRService] WebSocket连接关闭')
this.wsConnected = false
this.browserReady = false
this.attemptReconnect()
}
this.ws.onerror = (error) => {
console.error('[ASRService] WebSocket连接错误:', error)
this.wsConnected = false
window.message.error({ content: '语音识别服务连接失败', key: 'ws-connect' })
resolve(false)
}
this.ws.onmessage = this.handleWebSocketMessage
} catch (error) {
console.error('[ASRService] 创建WebSocket连接失败:', error)
window.message.error({ content: '语音识别服务连接失败', key: 'ws-connect' })
resolve(false)
}
})
}
/**
* WebSocket消息
*/
private handleWebSocketMessage = (event: MessageEvent) => {
try {
const data = JSON.parse(event.data)
console.log('[ASRService] 收到WebSocket消息:', data)
if (data.type === 'status') {
if (data.message === 'browser_ready' || data.message === 'Browser connected') {
console.log('[ASRService] 浏览器已准备好')
this.browserReady = true
window.message.success({ content: '语音识别浏览器已准备好', key: 'browser-status' })
} else if (data.message === 'Browser disconnected' || data.message === 'Browser connection error') {
console.log('[ASRService] 浏览器断开连接')
this.browserReady = false
window.message.error({ content: '语音识别浏览器断开连接', key: 'browser-status' })
}
} else if (data.type === 'status' && data.message === 'stopped') {
// 语音识别已停止
console.log('[ASRService] 语音识别已停止')
this.isRecording = false
// 如果没有收到最终结果,显示处理完成消息
window.message.success({ content: i18n.t('settings.asr.completed'), key: 'asr-processing' })
// 如果有回调函数,调用一次空字符串,触发按钮状态重置
if (this.resultCallback && typeof this.resultCallback === 'function') {
// 使用空字符串调用回调,不会影响输入框,但可以触发按钮状态重置
this.resultCallback('')
}
} else if (data.type === 'result' && data.data) {
// 处理识别结果
console.log('[ASRService] 收到识别结果:', data.data)
if (this.resultCallback && typeof this.resultCallback === 'function') {
// 只在收到最终结果时才调用回调
if (data.data.isFinal && data.data.text && data.data.text.trim()) {
console.log('[ASRService] 收到最终结果,调用回调函数,文本:', data.data.text)
this.resultCallback(data.data.text)
window.message.success({ content: i18n.t('settings.asr.success'), key: 'asr-processing' })
} else if (!data.data.isFinal) {
// 非最终结果,只输出日志,不调用回调
console.log('[ASRService] 收到中间结果,文本:', data.data.text)
} else {
console.log('[ASRService] 识别结果为空,不调用回调')
}
} else {
console.warn('[ASRService] 没有设置结果回调函数')
}
} else if (data.type === 'error') {
console.error('[ASRService] 收到错误消息:', data.message || data.data)
window.message.error({ content: `语音识别错误: ${data.message || data.data?.error || '未知错误'}`, key: 'asr-error' })
}
} catch (error) {
console.error('[ASRService] 解析WebSocket消息失败:', error, event.data)
}
}
/**
* WebSocket服务器
*/
private attemptReconnect = () => {
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout)
this.reconnectTimeout = null
}
if (this.reconnectAttempt >= this.maxReconnectAttempts) {
console.log('[ASRService] 达到最大重连次数,停止重连')
return
}
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempt), 30000)
console.log(`[ASRService] 将在 ${delay}ms 后尝试重连 (尝试 ${this.reconnectAttempt + 1}/${this.maxReconnectAttempts})`)
this.reconnectTimeout = setTimeout(() => {
this.reconnectAttempt++
this.connectToWebSocketServer().catch(console.error)
}, delay)
}
// 存储结果回调函数
resultCallback: ((text: string) => void) | null = null
startRecording = async (onTranscribed?: (text: string) => void): Promise<void> => {
try {
const { asrEnabled, asrServiceType } = store.getState().settings
if (!asrEnabled) {
window.message.error({ content: i18n.t('settings.asr.error.not_enabled'), key: 'asr-error' })
return
}
// 检查是否已经在录音
if (this.isRecording) {
console.log('已经在录音中,忽略此次请求')
return
}
// 如果是使用本地服务器
if (asrServiceType === 'local') {
// 连接WebSocket服务器
const connected = await this.connectToWebSocketServer()
if (!connected) {
throw new Error('无法连接到语音识别服务')
}
// 检查浏览器是否准备好
if (!this.browserReady) {
// 尝试等待浏览器准备好
let waitAttempts = 0
const maxWaitAttempts = 5
while (!this.browserReady && waitAttempts < maxWaitAttempts) {
window.message.loading({
content: `等待浏览器准备就绪 (${waitAttempts + 1}/${maxWaitAttempts})...`,
key: 'browser-status'
})
// 等待一秒
await new Promise(resolve => setTimeout(resolve, 1000))
waitAttempts++
}
if (!this.browserReady) {
window.message.warning({
content: '语音识别浏览器尚未准备好,请确保已打开浏览器页面',
key: 'browser-status'
})
throw new Error('浏览器尚未准备好')
}
}
// 保存回调函数(如果提供了)
if (onTranscribed && typeof onTranscribed === 'function') {
this.resultCallback = onTranscribed
}
// 发送开始命令
if (this.ws && this.wsConnected) {
this.ws.send(JSON.stringify({ type: 'start' }))
this.isRecording = true
console.log('开始语音识别')
window.message.info({ content: i18n.t('settings.asr.recording'), key: 'asr-recording' })
} else {
throw new Error('WebSocket连接未就绪')
}
return
}
// 以下是原有的录音逻辑OpenAI或浏览器API
// 请求麦克风权限
this.stream = await navigator.mediaDevices.getUserMedia({ audio: true })
// 创建MediaRecorder实例
this.mediaRecorder = new MediaRecorder(this.stream)
// 清空之前的录音数据
this.audioChunks = []
// 设置数据可用时的回调
this.mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
this.audioChunks.push(event.data)
}
}
// 开始录音
this.mediaRecorder.start()
this.isRecording = true
console.log('开始录音')
window.message.info({ content: i18n.t('settings.asr.recording'), key: 'asr-recording' })
} catch (error) {
console.error('开始录音失败:', error)
window.message.error({
content: i18n.t('settings.asr.error.start_failed') + ': ' + (error as Error).message,
key: 'asr-error'
})
this.isRecording = false
}
}
/**
*
* @param onTranscribed
* @returns Promise<void>
*/
stopRecording = async (onTranscribed: (text: string) => void): Promise<void> => {
const { asrServiceType } = store.getState().settings
// 如果是使用本地服务器
if (asrServiceType === 'local') {
if (!this.isRecording) {
console.log('没有正在进行的语音识别')
return
}
try {
// 保存回调函数
this.resultCallback = onTranscribed
// 发送停止命令
if (this.ws && this.wsConnected) {
this.ws.send(JSON.stringify({ type: 'stop' }))
console.log('停止语音识别')
window.message.loading({ content: i18n.t('settings.asr.processing'), key: 'asr-processing' })
// 立即调用回调函数,使按钮状态立即更新
if (onTranscribed) {
// 使用空字符串调用回调,不会影响输入框,但可以触发按钮状态重置
setTimeout(() => onTranscribed(''), 100)
}
} else {
throw new Error('WebSocket连接未就绪')
}
// 重置录音状态
this.isRecording = false
} catch (error) {
console.error('停止语音识别失败:', error)
window.message.error({
content: i18n.t('settings.asr.error.transcribe_failed') + ': ' + (error as Error).message,
key: 'asr-processing'
})
this.isRecording = false
}
return
}
// 以下是原有的录音停止逻辑OpenAI或浏览器API
if (!this.isRecording || !this.mediaRecorder) {
console.log('没有正在进行的录音')
return
}
try {
// 创建一个Promise等待录音结束
const recordingEndedPromise = new Promise<Blob>((resolve) => {
if (this.mediaRecorder) {
this.mediaRecorder.onstop = () => {
// 将所有音频块合并为一个Blob
const audioBlob = new Blob(this.audioChunks, { type: 'audio/webm' })
resolve(audioBlob)
}
// 停止录音
this.mediaRecorder.stop()
}
})
// 停止所有轨道
if (this.stream) {
this.stream.getTracks().forEach(track => track.stop())
this.stream = null
}
// 等待录音结束并获取音频Blob
const audioBlob = await recordingEndedPromise
// 重置录音状态
this.isRecording = false
this.mediaRecorder = null
console.log('录音结束,音频大小:', audioBlob.size, 'bytes')
// 显示处理中消息
window.message.loading({ content: i18n.t('settings.asr.processing'), key: 'asr-processing' })
if (asrServiceType === 'openai') {
// 使用OpenAI的Whisper API进行语音识别
await this.transcribeWithOpenAI(audioBlob, onTranscribed)
} else if (asrServiceType === 'browser') {
// 使用浏览器的Web Speech API进行语音识别
await this.transcribeWithBrowser(audioBlob, onTranscribed)
} else {
throw new Error(`不支持的ASR服务类型: ${asrServiceType}`)
}
} catch (error) {
console.error('停止录音或转录失败:', error)
window.message.error({
content: i18n.t('settings.asr.error.transcribe_failed') + ': ' + (error as Error).message,
key: 'asr-processing'
})
// 重置录音状态
this.isRecording = false
this.mediaRecorder = null
if (this.stream) {
this.stream.getTracks().forEach(track => track.stop())
this.stream = null
}
}
}
/**
* 使OpenAI的Whisper API进行语音识别
* @param audioBlob Blob
* @param onTranscribed
* @returns Promise<void>
*/
private transcribeWithOpenAI = async (audioBlob: Blob, onTranscribed: (text: string) => void): Promise<void> => {
try {
const { asrApiKey, asrApiUrl, asrModel } = store.getState().settings
if (!asrApiKey) {
throw new Error(i18n.t('settings.asr.error.no_api_key'))
}
// 创建FormData对象
const formData = new FormData()
formData.append('file', audioBlob, 'recording.webm')
formData.append('model', asrModel || 'whisper-1')
// 调用OpenAI API
const response = await fetch(asrApiUrl, {
method: 'POST',
headers: {
'Authorization': `Bearer ${asrApiKey}`
},
body: formData
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error?.message || 'OpenAI语音识别失败')
}
// 解析响应
const data = await response.json()
const transcribedText = data.text
if (transcribedText) {
console.log('语音识别成功:', transcribedText)
window.message.success({ content: i18n.t('settings.asr.success'), key: 'asr-processing' })
onTranscribed(transcribedText)
} else {
throw new Error('未能识别出文本')
}
} catch (error) {
console.error('OpenAI语音识别失败:', error)
throw error
}
}
/**
* 使Web Speech API进行语音识别
* @param audioBlob Blob
* @param onTranscribed
* @returns Promise<void>
*/
private transcribeWithBrowser = async (_audioBlob: Blob, onTranscribed: (text: string) => void): Promise<void> => {
try {
// 检查浏览器是否支持Web Speech API
if (!('webkitSpeechRecognition' in window) && !('SpeechRecognition' in window)) {
throw new Error(i18n.t('settings.asr.error.browser_not_support'))
}
// 由于Web Speech API不支持直接处理录制的音频这里我们只是模拟一个成功的回调
// 实际上使用Web Speech API时应该直接使用SpeechRecognition对象进行实时识别
// 这里简化处理,实际项目中可能需要更复杂的实现
window.message.success({ content: i18n.t('settings.asr.success'), key: 'asr-processing' })
onTranscribed('浏览器语音识别功能尚未完全实现')
} catch (error) {
console.error('浏览器语音识别失败:', error)
throw error
}
}
/**
*
* @returns boolean
*/
isCurrentlyRecording = (): boolean => {
return this.isRecording
}
/**
*
*/
cancelRecording = (): void => {
const { asrServiceType } = store.getState().settings
// 如果是使用本地服务器
if (asrServiceType === 'local') {
if (this.isRecording) {
// 发送停止命令
if (this.ws && this.wsConnected) {
this.ws.send(JSON.stringify({ type: 'stop' }))
}
// 重置状态
this.isRecording = false
this.resultCallback = null
console.log('语音识别已取消')
window.message.info({ content: i18n.t('settings.asr.canceled'), key: 'asr-recording' })
}
return
}
// 以下是原有的取消录音逻辑OpenAI或浏览器API
if (this.isRecording && this.mediaRecorder) {
// 停止MediaRecorder
this.mediaRecorder.stop()
// 停止所有轨道
if (this.stream) {
this.stream.getTracks().forEach(track => track.stop())
this.stream = null
}
// 重置状态
this.isRecording = false
this.mediaRecorder = null
this.audioChunks = []
console.log('录音已取消')
window.message.info({ content: i18n.t('settings.asr.canceled'), key: 'asr-recording' })
}
}
/**
* WebSocket连接
*/
closeWebSocketConnection = (): void => {
if (this.ws) {
try {
this.ws.close()
} catch (e) {
console.error('[ASRService] 关闭WebSocket连接失败:', e)
}
this.ws = null
}
this.wsConnected = false
this.browserReady = false
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout)
this.reconnectTimeout = null
}
}
/**
*
*/
openBrowserPage = (): void => {
// 使用window.open打开浏览器页面
window.open('http://localhost:8080', '_blank')
}
}
// 创建单例实例
const instance = new ASRService()
export default instance

View File

@ -129,6 +129,12 @@ export interface SettingsState {
filterHtmlTags: boolean // 过滤HTML标签
maxTextLength: number // 最大文本长度
}
// ASR配置语音识别
asrEnabled: boolean
asrServiceType: string // ASR服务类型openai或browser
asrApiKey: string
asrApiUrl: string
asrModel: string
// Quick Panel Triggers
enableQuickPanelTriggers: boolean
// Export Menu Options
@ -248,6 +254,12 @@ export const initialState: SettingsState = {
filterHtmlTags: true, // 默认过滤HTML标签
maxTextLength: 4000 // 默认最大文本长度
},
// ASR配置语音识别
asrEnabled: false,
asrServiceType: 'openai', // 默认使用 OpenAI ASR
asrApiKey: '',
asrApiUrl: 'https://api.openai.com/v1/audio/transcriptions',
asrModel: 'whisper-1',
// Quick Panel Triggers
enableQuickPanelTriggers: false,
// Export Menu Options
@ -628,6 +640,22 @@ const settingsSlice = createSlice({
...action.payload
}
},
// ASR相关的action
setAsrEnabled: (state, action: PayloadAction<boolean>) => {
state.asrEnabled = action.payload
},
setAsrServiceType: (state, action: PayloadAction<string>) => {
state.asrServiceType = action.payload
},
setAsrApiKey: (state, action: PayloadAction<string>) => {
state.asrApiKey = action.payload
},
setAsrApiUrl: (state, action: PayloadAction<string>) => {
state.asrApiUrl = action.payload
},
setAsrModel: (state, action: PayloadAction<string>) => {
state.asrModel = action.payload
},
// Quick Panel Triggers action
setEnableQuickPanelTriggers: (state, action: PayloadAction<boolean>) => {
state.enableQuickPanelTriggers = action.payload
@ -736,7 +764,12 @@ export const {
addTtsCustomModel,
removeTtsCustomVoice,
removeTtsCustomModel,
setTtsFilterOptions
setTtsFilterOptions,
setAsrEnabled,
setAsrServiceType,
setAsrApiKey,
setAsrApiUrl,
setAsrModel
} = settingsSlice.actions
export default settingsSlice.reducer

View File

@ -4,6 +4,21 @@ interface ObsidianAPI {
getFolders: (vaultName: string) => Promise<Array<{ path: string; type: 'folder' | 'markdown'; name: string }>>
}
interface IpcRendererAPI {
invoke: (channel: string, ...args: any[]) => Promise<any>
on: (channel: string, listener: (...args: any[]) => void) => void
once: (channel: string, listener: (...args: any[]) => void) => void
removeListener: (channel: string, listener: (...args: any[]) => void) => void
removeAllListeners: (channel: string) => void
send: (channel: string, ...args: any[]) => void
sendSync: (channel: string, ...args: any[]) => any
}
interface ElectronAPI {
ipcRenderer: IpcRendererAPI
}
interface Window {
obsidian: ObsidianAPI
electron: ElectronAPI
}