Compare commits

..

70 Commits

Author SHA1 Message Date
kangfenmao
f76076a0f9 Merge branch 'main' into 1600822305-patch-2
# Conflicts:
#	package.json
#	yarn.lock
2025-04-19 20:17:09 +08:00
kangfenmao
1ca66855b3 lint: fix 2025-04-14 23:25:41 +08:00
kangfenmao
85927a557b Merge branch 'main' into 1600822305-patch-2
# Conflicts:
#	electron-builder.yml
#	yarn.lock
2025-04-14 23:15:27 +08:00
1600822305
c82568d9f5 Update Inputbar.tsx 2025-04-14 18:41:14 +08:00
1600822305
f1ae202812 Update Inputbar.tsx 2025-04-14 18:38:13 +08:00
1600822305
64545ebf24 Update yarn.lock 2025-04-14 18:23:57 +08:00
1600822305
a24cc97bf4 Update yarn.lock 2025-04-14 18:20:33 +08:00
1600822305
b1c4e831e7 Update yarn.lock 2025-04-14 18:17:55 +08:00
1600822305
30f17d0c93 Merge branch 'main' into 1600822305-patch-2 2025-04-14 18:11:50 +08:00
1600822305
4b5601734a 翻译 2025-04-14 00:16:56 +08:00
kangfenmao
3752516b6d chore: update yarn.lock and enhance localization in Japanese, Russian, and Traditional Chinese
- Removed unused dependencies from yarn.lock.
- Added new localization strings for emoji filtering and TTS progress bar in Japanese, Russian, and Traditional Chinese.
- Improved layout and styling in TTSSettings and VoiceCallSettings components.
2025-04-13 21:50:00 +08:00
kangfenmao
ef8f09ad96 Merge branch 'main' into 1600822305-patch-2
# Conflicts:
#	src/renderer/src/pages/home/Messages/MessageContent.tsx
#	src/renderer/src/pages/settings/SettingsPage.tsx
#	src/renderer/src/store/settings.ts
2025-04-13 21:34:30 +08:00
1600822305
a728e9866a Merge branch 'main' into 1600822305-patch-2 2025-04-12 20:25:19 +08:00
1600822305
c90c8cfabd Delete src/renderer/src/i18n/locales/zh-cn.json.bak 2025-04-12 19:11:32 +08:00
1600822305
7b9448f72e 添加了 TTS 相关服务并更新了设置 2025-04-12 18:53:47 +08:00
1600822305
61570879ef 添加了 TTS 相关服务并更新了设置 2025-04-12 13:46:34 +08:00
1600822305
808e5ef076 添加了 TTS 相关服务并更新了设置 2025-04-12 12:04:27 +08:00
1600822305
42c38b73ce 添加了 TTS 相关服务并更新了设置 2025-04-12 11:57:00 +08:00
1600822305
e0531ca5b6 添加了 TTS 相关服务并更新了设置 2025-04-11 21:49:47 +08:00
1600822305
d458395076 添加了 TTS 相关服务并更新了设置 2025-04-11 21:03:18 +08:00
1600822305
1bb423c82b 添加了 TTS 相关服务并更新了设置 2025-04-11 20:05:32 +08:00
1600822305
50545ad719 添加了 TTS 相关服务并更新了设置 2025-04-11 19:08:51 +08:00
1600822305
4fca77a047 xuf 2025-04-11 19:03:02 +08:00
1600822305
b4f602e00d 添加了 TTS 相关服务并更新了设置 2025-04-11 17:06:39 +08:00
1600822305
fa4dfecfe1 Merge remote-tracking branch 'origin/main' into 1600822305-patch-2 2025-04-11 17:00:07 +08:00
1600822305
df7bf152bd 添加了 TTS 相关服务并更新了设置 2025-04-11 16:56:20 +08:00
1600822305
3069e35688 TTS语音通话功能 2025-04-11 16:02:17 +08:00
1600822305
a4eeea6732 修复部分问题 2025-04-11 04:00:42 +08:00
1600822305
644995dd76 修复部分问题 2025-04-11 03:53:14 +08:00
1600822305
1f967765e4 修复部分问题 2025-04-11 03:50:12 +08:00
1600822305
ff95670f25 修复zhcn 2025-04-11 03:46:20 +08:00
1600822305
a325ec091d 修复部分问题 2025-04-11 03:42:16 +08:00
1600822305
a86b4ba404 添加了 语音通话功能 相关服务并更新了设置 2025-04-11 03:37:16 +08:00
1600822305
f6cc733421 123 2025-04-11 00:53:50 +08:00
1600822305
14fe1036c9 Merge branch '1600822305-patch-2' of https://github.com/CherryHQ/cherry-studio into 1600822305-patch-2 2025-04-11 00:44:15 +08:00
1600822305
fe69d5c287 添加了 TTS 相关服务并更新了设置 2025-04-11 00:43:13 +08:00
1600822305
21e195c51a Update ASRServerService.ts 2025-04-10 17:54:35 +08:00
1600822305
4ea385f481 Update ASRServerService.ts 2025-04-10 17:48:08 +08:00
1600822305
1b06fa11b0 Update ASRService.ts 2025-04-10 17:47:23 +08:00
1600822305
688d3f3fb5 修复 2025-04-10 17:44:39 +08:00
kangfenmao
56d9f6a8a0 refactor(ipc): streamline IPC handler definitions and improve import organization
- Simplified the registration of IPC handlers for the search window by removing unnecessary async/await syntax.
- Improved import organization by removing duplicate import statements for ASRServerService.
2025-04-10 17:38:57 +08:00
1600822305
9267583c58 1 2025-04-10 16:25:48 +08:00
1600822305
44e4936baf 666 2025-04-10 16:20:36 +08:00
1600822305
eb75884b57 冲突6666 2025-04-10 16:17:46 +08:00
1600822305
7b76fb611b ipc 2025-04-10 16:01:28 +08:00
1600822305
e7ae2bbe64 冲突ipc 2025-04-10 15:58:24 +08:00
kangfenmao
dba84bb04e Merge branch 'main' into 1600822305-patch-2
# Conflicts:
#	src/main/ipc.ts
2025-04-10 13:52:15 +08:00
kangfenmao
09a6633370 refactor: Clean up code formatting and improve readability across multiple files
- Standardized code formatting by removing unnecessary line breaks and ensuring consistent use of semicolons.
- Enhanced readability in various components, including ASRButton, TTSButton, and TTSService, by restructuring code blocks and improving indentation.
- Updated comments for clarity and consistency in ASRService and TTSService.
- Adjusted import statements for better organization in several files, including TTSStopButton and ASRSettings.
- Improved the handling of promises and asynchronous functions for better code flow.
2025-04-10 13:48:29 +08:00
1600822305
5b819221b3 冲突 2025-04-10 12:49:57 +08:00
1600822305
4e5e7f6248 冲突 2025-04-10 12:49:14 +08:00
1600822305
fc77db3b91 ASR-TTS 2025-04-10 12:30:22 +08:00
1600822305
d6302bbc25 翻译 2025-04-09 20:56:27 +08:00
1600822305
a95a2e01e0 去除边框 2025-04-09 20:17:46 +08:00
1600822305
7bd8d1b1a4 Add files via upload
CSP策略
2025-04-09 18:50:43 +08:00
kangfenmao
c33d14feb5 refactor: Update TTS settings and improve localization
- Changed the title in the Chinese localization from "语音合成设置" to "语音设置" for clarity.
- Adjusted the layout of the TTSSettings component, including margin and flex properties for better alignment.
- Enhanced the help text section to improve readability and structure.
- Updated comments in settings.ts for better understanding of default values.
2025-04-09 18:14:28 +08:00
kangfenmao
7337c44053 refactor: Improve TTSSettings component structure and code readability
- Organized imports for better clarity.
- Enhanced the formatting of the TTSSettings component for improved readability.
- Updated various function calls and state management to ensure consistency.
- Refactored the handling of voice and model additions/removals for better maintainability.
- Cleaned up unnecessary comments and improved the overall structure of the code.
2025-04-09 18:04:55 +08:00
kangfenmao
e5dbf47b9b Merge branch 'main' into 1600822305-patch-2
# Conflicts:
#	src/renderer/src/store/settings.ts
2025-04-09 18:01:54 +08:00
1600822305
8444a45f78 Update settings.ts 2025-04-09 17:51:48 +08:00
1600822305
e3d2e6189f Update MessageMenubar.tsx 2025-04-09 17:41:36 +08:00
1600822305
9bb6a7db39 Update en-us.json 2025-04-09 05:08:59 +08:00
1600822305
8feb9d738a Update zh-cn.json
ttspaly
2025-04-09 05:08:08 +08:00
1600822305
f4875ab47b Add files via upload 2025-04-08 19:02:15 +08:00
1600822305
1cea5b9b2e Add files via upload 2025-04-08 18:06:40 +08:00
1600822305
736132e501 Add files via upload 2025-04-08 17:15:52 +08:00
1600822305
80a3fcadbb Add files via upload 2025-04-08 17:08:10 +08:00
1600822305
b23badd464 Update en-us.json 2025-04-08 17:00:29 +08:00
1600822305
1e8565896e Update zh-cn.json 2025-04-08 16:59:20 +08:00
1600822305
45f5979776 Update en-us.json 2025-04-08 16:56:39 +08:00
1600822305
65d63d8f71 Update zh-cn.json 2025-04-08 16:56:06 +08:00
1600822305
b48af7e27b Add files via upload
TTS
2025-04-08 15:43:12 +08:00
191 changed files with 19094 additions and 7277 deletions

View File

@@ -6,7 +6,7 @@ on:
tag:
description: 'Release tag (e.g. v1.0.0)'
required: true
default: 'v1.0.0'
default: 'v0.9.18'
push:
tags:
- v*.*.*
@@ -42,11 +42,6 @@ jobs:
with:
node-version: 20
- name: macos-latest dependencies fix
if: matrix.os == 'macos-latest'
run: |
brew install python-setuptools
- name: Install corepack
run: corepack enable && corepack prepare yarn@4.6.0 --activate
@@ -76,12 +71,10 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
NODE_OPTIONS: --max-old-space-size=8192
- name: Build Mac
if: matrix.os == 'macos-latest'
run: |
sudo -H pip install setuptools
yarn build:npm mac
yarn build:mac
env:
@@ -92,7 +85,6 @@ jobs:
APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_OPTIONS: --max-old-space-size=8192
- name: Build Windows
if: matrix.os == 'windows-latest'
@@ -102,7 +94,6 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
NODE_OPTIONS: --max-old-space-size=8192
- name: Release
uses: ncipollo/release-action@v1

8
.gitignore vendored
View File

@@ -46,11 +46,3 @@ local
.aider*
.cursorrules
.cursor/rules
# test
coverage
.vitest-cache
vitest.config.*.timestamp-*
# Sentry Config File
.env.sentry-build-plugin

View File

@@ -1,92 +0,0 @@
diff --git a/out/electron/ElectronFramework.js b/out/electron/ElectronFramework.js
index 5a4b4546870ee9e770d5a50d79790d39baabd268..3f0ac05dfd6bbaeaf5f834341a823718bd10f55c 100644
--- a/out/electron/ElectronFramework.js
+++ b/out/electron/ElectronFramework.js
@@ -55,26 +55,27 @@ async function removeUnusedLanguagesIfNeeded(options) {
if (!wantedLanguages.length) {
return;
}
- const { dir, langFileExt } = getLocalesConfig(options);
+ const { dirs, langFileExt } = getLocalesConfig(options);
// noinspection SpellCheckingInspection
- await (0, tiny_async_pool_1.default)(builder_util_1.MAX_FILE_REQUESTS, await (0, fs_extra_1.readdir)(dir), async (file) => {
- if (!file.endsWith(langFileExt)) {
+ const deletedFiles = async (dir) => {
+ await (0, tiny_async_pool_1.default)(builder_util_1.MAX_FILE_REQUESTS, await (0, fs_extra_1.readdir)(dir), async (file) => {
+ if (!file.endsWith(langFileExt)) {
+ return;
+ }
+ const language = file.substring(0, file.length - langFileExt.length);
+ if (!wantedLanguages.includes(language)) {
+ return fs.rm(path.join(dir, file), { recursive: true, force: true });
+ }
return;
- }
- const language = file.substring(0, file.length - langFileExt.length);
- if (!wantedLanguages.includes(language)) {
- return fs.rm(path.join(dir, file), { recursive: true, force: true });
- }
- return;
- });
+ });
+ };
+ await Promise.all(dirs.map(deletedFiles));
function getLocalesConfig(options) {
const { appOutDir, packager } = options;
if (packager.platform === index_1.Platform.MAC) {
- return { dir: packager.getResourcesDir(appOutDir), langFileExt: ".lproj" };
- }
- else {
- return { dir: path.join(packager.getResourcesDir(appOutDir), "..", "locales"), langFileExt: ".pak" };
+ return { dirs: [packager.getResourcesDir(appOutDir), packager.getMacOsElectronFrameworkResourcesDir(appOutDir)], langFileExt: ".lproj" };
}
+ return { dirs: [path.join(packager.getResourcesDir(appOutDir), "..", "locales")], langFileExt: ".pak" };
}
}
class ElectronFramework {
diff --git a/out/node-module-collector/index.d.ts b/out/node-module-collector/index.d.ts
index 8e808be0fa0d5971b9f9605c8eb88f71630e34b7..1b97dccd8a150a67c4312d2ba4757960e624045b 100644
--- a/out/node-module-collector/index.d.ts
+++ b/out/node-module-collector/index.d.ts
@@ -2,6 +2,6 @@ import { NpmNodeModulesCollector } from "./npmNodeModulesCollector";
import { PnpmNodeModulesCollector } from "./pnpmNodeModulesCollector";
import { detect, PM, getPackageManagerVersion } from "./packageManager";
import { NodeModuleInfo } from "./types";
-export declare function getCollectorByPackageManager(rootDir: string): Promise<NpmNodeModulesCollector | PnpmNodeModulesCollector>;
+export declare function getCollectorByPackageManager(rootDir: string): Promise<PnpmNodeModulesCollector | NpmNodeModulesCollector>;
export declare function getNodeModules(rootDir: string): Promise<NodeModuleInfo[]>;
export { detect, getPackageManagerVersion, PM };
diff --git a/out/platformPackager.d.ts b/out/platformPackager.d.ts
index 2df1ba2725c54c7b0e8fed67ab52e94f0cdb17bc..c7ff756564cfd216d2c7d8f72f367527010c06f9 100644
--- a/out/platformPackager.d.ts
+++ b/out/platformPackager.d.ts
@@ -67,6 +67,7 @@ export declare abstract class PlatformPackager<DC extends PlatformSpecificBuildO
getElectronSrcDir(dist: string): string;
getElectronDestinationDir(appOutDir: string): string;
getResourcesDir(appOutDir: string): string;
+ getMacOsElectronFrameworkResourcesDir(appOutDir: string): string;
getMacOsResourcesDir(appOutDir: string): string;
private checkFileInPackage;
private sanityCheckPackage;
diff --git a/out/platformPackager.js b/out/platformPackager.js
index 6f799ce0d1cdb5f0b18a9c8187b2db84b3567aa9..879248e6c6786d3473e1a80e3930d3a8d0190aab 100644
--- a/out/platformPackager.js
+++ b/out/platformPackager.js
@@ -465,12 +465,13 @@ class PlatformPackager {
if (this.platform === index_1.Platform.MAC) {
return this.getMacOsResourcesDir(appOutDir);
}
- else if ((0, Framework_1.isElectronBased)(this.info.framework)) {
+ if ((0, Framework_1.isElectronBased)(this.info.framework)) {
return path.join(appOutDir, "resources");
}
- else {
- return appOutDir;
- }
+ return appOutDir;
+ }
+ getMacOsElectronFrameworkResourcesDir(appOutDir) {
+ return path.join(appOutDir, `${this.appInfo.productFilename}.app`, "Contents", "Frameworks", "Electron Framework.framework", "Resources");
}
getMacOsResourcesDir(appOutDir) {
return path.join(appOutDir, `${this.appInfo.productFilename}.app`, "Contents", "Resources");

View File

@@ -1,32 +0,0 @@
diff --git a/dist/index.js b/dist/index.js
index 663919ac5bb4f9147c5c1b09bd2e379586266a4b..88ff8873ac5beb5eb293f7e741a92fb15b00960c 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -20,21 +20,21 @@ function getSystemProxy() {
else if (process.platform === 'darwin') {
const proxySettings = yield mac_system_proxy_1.getMacSystemProxy();
const noProxy = proxySettings.ExceptionsList || [];
- if (proxySettings.HTTPSEnable && proxySettings.HTTPSProxy && proxySettings.HTTPSPort) {
+ if (proxySettings.HTTPEnable && proxySettings.HTTPProxy && proxySettings.HTTPPort) {
return {
- proxyUrl: `https://${proxySettings.HTTPSProxy}:${proxySettings.HTTPSPort}`,
+ proxyUrl: `http://${proxySettings.HTTPProxy}:${proxySettings.HTTPPort}`,
noProxy
};
}
- else if (proxySettings.HTTPEnable && proxySettings.HTTPProxy && proxySettings.HTTPPort) {
+ else if (proxySettings.SOCKSEnable && proxySettings.SOCKSProxy && proxySettings.SOCKSPort) {
return {
- proxyUrl: `http://${proxySettings.HTTPProxy}:${proxySettings.HTTPPort}`,
+ proxyUrl: `socks://${proxySettings.SOCKSProxy}:${proxySettings.SOCKSPort}`,
noProxy
};
}
- else if (proxySettings.SOCKSEnable && proxySettings.SOCKSProxy && proxySettings.SOCKSPort) {
+ else if (proxySettings.HTTPSEnable && proxySettings.HTTPSProxy && proxySettings.HTTPSPort) {
return {
- proxyUrl: `socks://${proxySettings.SOCKSProxy}:${proxySettings.SOCKSPort}`,
+ proxyUrl: `http://${proxySettings.HTTPSProxy}:${proxySettings.HTTPSPort}`,
noProxy
};
}

View File

@@ -23,12 +23,14 @@ https://docs.cherry-ai.com
# 🌠 Screenshot
![](https://github.com/user-attachments/assets/082efa42-c4df-4863-a9cb-80435cecce0f)
![](https://github.com/user-attachments/assets/f8411a65-c51f-47d3-9273-62ae384cc6f1)
![](https://github.com/user-attachments/assets/0d235b3e-65ae-45ab-987f-8dbe003c52be)
![](https://github.com/user-attachments/assets/28585d83-4bf0-4714-b561-8c7bf57cc600)
![](https://github.com/user-attachments/assets/8576863a-f632-4776-bc12-657eeced9da3)
![](https://github.com/user-attachments/assets/790790d7-b462-48dd-bde1-91c1697a4648)
# 🌟 Key Features
![](https://github.com/user-attachments/assets/7b4f2f78-5cbe-4be8-9aec-f98d8405a505)
1. **Diverse LLM Provider Support**:
- ☁️ Major LLM Cloud Services: OpenAI, Gemini, Anthropic, and more

123
asr-server/embedded.js Normal file
View File

@@ -0,0 +1,123 @@
/**
* 内置的ASR服务器模块
* 这个文件可以直接在Electron中运行不需要外部依赖
*/
// 使用Electron内置的Node.js模块
const http = require('http')
const path = require('path')
const fs = require('fs')
// 输出环境信息
console.log('ASR Server (Embedded) starting...')
console.log('Node.js version:', process.version)
console.log('Current directory:', __dirname)
console.log('Current working directory:', process.cwd())
console.log('Command line arguments:', process.argv)
// 创建HTTP服务器
const server = http.createServer((req, res) => {
try {
if (req.url === '/' || req.url === '/index.html') {
// 尝试多个可能的路径
const possiblePaths = [
// 当前目录
path.join(__dirname, 'index.html'),
// 上级目录
path.join(__dirname, '..', 'index.html'),
// 应用根目录
path.join(process.cwd(), 'index.html')
]
console.log('Possible index.html paths:', possiblePaths)
// 查找第一个存在的文件
let indexPath = null
for (const p of possiblePaths) {
try {
if (fs.existsSync(p)) {
indexPath = p
console.log(`Found index.html at: ${p}`)
break
}
} catch (e) {
console.error(`Error checking existence of ${p}:`, e)
}
}
if (indexPath) {
// 读取文件内容并发送
fs.readFile(indexPath, (err, data) => {
if (err) {
console.error('Error reading index.html:', err)
res.writeHead(500)
res.end('Error reading index.html')
return
}
res.writeHead(200, { 'Content-Type': 'text/html' })
res.end(data)
})
} else {
// 如果找不到文件返回一个简单的HTML页面
console.error('Could not find index.html, serving fallback page')
res.writeHead(200, { 'Content-Type': 'text/html' })
res.end(`
<!DOCTYPE html>
<html>
<head>
<title>ASR Server</title>
<style>
body { font-family: sans-serif; padding: 2em; }
h1 { color: #333; }
</style>
</head>
<body>
<h1>ASR Server is running</h1>
<p>This is a fallback page because the index.html file could not be found.</p>
<p>Server is running at: http://localhost:34515</p>
<p>Current directory: ${__dirname}</p>
<p>Working directory: ${process.cwd()}</p>
</body>
</html>
`)
}
} else {
// 处理其他请求
res.writeHead(404)
res.end('Not found')
}
} catch (error) {
console.error('Error handling request:', error)
res.writeHead(500)
res.end('Server error')
}
})
// 添加进程错误处理
process.on('uncaughtException', (error) => {
console.error('[Server] Uncaught exception:', error)
// 不立即退出,给日志输出的时间
setTimeout(() => process.exit(1), 1000)
})
process.on('unhandledRejection', (reason, promise) => {
console.error('[Server] Unhandled rejection at:', promise, 'reason:', reason)
})
// 尝试启动服务器
try {
const port = 34515
server.listen(port, () => {
console.log(`[Server] Server running at http://localhost:${port}`)
})
// 处理服务器错误
server.on('error', (error) => {
console.error(`[Server] Failed to start server:`, error)
process.exit(1) // Exit if server fails to start
})
} catch (error) {
console.error('[Server] Critical error starting server:', error)
process.exit(1)
}

425
asr-server/index.html Normal file
View File

@@ -0,0 +1,425 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cherry Studio ASR</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>浏览器语音识别中继页面</h1>
<p>这个页面需要在浏览器中保持打开,以便应用使用其语音识别功能。</p>
<div id="status">正在连接到服务器...</div>
<div id="result"></div>
<script>
const statusDiv = document.getElementById('status');
const resultDiv = document.getElementById('result');
// 尝试连接到WebSocket服务器
let ws;
let reconnectAttempts = 0;
const maxReconnectAttempts = 5;
const reconnectInterval = 2000; // 2秒
function connectWebSocket() {
try {
ws = new WebSocket('ws://localhost:34515');
ws.onopen = () => {
reconnectAttempts = 0;
updateStatus('已连接到服务器,等待指令...');
ws.send(JSON.stringify({ type: 'identify', role: 'browser' }));
};
ws.onmessage = handleMessage;
ws.onerror = (error) => {
console.error('[Browser Page] WebSocket Error:', error);
updateStatus('WebSocket 连接错误!请检查服务器是否运行。');
};
ws.onclose = () => {
console.log('[Browser Page] WebSocket Connection Closed');
updateStatus('与服务器断开连接。尝试重新连接...');
stopRecognition();
// 尝试重新连接
if (reconnectAttempts < maxReconnectAttempts) {
reconnectAttempts++;
updateStatus(`与服务器断开连接。尝试重新连接 (${reconnectAttempts}/${maxReconnectAttempts})...`);
setTimeout(connectWebSocket, reconnectInterval);
} else {
updateStatus('无法连接到服务器。请刷新页面或重启应用。');
}
};
} catch (error) {
console.error('[Browser Page] Error creating WebSocket:', error);
updateStatus('创建WebSocket连接时出错。请刷新页面或重启应用。');
}
}
// 初始连接
connectWebSocket();
let recognition = null;
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
function updateStatus(message) {
console.log(`[Browser Page Status] ${message}`);
statusDiv.textContent = message;
}
function handleMessage(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 if (data.type === 'reset') {
// 强制重置语音识别
forceResetRecognition();
} else {
console.warn('[Browser Page] Received unknown command type:', data.type);
}
};
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("识别未运行。");
}
}
function forceResetRecognition() {
console.log('[Browser Page] Force resetting recognition...');
updateStatus("强制重置语音识别...");
// 先尝试停止当前的识别
if (recognition) {
try {
recognition.stop();
} catch (e) {
console.error('[Browser Page] Error stopping recognition during reset:', e);
}
}
// 强制设置为null丢弃所有后续结果
recognition = null;
// 通知服务器已重置
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'status', message: 'reset_complete' }));
}
updateStatus("语音识别已重置,等待新指令。");
}
</script>
</body>
</html>

854
asr-server/package-lock.json generated Normal file
View File

@@ -0,0 +1,854 @@
{
"name": "cherry-asr-server",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cherry-asr-server",
"version": "1.0.0",
"dependencies": {
"express": "^4.18.2",
"ws": "^8.13.0"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmmirror.com/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.3",
"resolved": "https://registry.npmmirror.com/body-parser/-/body-parser-1.20.3.tgz",
"integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==",
"license": "MIT",
"dependencies": {
"bytes": "3.1.2",
"content-type": "~1.0.5",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"http-errors": "2.0.0",
"iconv-lite": "0.4.24",
"on-finished": "2.4.1",
"qs": "6.13.0",
"raw-body": "2.5.2",
"type-is": "~1.6.18",
"unpipe": "1.0.0"
},
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmmirror.com/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmmirror.com/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"license": "MIT",
"dependencies": {
"safe-buffer": "5.2.1"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmmirror.com/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.1",
"resolved": "https://registry.npmmirror.com/cookie/-/cookie-0.7.1.tgz",
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmmirror.com/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmmirror.com/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/destroy": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/destroy/-/destroy-1.2.0.tgz",
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
"license": "MIT",
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmmirror.com/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "4.21.2",
"resolved": "https://registry.npmmirror.com/express/-/express-4.21.2.tgz",
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "1.20.3",
"content-disposition": "0.5.4",
"content-type": "~1.0.4",
"cookie": "0.7.1",
"cookie-signature": "1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"finalhandler": "1.3.1",
"fresh": "0.5.2",
"http-errors": "2.0.0",
"merge-descriptors": "1.0.3",
"methods": "~1.1.2",
"on-finished": "2.4.1",
"parseurl": "~1.3.3",
"path-to-regexp": "0.1.12",
"proxy-addr": "~2.0.7",
"qs": "6.13.0",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "0.19.0",
"serve-static": "1.16.2",
"setprototypeof": "1.2.0",
"statuses": "2.0.1",
"type-is": "~1.6.18",
"utils-merge": "1.0.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "1.3.1",
"resolved": "https://registry.npmmirror.com/finalhandler/-/finalhandler-1.3.1.tgz",
"integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"on-finished": "2.4.1",
"parseurl": "~1.3.3",
"statuses": "2.0.1",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmmirror.com/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmmirror.com/fresh/-/fresh-0.5.2.tgz",
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.0.tgz",
"integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
"license": "MIT",
"dependencies": {
"depd": "2.0.0",
"inherits": "2.0.4",
"setprototypeof": "1.2.0",
"statuses": "2.0.1",
"toidentifier": "1.0.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmmirror.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmmirror.com/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/merge-descriptors": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmmirror.com/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmmirror.com/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmmirror.com/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.12",
"resolved": "https://registry.npmmirror.com/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT"
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.13.0",
"resolved": "https://registry.npmmirror.com/qs/-/qs-6.13.0.tgz",
"integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.0.6"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmmirror.com/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "2.5.2",
"resolved": "https://registry.npmmirror.com/raw-body/-/raw-body-2.5.2.tgz",
"integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==",
"license": "MIT",
"dependencies": {
"bytes": "3.1.2",
"http-errors": "2.0.0",
"iconv-lite": "0.4.24",
"unpipe": "1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/send": {
"version": "0.19.0",
"resolved": "https://registry.npmmirror.com/send/-/send-0.19.0.tgz",
"integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"encodeurl": "~1.0.2",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"fresh": "0.5.2",
"http-errors": "2.0.0",
"mime": "1.6.0",
"ms": "2.1.3",
"on-finished": "2.4.1",
"range-parser": "~1.2.1",
"statuses": "2.0.1"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/send/node_modules/encodeurl": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/encodeurl/-/encodeurl-1.0.2.tgz",
"integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/send/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/serve-static": {
"version": "1.16.2",
"resolved": "https://registry.npmmirror.com/serve-static/-/serve-static-1.16.2.tgz",
"integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==",
"license": "MIT",
"dependencies": {
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"parseurl": "~1.3.3",
"send": "0.19.0"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/statuses/-/statuses-2.0.1.tgz",
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmmirror.com/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmmirror.com/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}

10
asr-server/package.json Normal file
View File

@@ -0,0 +1,10 @@
{
"name": "cherry-asr-server",
"version": "1.0.0",
"description": "Cherry Studio ASR Server",
"main": "server.js",
"dependencies": {
"express": "^4.18.2",
"ws": "^8.13.0"
}
}

269
asr-server/server.js Normal file
View File

@@ -0,0 +1,269 @@
// 检查依赖项
try {
console.log('ASR Server starting...')
console.log('Node.js version:', process.version)
console.log('Current directory:', __dirname)
console.log('Current working directory:', process.cwd())
console.log('Command line arguments:', process.argv)
// 检查必要的依赖项
const checkDependency = (name) => {
try {
require(name) // Removed unused variable 'module'
console.log(`Successfully loaded dependency: ${name}`)
return true
} catch (error) {
console.error(`Failed to load dependency: ${name}`, error.message)
return false
}
}
// 检查所有必要的依赖项
const dependencies = ['http', 'ws', 'express', 'path', 'fs']
const missingDeps = dependencies.filter((dep) => !checkDependency(dep))
if (missingDeps.length > 0) {
console.error(`Missing dependencies: ${missingDeps.join(', ')}. Server cannot start.`)
process.exit(1)
}
} catch (error) {
console.error('Error during dependency check:', error)
process.exit(1)
}
// 加载依赖项
const http = require('http')
const WebSocket = require('ws')
const express = require('express')
const path = require('path') // Need path module
// const fs = require('fs') // Commented out unused import 'fs'
const app = express()
const port = 34515 // Define the port
// 获取index.html文件的路径
function getIndexHtmlPath() {
const fs = require('fs')
console.log('Current directory:', __dirname)
console.log('Current working directory:', process.cwd())
// 尝试多个可能的路径
const possiblePaths = [
// 开发环境路径
path.join(__dirname, 'index.html'),
// 当前目录
path.join(process.cwd(), 'index.html'),
// 相对于可执行文件的路径
path.join(path.dirname(process.execPath), 'index.html'),
// 相对于可执行文件的上级目录的路径
path.join(path.dirname(path.dirname(process.execPath)), 'index.html'),
// 相对于可执行文件的resources目录的路径
path.join(path.dirname(process.execPath), 'resources', 'index.html'),
// 相对于可执行文件的resources/asr-server目录的路径
path.join(path.dirname(process.execPath), 'resources', 'asr-server', 'index.html'),
// 相对于可执行文件的asr-server目录的路径
path.join(path.dirname(process.execPath), 'asr-server', 'index.html'),
// 如果是pkg打包环境
process.pkg ? path.join(path.dirname(process.execPath), 'index.html') : null
].filter(Boolean) // 过滤掉null值
console.log('Possible index.html paths:', possiblePaths)
// 检查每个路径,返回第一个存在的文件
for (const p of possiblePaths) {
try {
if (fs.existsSync(p)) {
console.log(`Found index.html at: ${p}`)
return p
}
} catch (e) {
console.error(`Error checking existence of ${p}:`, e)
}
}
// 如果没有找到文件,返回默认路径并记录错误
console.error('Could not find index.html in any of the expected locations')
return path.join(__dirname, 'index.html') // 返回默认路径,即使它可能不存在
}
// 提供网页给浏览器
app.get('/', (req, res) => {
try {
const indexPath = getIndexHtmlPath()
console.log(`Serving index.html from: ${indexPath}`)
// 检查文件是否存在
const fs = require('fs')
if (!fs.existsSync(indexPath)) {
console.error(`Error: index.html not found at ${indexPath}`)
return res.status(404).send(`Error: index.html not found at ${indexPath}. <br>Please check the server logs.`)
}
res.sendFile(indexPath, (err) => {
if (err) {
console.error('Error sending index.html:', err)
res.status(500).send(`Error serving index.html: ${err.message}`)
}
})
} catch (error) {
console.error('Error in route handler:', error)
res.status(500).send(`Server error: ${error.message}`)
}
})
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 === 'reset' && ws === electronConnection) {
if (browserConnection && browserConnection.readyState === WebSocket.OPEN) {
console.log('[Server] Relaying RESET command to browser')
browserConnection.send(JSON.stringify({ type: 'reset' }))
} else {
console.log('[Server] Cannot relay RESET: 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
}
})
})
// 添加进程错误处理
process.on('uncaughtException', (error) => {
console.error('[Server] Uncaught exception:', error)
// 不立即退出,给日志输出的时间
setTimeout(() => process.exit(1), 1000)
})
process.on('unhandledRejection', (reason, promise) => {
console.error('[Server] Unhandled rejection at:', promise, 'reason:', reason)
})
// 尝试启动服务器
try {
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
})
} catch (error) {
console.error('[Server] Critical error starting server:', error)
process.exit(1)
}

114
asr-server/standalone.js Normal file
View File

@@ -0,0 +1,114 @@
/**
* 独立的ASR服务器
* 这个文件是一个简化版的server.js用于在打包后的应用中运行
*/
// 基本依赖
const http = require('http')
const express = require('express')
const path = require('path')
const fs = require('fs')
// 输出环境信息
console.log('ASR Server starting...')
console.log('Node.js version:', process.version)
console.log('Current directory:', __dirname)
console.log('Current working directory:', process.cwd())
console.log('Command line arguments:', process.argv)
// 创建Express应用
const app = express()
const port = 34515
// 提供静态文件
app.use(express.static(__dirname))
// 提供网页给浏览器
app.get('/', (req, res) => {
try {
// 尝试多个可能的路径
const possiblePaths = [
// 当前目录
path.join(__dirname, 'index.html'),
// 上级目录
path.join(__dirname, '..', 'index.html'),
// 应用根目录
path.join(process.cwd(), 'index.html')
]
console.log('Possible index.html paths:', possiblePaths)
// 查找第一个存在的文件
let indexPath = null
for (const p of possiblePaths) {
try {
if (fs.existsSync(p)) {
indexPath = p
console.log(`Found index.html at: ${p}`)
break
}
} catch (e) {
console.error(`Error checking existence of ${p}:`, e)
}
}
if (indexPath) {
res.sendFile(indexPath)
} else {
// 如果找不到文件返回一个简单的HTML页面
console.error('Could not find index.html, serving fallback page')
res.send(`
<!DOCTYPE html>
<html>
<head>
<title>ASR Server</title>
<style>
body { font-family: sans-serif; padding: 2em; }
h1 { color: #333; }
</style>
</head>
<body>
<h1>ASR Server is running</h1>
<p>This is a fallback page because the index.html file could not be found.</p>
<p>Server is running at: http://localhost:${port}</p>
<p>Current directory: ${__dirname}</p>
<p>Working directory: ${process.cwd()}</p>
</body>
</html>
`)
}
} catch (error) {
console.error('Error serving index.html:', error)
res.status(500).send(`Server error: ${error.message}`)
}
})
// 创建HTTP服务器
const server = http.createServer(app)
// 添加进程错误处理
process.on('uncaughtException', (error) => {
console.error('[Server] Uncaught exception:', error)
// 不立即退出,给日志输出的时间
setTimeout(() => process.exit(1), 1000)
})
process.on('unhandledRejection', (reason, promise) => {
console.error('[Server] Unhandled rejection at:', promise, 'reason:', reason)
})
// 尝试启动服务器
try {
server.listen(port, () => {
console.log(`[Server] Server running at http://localhost:${port}`)
})
// 处理服务器错误
server.on('error', (error) => {
console.error(`[Server] Failed to start server:`, error)
process.exit(1) // Exit if server fails to start
})
} catch (error) {
console.error('[Server] Critical error starting server:', error)
process.exit(1)
}

View File

@@ -0,0 +1,5 @@
@echo off
echo Starting ASR Server...
cd /d %~dp0
node standalone.js
pause

View File

@@ -24,12 +24,14 @@ https://docs.cherry-ai.com
# 🌠 スクリーンショット
![](https://github.com/user-attachments/assets/082efa42-c4df-4863-a9cb-80435cecce0f)
![](https://github.com/user-attachments/assets/f8411a65-c51f-47d3-9273-62ae384cc6f1)
![](https://github.com/user-attachments/assets/0d235b3e-65ae-45ab-987f-8dbe003c52be)
![](https://github.com/user-attachments/assets/28585d83-4bf0-4714-b561-8c7bf57cc600)
![](https://github.com/user-attachments/assets/8576863a-f632-4776-bc12-657eeced9da3)
![](https://github.com/user-attachments/assets/790790d7-b462-48dd-bde1-91c1697a4648)
# 🌟 主な機能
![](https://github.com/user-attachments/assets/7b4f2f78-5cbe-4be8-9aec-f98d8405a505)
1. **多様な LLM サービス対応**
- ☁️ 主要な LLM クラウドサービス対応OpenAI、Gemini、Anthropic など

View File

@@ -24,12 +24,14 @@ https://docs.cherry-ai.com
# 🌠 界面
![](https://github.com/user-attachments/assets/082efa42-c4df-4863-a9cb-80435cecce0f)
![](https://github.com/user-attachments/assets/f8411a65-c51f-47d3-9273-62ae384cc6f1)
![](https://github.com/user-attachments/assets/0d235b3e-65ae-45ab-987f-8dbe003c52be)
![](https://github.com/user-attachments/assets/28585d83-4bf0-4714-b561-8c7bf57cc600)
![](https://github.com/user-attachments/assets/8576863a-f632-4776-bc12-657eeced9da3)
![](https://github.com/user-attachments/assets/790790d7-b462-48dd-bde1-91c1697a4648)
# 🌟 主要特性
![](https://github.com/user-attachments/assets/995910f3-177a-4d1e-97ea-04e3b009ba36)
1. **多样化 LLM 服务支持**
- ☁️ 支持主流 LLM 云服务OpenAI、Gemini、Anthropic、硅基流动等

View File

@@ -3,15 +3,14 @@ productName: Cherry Studio
electronLanguages:
- zh-CN
- zh-TW
- en-GB
- en-US
- ja # macOS/linux/win
- ru # macOS/linux/win
- zh_CN # for macOS
- zh_TW # for macOS
- en # for macOS
- ru
directories:
buildResources: build
files:
- out/**/*
- package.json
- '!{.vscode,.yarn,.github}'
- '!electron.vite.config.{js,ts,mjs,cjs}'
- '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
@@ -36,9 +35,18 @@ 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}'
asarUnpack:
asarUnpack: # Removed ASR server rules from 'files' section
- resources/**
- '**/*.{metal,exp,lib}'
extraResources: # Add extraResources to copy the prepared asr-server directory
- from: asr-server # Copy the folder from project root
to: app/asr-server # Copy TO the 'app' subfolder within resources
filter:
- '**/*' # Include everything inside
- from: resources/data # Copy the data folder with agents.json
to: data # Copy TO the 'data' subfolder within resources
filter:
- '**/*' # Include everything inside
win:
executableName: Cherry Studio
artifactName: ${productName}-${version}-${arch}-setup.${ext}
@@ -74,9 +82,6 @@ linux:
- target: AppImage
maintainer: electronjs.org
category: Utility
desktop:
entry:
StartupWMClass: CherryStudio
publish:
provider: generic
url: https://releases.cherry-ai.com
@@ -87,11 +92,6 @@ afterSign: scripts/notarize.js
artifactBuildCompleted: scripts/artifact-build-completed.js
releaseInfo:
releaseNotes: |
修正语言及本地化错误
Windows ARM 更新跳转到官网下载
改进系统代理处理和初始化逻辑
修复 MCP 服务请求头不生效问题
移除搜索增强模式
优化消息渲染速度
修复备份大文件失败问题
修复网络搜索导致卡顿问题
全新图标风格
新的智能体界面
WebDAV 增加文件管理功能

View File

@@ -1,5 +1,4 @@
import { sentryVitePlugin } from '@sentry/vite-plugin'
import react from '@vitejs/plugin-react-swc'
import viteReact from '@vitejs/plugin-react'
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
import { resolve } from 'path'
import { visualizer } from 'rollup-plugin-visualizer'
@@ -7,7 +6,7 @@ import { visualizer } from 'rollup-plugin-visualizer'
const visualizerPlugin = (type: 'renderer' | 'main') => {
return process.env[`VISUALIZER_${type.toUpperCase()}`] ? [visualizer({ open: true })] : []
}
// const viteReact = await import('@vitejs/plugin-react')
export default defineConfig({
main: {
plugins: [
@@ -52,23 +51,20 @@ export default defineConfig({
},
renderer: {
plugins: [
react({
plugins: [
[
'@swc/plugin-styled-components',
{
displayName: true, // 开发环境下启用组件名称
fileName: false, // 不在类名中包含文件名
pure: true, // 优化性能
ssr: false // 不需要服务端渲染
}
viteReact({
babel: {
plugins: [
[
'styled-components',
{
displayName: true, // 开发环境下启用组件名
fileName: false, // 不在类名中包含文件名
pure: true, // 优化性能
ssr: false // 不需要服务端渲染
}
]
]
]
}),
sentryVitePlugin({
authToken: process.env.SENTRY_AUTH_TOKEN,
org: 'cherry-ai',
project: 'cherry-studio'
}
}),
...visualizerPlugin('renderer')
],
@@ -80,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

@@ -1,42 +1,425 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="initial-scale=1, width=device-width" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; connect-src blob: *; script-src 'self' 'unsafe-eval' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: * blob:; frame-src * file:" />
<title>Cherry Studio</title>
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cherry Studio ASR</title>
<style>
html,
body {
margin: 0;
}
body {
font-family: sans-serif;
padding: 1em;
}
#spinner {
position: fixed;
width: 100vw;
height: 100vh;
flex-direction: row;
justify-content: center;
align-items: center;
display: none;
}
#status {
margin-top: 1em;
font-style: italic;
color: #555;
}
#spinner img {
width: 100px;
border-radius: 50px;
}
#result {
margin-top: 0.5em;
border: 1px solid #ccc;
padding: 0.5em;
min-height: 50px;
background: #f9f9f9;
}
</style>
</head>
</head>
<body>
<div id="root"></div>
<div id="spinner">
<img src="/src/assets/images/logo.png" />
</div>
<script type="module" src="/src/init.ts"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
<body>
<h1>浏览器语音识别中继页面</h1>
<p>这个页面需要在浏览器中保持打开,以便应用使用其语音识别功能。</p>
<div id="status">正在连接到服务器...</div>
<div id="result"></div>
<script>
const statusDiv = document.getElementById('status');
const resultDiv = document.getElementById('result');
// 尝试连接到WebSocket服务器
let ws;
let reconnectAttempts = 0;
const maxReconnectAttempts = 5;
const reconnectInterval = 2000; // 2秒
function connectWebSocket() {
try {
ws = new WebSocket('ws://localhost:34515');
ws.onopen = () => {
reconnectAttempts = 0;
updateStatus('已连接到服务器,等待指令...');
ws.send(JSON.stringify({ type: 'identify', role: 'browser' }));
};
ws.onmessage = handleMessage;
ws.onerror = (error) => {
console.error('[Browser Page] WebSocket Error:', error);
updateStatus('WebSocket 连接错误!请检查服务器是否运行。');
};
ws.onclose = () => {
console.log('[Browser Page] WebSocket Connection Closed');
updateStatus('与服务器断开连接。尝试重新连接...');
stopRecognition();
// 尝试重新连接
if (reconnectAttempts < maxReconnectAttempts) {
reconnectAttempts++;
updateStatus(`与服务器断开连接。尝试重新连接 (${reconnectAttempts}/${maxReconnectAttempts})...`);
setTimeout(connectWebSocket, reconnectInterval);
} else {
updateStatus('无法连接到服务器。请刷新页面或重启应用。');
}
};
} catch (error) {
console.error('[Browser Page] Error creating WebSocket:', error);
updateStatus('创建WebSocket连接时出错。请刷新页面或重启应用。');
}
}
// 初始连接
connectWebSocket();
let recognition = null;
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
function updateStatus(message) {
console.log(`[Browser Page Status] ${message}`);
statusDiv.textContent = message;
}
function handleMessage(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 if (data.type === 'reset') {
// 强制重置语音识别
forceResetRecognition();
} else {
console.warn('[Browser Page] Received unknown command type:', data.type);
}
};
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("识别未运行。");
}
}
function forceResetRecognition() {
console.log('[Browser Page] Force resetting recognition...');
updateStatus("强制重置语音识别...");
// 先尝试停止当前的识别
if (recognition) {
try {
recognition.stop();
} catch (e) {
console.error('[Browser Page] Error stopping recognition during reset:', e);
}
}
// 强制设置为null丢弃所有后续结果
recognition = null;
// 通知服务器已重置
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'status', message: 'reset_complete' }));
}
updateStatus("语音识别已重置,等待新指令。");
}
</script>
</body>
</html>

View File

@@ -1,6 +1,6 @@
{
"name": "CherryStudio",
"version": "1.2.7",
"version": "1.2.5",
"private": true,
"description": "A powerful AI assistant for producer.",
"main": "./out/main/index.js",
@@ -44,12 +44,7 @@
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
"check:i18n": "node scripts/check-i18n.js",
"test": "yarn test:renderer",
"test:coverage": "yarn test:renderer:coverage",
"test:node": "npx -y tsx --test src/**/*.test.ts",
"test:renderer": "vitest run",
"test:renderer:ui": "vitest --ui",
"test:renderer:coverage": "vitest run --coverage",
"test": "npx -y tsx --test src/**/*.test.ts",
"format": "prettier --write .",
"lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
"postinstall": "electron-builder install-app-deps",
@@ -72,33 +67,30 @@
"@langchain/community": "^0.3.36",
"@mozilla/readability": "^0.6.0",
"@notionhq/client": "^2.2.15",
"@sentry/electron": "^6.5.0",
"@strongtz/win32-arm64-msvc": "^0.4.7",
"@tryfabric/martian": "^1.2.4",
"@types/react-infinite-scroll-component": "^5.0.0",
"@xyflow/react": "^12.4.4",
"adm-zip": "^0.5.16",
"archiver": "^7.0.1",
"async-mutex": "^0.5.0",
"bufferutil": "^4.0.9",
"color": "^5.0.0",
"diff": "^7.0.0",
"docx": "^9.0.2",
"edge-tts-node": "^1.5.7",
"electron-log": "^5.1.5",
"electron-store": "^8.2.0",
"electron-updater": "^6.3.9",
"electron-window-state": "^5.0.3",
"epub": "patch:epub@npm%3A1.3.0#~/.yarn/patches/epub-npm-1.3.0-8325494ffe.patch",
"extract-zip": "^2.0.1",
"fast-xml-parser": "^5.2.0",
"fetch-socks": "^1.3.2",
"fs-extra": "^11.2.0",
"got-scraping": "^4.1.1",
"jsdom": "^26.0.0",
"markdown-it": "^14.1.0",
"node-stream-zip": "^1.15.0",
"node-edge-tts": "^1.2.8",
"officeparser": "^4.1.1",
"os-proxy-config": "patch:os-proxy-config@npm%3A1.1.1#~/.yarn/patches/os-proxy-config-npm-1.1.1-af9c7574cc.patch",
"proxy-agent": "^6.5.0",
"tar": "^7.4.3",
"turndown": "^7.2.0",
@@ -112,6 +104,7 @@
"@agentic/exa": "^7.3.3",
"@agentic/searxng": "^7.3.3",
"@agentic/tavily": "^7.3.3",
"@analytics/google-analytics": "^1.1.0",
"@ant-design/v5-patch-for-react-19": "^1.0.3",
"@anthropic-ai/sdk": "^0.38.0",
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
@@ -127,15 +120,13 @@
"@modelcontextprotocol/sdk": "^1.9.0",
"@notionhq/client": "^2.2.15",
"@reduxjs/toolkit": "^2.2.5",
"@sentry/react": "^9.13.0",
"@sentry/vite-plugin": "^3.3.1",
"@swc/plugin-styled-components": "^7.1.3",
"@tavily/core": "patch:@tavily/core@npm%3A0.3.1#~/.yarn/patches/@tavily-core-npm-0.3.1-fe69bf2bea.patch",
"@tryfabric/martian": "^1.2.4",
"@types/adm-zip": "^0",
"@types/diff": "^7",
"@types/fs-extra": "^11",
"@types/lodash": "^4.17.5",
"@types/js-yaml": "^4",
"@types/lodash": "^4.17.16",
"@types/markdown-it": "^14",
"@types/md5": "^2.3.5",
"@types/node": "^18.19.9",
@@ -145,9 +136,8 @@
"@types/react-infinite-scroll-component": "^5.0.0",
"@types/tinycolor2": "^1",
"@types/ws": "^8",
"@vitejs/plugin-react-swc": "^3.9.0",
"@vitest/coverage-v8": "^3.1.1",
"@vitest/ui": "^3.1.1",
"@vitejs/plugin-react": "4.3.4",
"analytics": "^0.8.16",
"antd": "^5.22.5",
"applescript": "^1.0.0",
"axios": "^1.7.3",
@@ -196,6 +186,7 @@
"rehype-katex": "^7.0.1",
"rehype-mathjax": "^7.0.0",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-cjk-friendly": "^1.1.0",
"remark-gfm": "^4.0.0",
"remark-math": "^6.0.0",
@@ -209,8 +200,7 @@
"tokenx": "^0.4.1",
"typescript": "^5.6.2",
"uuid": "^10.0.0",
"vite": "6.2.6",
"vitest": "^3.1.1"
"vite": "6.2.6"
},
"resolutions": {
"pdf-parse@npm:1.1.1": "patch:pdf-parse@npm%3A1.1.1#~/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch",
@@ -219,8 +209,7 @@
"node-gyp": "^9.1.0",
"libsql@npm:^0.4.4": "patch:libsql@npm%3A0.4.7#~/.yarn/patches/libsql-npm-0.4.7-444e260fb1.patch",
"openai@npm:^4.77.0": "patch:openai@npm%3A4.87.3#~/.yarn/patches/openai-npm-4.87.3-2b30a7685f.patch",
"pkce-challenge@npm:^4.1.0": "patch:pkce-challenge@npm%3A4.1.0#~/.yarn/patches/pkce-challenge-npm-4.1.0-fbc51695a3.patch",
"app-builder-lib@npm:26.0.13": "patch:app-builder-lib@npm%3A26.0.13#~/.yarn/patches/app-builder-lib-npm-26.0.13-a064c9e1d0.patch"
"pkce-challenge@npm:^4.1.0": "patch:pkce-challenge@npm%3A4.1.0#~/.yarn/patches/pkce-challenge-npm-4.1.0-fbc51695a3.patch"
},
"packageManager": "yarn@4.6.0",
"lint-staged": {

View File

@@ -20,6 +20,17 @@ export enum IpcChannel {
App_InstallUvBinary = 'app:install-uv-binary',
App_InstallBunBinary = 'app:install-bun-binary',
// ASR Server
Asr_StartServer = 'start-asr-server',
Asr_StopServer = 'stop-asr-server',
// MsTTS
MsTTS_GetVoices = 'mstts:get-voices',
MsTTS_Synthesize = 'mstts:synthesize',
MsTTS_SynthesizeStream = 'mstts:synthesize-stream',
MsTTS_StreamData = 'mstts:stream-data',
MsTTS_StreamEnd = 'mstts:stream-end',
// Open
Open_Path = 'open:path',
Open_Website = 'open:website',
@@ -159,8 +170,5 @@ export enum IpcChannel {
// Search Window
SearchWindow_Open = 'search-window:open',
SearchWindow_Close = 'search-window:close',
SearchWindow_OpenUrl = 'search-window:open-url',
// sentry
Sentry_Init = 'sentry:init'
SearchWindow_OpenUrl = 'search-window:open-url'
}

View File

@@ -0,0 +1,123 @@
/**
* 内置的ASR服务器模块
* 这个文件可以直接在Electron中运行不需要外部依赖
*/
// 使用Electron内置的Node.js模块
const http = require('http')
const path = require('path')
const fs = require('fs')
// 输出环境信息
console.log('ASR Server (Embedded) starting...')
console.log('Node.js version:', process.version)
console.log('Current directory:', __dirname)
console.log('Current working directory:', process.cwd())
console.log('Command line arguments:', process.argv)
// 创建HTTP服务器
const server = http.createServer((req, res) => {
try {
if (req.url === '/' || req.url === '/index.html') {
// 尝试多个可能的路径
const possiblePaths = [
// 当前目录
path.join(__dirname, 'index.html'),
// 上级目录
path.join(__dirname, '..', 'index.html'),
// 应用根目录
path.join(process.cwd(), 'index.html')
]
console.log('Possible index.html paths:', possiblePaths)
// 查找第一个存在的文件
let indexPath = null
for (const p of possiblePaths) {
try {
if (fs.existsSync(p)) {
indexPath = p
console.log(`Found index.html at: ${p}`)
break
}
} catch (e) {
console.error(`Error checking existence of ${p}:`, e)
}
}
if (indexPath) {
// 读取文件内容并发送
fs.readFile(indexPath, (err, data) => {
if (err) {
console.error('Error reading index.html:', err)
res.writeHead(500)
res.end('Error reading index.html')
return
}
res.writeHead(200, { 'Content-Type': 'text/html' })
res.end(data)
})
} else {
// 如果找不到文件返回一个简单的HTML页面
console.error('Could not find index.html, serving fallback page')
res.writeHead(200, { 'Content-Type': 'text/html' })
res.end(`
<!DOCTYPE html>
<html>
<head>
<title>ASR Server</title>
<style>
body { font-family: sans-serif; padding: 2em; }
h1 { color: #333; }
</style>
</head>
<body>
<h1>ASR Server is running</h1>
<p>This is a fallback page because the index.html file could not be found.</p>
<p>Server is running at: http://localhost:34515</p>
<p>Current directory: ${__dirname}</p>
<p>Working directory: ${process.cwd()}</p>
</body>
</html>
`)
}
} else {
// 处理其他请求
res.writeHead(404)
res.end('Not found')
}
} catch (error) {
console.error('Error handling request:', error)
res.writeHead(500)
res.end('Server error')
}
})
// 添加进程错误处理
process.on('uncaughtException', (error) => {
console.error('[Server] Uncaught exception:', error)
// 不立即退出,给日志输出的时间
setTimeout(() => process.exit(1), 1000)
})
process.on('unhandledRejection', (reason, promise) => {
console.error('[Server] Unhandled rejection at:', promise, 'reason:', reason)
})
// 尝试启动服务器
try {
const port = 34515
server.listen(port, () => {
console.log(`[Server] Server running at http://localhost:${port}`)
})
// 处理服务器错误
server.on('error', (error) => {
console.error(`[Server] Failed to start server:`, error)
process.exit(1) // Exit if server fails to start
})
} catch (error) {
console.error('[Server] Critical error starting server:', error)
process.exit(1)
}

View File

@@ -0,0 +1,425 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cherry Studio ASR</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>浏览器语音识别中继页面</h1>
<p>这个页面需要在浏览器中保持打开,以便应用使用其语音识别功能。</p>
<div id="status">正在连接到服务器...</div>
<div id="result"></div>
<script>
const statusDiv = document.getElementById('status');
const resultDiv = document.getElementById('result');
// 尝试连接到WebSocket服务器
let ws;
let reconnectAttempts = 0;
const maxReconnectAttempts = 5;
const reconnectInterval = 2000; // 2秒
function connectWebSocket() {
try {
ws = new WebSocket('ws://localhost:34515');
ws.onopen = () => {
reconnectAttempts = 0;
updateStatus('已连接到服务器,等待指令...');
ws.send(JSON.stringify({ type: 'identify', role: 'browser' }));
};
ws.onmessage = handleMessage;
ws.onerror = (error) => {
console.error('[Browser Page] WebSocket Error:', error);
updateStatus('WebSocket 连接错误!请检查服务器是否运行。');
};
ws.onclose = () => {
console.log('[Browser Page] WebSocket Connection Closed');
updateStatus('与服务器断开连接。尝试重新连接...');
stopRecognition();
// 尝试重新连接
if (reconnectAttempts < maxReconnectAttempts) {
reconnectAttempts++;
updateStatus(`与服务器断开连接。尝试重新连接 (${reconnectAttempts}/${maxReconnectAttempts})...`);
setTimeout(connectWebSocket, reconnectInterval);
} else {
updateStatus('无法连接到服务器。请刷新页面或重启应用。');
}
};
} catch (error) {
console.error('[Browser Page] Error creating WebSocket:', error);
updateStatus('创建WebSocket连接时出错。请刷新页面或重启应用。');
}
}
// 初始连接
connectWebSocket();
let recognition = null;
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
function updateStatus(message) {
console.log(`[Browser Page Status] ${message}`);
statusDiv.textContent = message;
}
function handleMessage(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 if (data.type === 'reset') {
// 强制重置语音识别
forceResetRecognition();
} else {
console.warn('[Browser Page] Received unknown command type:', data.type);
}
};
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("识别未运行。");
}
}
function forceResetRecognition() {
console.log('[Browser Page] Force resetting recognition...');
updateStatus("强制重置语音识别...");
// 先尝试停止当前的识别
if (recognition) {
try {
recognition.stop();
} catch (e) {
console.error('[Browser Page] Error stopping recognition during reset:', e);
}
}
// 强制设置为null丢弃所有后续结果
recognition = null;
// 通知服务器已重置
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'status', message: 'reset_complete' }));
}
updateStatus("语音识别已重置,等待新指令。");
}
</script>
</body>
</html>

854
public/asr-server/package-lock.json generated Normal file
View File

@@ -0,0 +1,854 @@
{
"name": "cherry-asr-server",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cherry-asr-server",
"version": "1.0.0",
"dependencies": {
"express": "^4.18.2",
"ws": "^8.13.0"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmmirror.com/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.3",
"resolved": "https://registry.npmmirror.com/body-parser/-/body-parser-1.20.3.tgz",
"integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==",
"license": "MIT",
"dependencies": {
"bytes": "3.1.2",
"content-type": "~1.0.5",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"http-errors": "2.0.0",
"iconv-lite": "0.4.24",
"on-finished": "2.4.1",
"qs": "6.13.0",
"raw-body": "2.5.2",
"type-is": "~1.6.18",
"unpipe": "1.0.0"
},
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmmirror.com/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmmirror.com/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"license": "MIT",
"dependencies": {
"safe-buffer": "5.2.1"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmmirror.com/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.1",
"resolved": "https://registry.npmmirror.com/cookie/-/cookie-0.7.1.tgz",
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmmirror.com/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmmirror.com/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/destroy": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/destroy/-/destroy-1.2.0.tgz",
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
"license": "MIT",
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmmirror.com/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "4.21.2",
"resolved": "https://registry.npmmirror.com/express/-/express-4.21.2.tgz",
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "1.20.3",
"content-disposition": "0.5.4",
"content-type": "~1.0.4",
"cookie": "0.7.1",
"cookie-signature": "1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"finalhandler": "1.3.1",
"fresh": "0.5.2",
"http-errors": "2.0.0",
"merge-descriptors": "1.0.3",
"methods": "~1.1.2",
"on-finished": "2.4.1",
"parseurl": "~1.3.3",
"path-to-regexp": "0.1.12",
"proxy-addr": "~2.0.7",
"qs": "6.13.0",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "0.19.0",
"serve-static": "1.16.2",
"setprototypeof": "1.2.0",
"statuses": "2.0.1",
"type-is": "~1.6.18",
"utils-merge": "1.0.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "1.3.1",
"resolved": "https://registry.npmmirror.com/finalhandler/-/finalhandler-1.3.1.tgz",
"integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"on-finished": "2.4.1",
"parseurl": "~1.3.3",
"statuses": "2.0.1",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmmirror.com/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmmirror.com/fresh/-/fresh-0.5.2.tgz",
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.0.tgz",
"integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
"license": "MIT",
"dependencies": {
"depd": "2.0.0",
"inherits": "2.0.4",
"setprototypeof": "1.2.0",
"statuses": "2.0.1",
"toidentifier": "1.0.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmmirror.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmmirror.com/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/merge-descriptors": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmmirror.com/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmmirror.com/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmmirror.com/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.12",
"resolved": "https://registry.npmmirror.com/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT"
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.13.0",
"resolved": "https://registry.npmmirror.com/qs/-/qs-6.13.0.tgz",
"integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.0.6"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmmirror.com/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "2.5.2",
"resolved": "https://registry.npmmirror.com/raw-body/-/raw-body-2.5.2.tgz",
"integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==",
"license": "MIT",
"dependencies": {
"bytes": "3.1.2",
"http-errors": "2.0.0",
"iconv-lite": "0.4.24",
"unpipe": "1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/send": {
"version": "0.19.0",
"resolved": "https://registry.npmmirror.com/send/-/send-0.19.0.tgz",
"integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"encodeurl": "~1.0.2",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"fresh": "0.5.2",
"http-errors": "2.0.0",
"mime": "1.6.0",
"ms": "2.1.3",
"on-finished": "2.4.1",
"range-parser": "~1.2.1",
"statuses": "2.0.1"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/send/node_modules/encodeurl": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/encodeurl/-/encodeurl-1.0.2.tgz",
"integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/send/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/serve-static": {
"version": "1.16.2",
"resolved": "https://registry.npmmirror.com/serve-static/-/serve-static-1.16.2.tgz",
"integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==",
"license": "MIT",
"dependencies": {
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"parseurl": "~1.3.3",
"send": "0.19.0"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/statuses/-/statuses-2.0.1.tgz",
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmmirror.com/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmmirror.com/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}

View File

@@ -0,0 +1,10 @@
{
"name": "cherry-asr-server",
"version": "1.0.0",
"description": "Cherry Studio ASR Server",
"main": "server.js",
"dependencies": {
"express": "^4.18.2",
"ws": "^8.13.0"
}
}

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

@@ -0,0 +1,269 @@
// 检查依赖项
try {
console.log('ASR Server starting...')
console.log('Node.js version:', process.version)
console.log('Current directory:', __dirname)
console.log('Current working directory:', process.cwd())
console.log('Command line arguments:', process.argv)
// 检查必要的依赖项
const checkDependency = (name) => {
try {
require(name) // Removed unused variable 'module'
console.log(`Successfully loaded dependency: ${name}`)
return true
} catch (error) {
console.error(`Failed to load dependency: ${name}`, error.message)
return false
}
}
// 检查所有必要的依赖项
const dependencies = ['http', 'ws', 'express', 'path', 'fs']
const missingDeps = dependencies.filter((dep) => !checkDependency(dep))
if (missingDeps.length > 0) {
console.error(`Missing dependencies: ${missingDeps.join(', ')}. Server cannot start.`)
process.exit(1)
}
} catch (error) {
console.error('Error during dependency check:', error)
process.exit(1)
}
// 加载依赖项
const http = require('http')
const WebSocket = require('ws')
const express = require('express')
const path = require('path') // Need path module
// const fs = require('fs') // Commented out unused import 'fs'
const app = express()
const port = 34515 // Define the port
// 获取index.html文件的路径
function getIndexHtmlPath() {
const fs = require('fs')
console.log('Current directory:', __dirname)
console.log('Current working directory:', process.cwd())
// 尝试多个可能的路径
const possiblePaths = [
// 开发环境路径
path.join(__dirname, 'index.html'),
// 当前目录
path.join(process.cwd(), 'index.html'),
// 相对于可执行文件的路径
path.join(path.dirname(process.execPath), 'index.html'),
// 相对于可执行文件的上级目录的路径
path.join(path.dirname(path.dirname(process.execPath)), 'index.html'),
// 相对于可执行文件的resources目录的路径
path.join(path.dirname(process.execPath), 'resources', 'index.html'),
// 相对于可执行文件的resources/asr-server目录的路径
path.join(path.dirname(process.execPath), 'resources', 'asr-server', 'index.html'),
// 相对于可执行文件的asr-server目录的路径
path.join(path.dirname(process.execPath), 'asr-server', 'index.html'),
// 如果是pkg打包环境
process.pkg ? path.join(path.dirname(process.execPath), 'index.html') : null
].filter(Boolean) // 过滤掉null值
console.log('Possible index.html paths:', possiblePaths)
// 检查每个路径,返回第一个存在的文件
for (const p of possiblePaths) {
try {
if (fs.existsSync(p)) {
console.log(`Found index.html at: ${p}`)
return p
}
} catch (e) {
console.error(`Error checking existence of ${p}:`, e)
}
}
// 如果没有找到文件,返回默认路径并记录错误
console.error('Could not find index.html in any of the expected locations')
return path.join(__dirname, 'index.html') // 返回默认路径,即使它可能不存在
}
// 提供网页给浏览器
app.get('/', (req, res) => {
try {
const indexPath = getIndexHtmlPath()
console.log(`Serving index.html from: ${indexPath}`)
// 检查文件是否存在
const fs = require('fs')
if (!fs.existsSync(indexPath)) {
console.error(`Error: index.html not found at ${indexPath}`)
return res.status(404).send(`Error: index.html not found at ${indexPath}. <br>Please check the server logs.`)
}
res.sendFile(indexPath, (err) => {
if (err) {
console.error('Error sending index.html:', err)
res.status(500).send(`Error serving index.html: ${err.message}`)
}
})
} catch (error) {
console.error('Error in route handler:', error)
res.status(500).send(`Server error: ${error.message}`)
}
})
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 === 'reset' && ws === electronConnection) {
if (browserConnection && browserConnection.readyState === WebSocket.OPEN) {
console.log('[Server] Relaying RESET command to browser')
browserConnection.send(JSON.stringify({ type: 'reset' }))
} else {
console.log('[Server] Cannot relay RESET: 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
}
})
})
// 添加进程错误处理
process.on('uncaughtException', (error) => {
console.error('[Server] Uncaught exception:', error)
// 不立即退出,给日志输出的时间
setTimeout(() => process.exit(1), 1000)
})
process.on('unhandledRejection', (reason, promise) => {
console.error('[Server] Unhandled rejection at:', promise, 'reason:', reason)
})
// 尝试启动服务器
try {
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
})
} catch (error) {
console.error('[Server] Critical error starting server:', error)
process.exit(1)
}

View File

@@ -0,0 +1,114 @@
/**
* 独立的ASR服务器
* 这个文件是一个简化版的server.js用于在打包后的应用中运行
*/
// 基本依赖
const http = require('http')
const express = require('express')
const path = require('path')
const fs = require('fs')
// 输出环境信息
console.log('ASR Server starting...')
console.log('Node.js version:', process.version)
console.log('Current directory:', __dirname)
console.log('Current working directory:', process.cwd())
console.log('Command line arguments:', process.argv)
// 创建Express应用
const app = express()
const port = 34515
// 提供静态文件
app.use(express.static(__dirname))
// 提供网页给浏览器
app.get('/', (req, res) => {
try {
// 尝试多个可能的路径
const possiblePaths = [
// 当前目录
path.join(__dirname, 'index.html'),
// 上级目录
path.join(__dirname, '..', 'index.html'),
// 应用根目录
path.join(process.cwd(), 'index.html')
]
console.log('Possible index.html paths:', possiblePaths)
// 查找第一个存在的文件
let indexPath = null
for (const p of possiblePaths) {
try {
if (fs.existsSync(p)) {
indexPath = p
console.log(`Found index.html at: ${p}`)
break
}
} catch (e) {
console.error(`Error checking existence of ${p}:`, e)
}
}
if (indexPath) {
res.sendFile(indexPath)
} else {
// 如果找不到文件返回一个简单的HTML页面
console.error('Could not find index.html, serving fallback page')
res.send(`
<!DOCTYPE html>
<html>
<head>
<title>ASR Server</title>
<style>
body { font-family: sans-serif; padding: 2em; }
h1 { color: #333; }
</style>
</head>
<body>
<h1>ASR Server is running</h1>
<p>This is a fallback page because the index.html file could not be found.</p>
<p>Server is running at: http://localhost:${port}</p>
<p>Current directory: ${__dirname}</p>
<p>Working directory: ${process.cwd()}</p>
</body>
</html>
`)
}
} catch (error) {
console.error('Error serving index.html:', error)
res.status(500).send(`Server error: ${error.message}`)
}
})
// 创建HTTP服务器
const server = http.createServer(app)
// 添加进程错误处理
process.on('uncaughtException', (error) => {
console.error('[Server] Uncaught exception:', error)
// 不立即退出,给日志输出的时间
setTimeout(() => process.exit(1), 1000)
})
process.on('unhandledRejection', (reason, promise) => {
console.error('[Server] Unhandled rejection at:', promise, 'reason:', reason)
})
// 尝试启动服务器
try {
server.listen(port, () => {
console.log(`[Server] Server running at http://localhost:${port}`)
})
// 处理服务器错误
server.on('error', (error) => {
console.error(`[Server] Failed to start server:`, error)
process.exit(1) // Exit if server fails to start
})
} catch (error) {
console.error('[Server] Critical error starting server:', error)
process.exit(1)
}

View File

@@ -0,0 +1,5 @@
@echo off
echo Starting ASR Server...
cd /d %~dp0
node standalone.js
pause

View File

@@ -0,0 +1,123 @@
/**
* 内置的ASR服务器模块
* 这个文件可以直接在Electron中运行不需要外部依赖
*/
// 使用Electron内置的Node.js模块
const http = require('http')
const path = require('path')
const fs = require('fs')
// 输出环境信息
console.log('ASR Server (Embedded) starting...')
console.log('Node.js version:', process.version)
console.log('Current directory:', __dirname)
console.log('Current working directory:', process.cwd())
console.log('Command line arguments:', process.argv)
// 创建HTTP服务器
const server = http.createServer((req, res) => {
try {
if (req.url === '/' || req.url === '/index.html') {
// 尝试多个可能的路径
const possiblePaths = [
// 当前目录
path.join(__dirname, 'index.html'),
// 上级目录
path.join(__dirname, '..', 'index.html'),
// 应用根目录
path.join(process.cwd(), 'index.html')
]
console.log('Possible index.html paths:', possiblePaths)
// 查找第一个存在的文件
let indexPath = null
for (const p of possiblePaths) {
try {
if (fs.existsSync(p)) {
indexPath = p
console.log(`Found index.html at: ${p}`)
break
}
} catch (e) {
console.error(`Error checking existence of ${p}:`, e)
}
}
if (indexPath) {
// 读取文件内容并发送
fs.readFile(indexPath, (err, data) => {
if (err) {
console.error('Error reading index.html:', err)
res.writeHead(500)
res.end('Error reading index.html')
return
}
res.writeHead(200, { 'Content-Type': 'text/html' })
res.end(data)
})
} else {
// 如果找不到文件返回一个简单的HTML页面
console.error('Could not find index.html, serving fallback page')
res.writeHead(200, { 'Content-Type': 'text/html' })
res.end(`
<!DOCTYPE html>
<html>
<head>
<title>ASR Server</title>
<style>
body { font-family: sans-serif; padding: 2em; }
h1 { color: #333; }
</style>
</head>
<body>
<h1>ASR Server is running</h1>
<p>This is a fallback page because the index.html file could not be found.</p>
<p>Server is running at: http://localhost:34515</p>
<p>Current directory: ${__dirname}</p>
<p>Working directory: ${process.cwd()}</p>
</body>
</html>
`)
}
} else {
// 处理其他请求
res.writeHead(404)
res.end('Not found')
}
} catch (error) {
console.error('Error handling request:', error)
res.writeHead(500)
res.end('Server error')
}
})
// 添加进程错误处理
process.on('uncaughtException', (error) => {
console.error('[Server] Uncaught exception:', error)
// 不立即退出,给日志输出的时间
setTimeout(() => process.exit(1), 1000)
})
process.on('unhandledRejection', (reason, promise) => {
console.error('[Server] Unhandled rejection at:', promise, 'reason:', reason)
})
// 尝试启动服务器
try {
const port = 34515
server.listen(port, () => {
console.log(`[Server] Server running at http://localhost:${port}`)
})
// 处理服务器错误
server.on('error', (error) => {
console.error(`[Server] Failed to start server:`, error)
process.exit(1) // Exit if server fails to start
})
} catch (error) {
console.error('[Server] Critical error starting server:', error)
process.exit(1)
}

View File

@@ -0,0 +1,425 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cherry Studio ASR</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>浏览器语音识别中继页面</h1>
<p>这个页面需要在浏览器中保持打开,以便应用使用其语音识别功能。</p>
<div id="status">正在连接到服务器...</div>
<div id="result"></div>
<script>
const statusDiv = document.getElementById('status');
const resultDiv = document.getElementById('result');
// 尝试连接到WebSocket服务器
let ws;
let reconnectAttempts = 0;
const maxReconnectAttempts = 5;
const reconnectInterval = 2000; // 2秒
function connectWebSocket() {
try {
ws = new WebSocket('ws://localhost:34515');
ws.onopen = () => {
reconnectAttempts = 0;
updateStatus('已连接到服务器,等待指令...');
ws.send(JSON.stringify({ type: 'identify', role: 'browser' }));
};
ws.onmessage = handleMessage;
ws.onerror = (error) => {
console.error('[Browser Page] WebSocket Error:', error);
updateStatus('WebSocket 连接错误!请检查服务器是否运行。');
};
ws.onclose = () => {
console.log('[Browser Page] WebSocket Connection Closed');
updateStatus('与服务器断开连接。尝试重新连接...');
stopRecognition();
// 尝试重新连接
if (reconnectAttempts < maxReconnectAttempts) {
reconnectAttempts++;
updateStatus(`与服务器断开连接。尝试重新连接 (${reconnectAttempts}/${maxReconnectAttempts})...`);
setTimeout(connectWebSocket, reconnectInterval);
} else {
updateStatus('无法连接到服务器。请刷新页面或重启应用。');
}
};
} catch (error) {
console.error('[Browser Page] Error creating WebSocket:', error);
updateStatus('创建WebSocket连接时出错。请刷新页面或重启应用。');
}
}
// 初始连接
connectWebSocket();
let recognition = null;
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
function updateStatus(message) {
console.log(`[Browser Page Status] ${message}`);
statusDiv.textContent = message;
}
function handleMessage(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 if (data.type === 'reset') {
// 强制重置语音识别
forceResetRecognition();
} else {
console.warn('[Browser Page] Received unknown command type:', data.type);
}
};
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("识别未运行。");
}
}
function forceResetRecognition() {
console.log('[Browser Page] Force resetting recognition...');
updateStatus("强制重置语音识别...");
// 先尝试停止当前的识别
if (recognition) {
try {
recognition.stop();
} catch (e) {
console.error('[Browser Page] Error stopping recognition during reset:', e);
}
}
// 强制设置为null丢弃所有后续结果
recognition = null;
// 通知服务器已重置
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'status', message: 'reset_complete' }));
}
updateStatus("语音识别已重置,等待新指令。");
}
</script>
</body>
</html>

854
resources/asr-server/package-lock.json generated Normal file
View File

@@ -0,0 +1,854 @@
{
"name": "cherry-asr-server",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cherry-asr-server",
"version": "1.0.0",
"dependencies": {
"express": "^4.18.2",
"ws": "^8.13.0"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmmirror.com/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.3",
"resolved": "https://registry.npmmirror.com/body-parser/-/body-parser-1.20.3.tgz",
"integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==",
"license": "MIT",
"dependencies": {
"bytes": "3.1.2",
"content-type": "~1.0.5",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"http-errors": "2.0.0",
"iconv-lite": "0.4.24",
"on-finished": "2.4.1",
"qs": "6.13.0",
"raw-body": "2.5.2",
"type-is": "~1.6.18",
"unpipe": "1.0.0"
},
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmmirror.com/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmmirror.com/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"license": "MIT",
"dependencies": {
"safe-buffer": "5.2.1"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmmirror.com/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.1",
"resolved": "https://registry.npmmirror.com/cookie/-/cookie-0.7.1.tgz",
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmmirror.com/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmmirror.com/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/destroy": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/destroy/-/destroy-1.2.0.tgz",
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
"license": "MIT",
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmmirror.com/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "4.21.2",
"resolved": "https://registry.npmmirror.com/express/-/express-4.21.2.tgz",
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "1.20.3",
"content-disposition": "0.5.4",
"content-type": "~1.0.4",
"cookie": "0.7.1",
"cookie-signature": "1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"finalhandler": "1.3.1",
"fresh": "0.5.2",
"http-errors": "2.0.0",
"merge-descriptors": "1.0.3",
"methods": "~1.1.2",
"on-finished": "2.4.1",
"parseurl": "~1.3.3",
"path-to-regexp": "0.1.12",
"proxy-addr": "~2.0.7",
"qs": "6.13.0",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "0.19.0",
"serve-static": "1.16.2",
"setprototypeof": "1.2.0",
"statuses": "2.0.1",
"type-is": "~1.6.18",
"utils-merge": "1.0.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "1.3.1",
"resolved": "https://registry.npmmirror.com/finalhandler/-/finalhandler-1.3.1.tgz",
"integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"on-finished": "2.4.1",
"parseurl": "~1.3.3",
"statuses": "2.0.1",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmmirror.com/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmmirror.com/fresh/-/fresh-0.5.2.tgz",
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.0.tgz",
"integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
"license": "MIT",
"dependencies": {
"depd": "2.0.0",
"inherits": "2.0.4",
"setprototypeof": "1.2.0",
"statuses": "2.0.1",
"toidentifier": "1.0.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmmirror.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmmirror.com/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/merge-descriptors": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmmirror.com/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmmirror.com/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmmirror.com/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.12",
"resolved": "https://registry.npmmirror.com/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT"
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.13.0",
"resolved": "https://registry.npmmirror.com/qs/-/qs-6.13.0.tgz",
"integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.0.6"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmmirror.com/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "2.5.2",
"resolved": "https://registry.npmmirror.com/raw-body/-/raw-body-2.5.2.tgz",
"integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==",
"license": "MIT",
"dependencies": {
"bytes": "3.1.2",
"http-errors": "2.0.0",
"iconv-lite": "0.4.24",
"unpipe": "1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/send": {
"version": "0.19.0",
"resolved": "https://registry.npmmirror.com/send/-/send-0.19.0.tgz",
"integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"encodeurl": "~1.0.2",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"fresh": "0.5.2",
"http-errors": "2.0.0",
"mime": "1.6.0",
"ms": "2.1.3",
"on-finished": "2.4.1",
"range-parser": "~1.2.1",
"statuses": "2.0.1"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/send/node_modules/encodeurl": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/encodeurl/-/encodeurl-1.0.2.tgz",
"integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/send/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/serve-static": {
"version": "1.16.2",
"resolved": "https://registry.npmmirror.com/serve-static/-/serve-static-1.16.2.tgz",
"integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==",
"license": "MIT",
"dependencies": {
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"parseurl": "~1.3.3",
"send": "0.19.0"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/statuses/-/statuses-2.0.1.tgz",
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmmirror.com/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmmirror.com/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}

View File

@@ -0,0 +1,10 @@
{
"name": "cherry-asr-server",
"version": "1.0.0",
"description": "Cherry Studio ASR Server",
"main": "server.js",
"dependencies": {
"express": "^4.18.2",
"ws": "^8.13.0"
}
}

View File

@@ -0,0 +1,269 @@
// 检查依赖项
try {
console.log('ASR Server starting...')
console.log('Node.js version:', process.version)
console.log('Current directory:', __dirname)
console.log('Current working directory:', process.cwd())
console.log('Command line arguments:', process.argv)
// 检查必要的依赖项
const checkDependency = (name) => {
try {
require(name) // Removed unused variable 'module'
console.log(`Successfully loaded dependency: ${name}`)
return true
} catch (error) {
console.error(`Failed to load dependency: ${name}`, error.message)
return false
}
}
// 检查所有必要的依赖项
const dependencies = ['http', 'ws', 'express', 'path', 'fs']
const missingDeps = dependencies.filter((dep) => !checkDependency(dep))
if (missingDeps.length > 0) {
console.error(`Missing dependencies: ${missingDeps.join(', ')}. Server cannot start.`)
process.exit(1)
}
} catch (error) {
console.error('Error during dependency check:', error)
process.exit(1)
}
// 加载依赖项
const http = require('http')
const WebSocket = require('ws')
const express = require('express')
const path = require('path') // Need path module
// const fs = require('fs') // Commented out unused import 'fs'
const app = express()
const port = 34515 // Define the port
// 获取index.html文件的路径
function getIndexHtmlPath() {
const fs = require('fs')
console.log('Current directory:', __dirname)
console.log('Current working directory:', process.cwd())
// 尝试多个可能的路径
const possiblePaths = [
// 开发环境路径
path.join(__dirname, 'index.html'),
// 当前目录
path.join(process.cwd(), 'index.html'),
// 相对于可执行文件的路径
path.join(path.dirname(process.execPath), 'index.html'),
// 相对于可执行文件的上级目录的路径
path.join(path.dirname(path.dirname(process.execPath)), 'index.html'),
// 相对于可执行文件的resources目录的路径
path.join(path.dirname(process.execPath), 'resources', 'index.html'),
// 相对于可执行文件的resources/asr-server目录的路径
path.join(path.dirname(process.execPath), 'resources', 'asr-server', 'index.html'),
// 相对于可执行文件的asr-server目录的路径
path.join(path.dirname(process.execPath), 'asr-server', 'index.html'),
// 如果是pkg打包环境
process.pkg ? path.join(path.dirname(process.execPath), 'index.html') : null
].filter(Boolean) // 过滤掉null值
console.log('Possible index.html paths:', possiblePaths)
// 检查每个路径,返回第一个存在的文件
for (const p of possiblePaths) {
try {
if (fs.existsSync(p)) {
console.log(`Found index.html at: ${p}`)
return p
}
} catch (e) {
console.error(`Error checking existence of ${p}:`, e)
}
}
// 如果没有找到文件,返回默认路径并记录错误
console.error('Could not find index.html in any of the expected locations')
return path.join(__dirname, 'index.html') // 返回默认路径,即使它可能不存在
}
// 提供网页给浏览器
app.get('/', (req, res) => {
try {
const indexPath = getIndexHtmlPath()
console.log(`Serving index.html from: ${indexPath}`)
// 检查文件是否存在
const fs = require('fs')
if (!fs.existsSync(indexPath)) {
console.error(`Error: index.html not found at ${indexPath}`)
return res.status(404).send(`Error: index.html not found at ${indexPath}. <br>Please check the server logs.`)
}
res.sendFile(indexPath, (err) => {
if (err) {
console.error('Error sending index.html:', err)
res.status(500).send(`Error serving index.html: ${err.message}`)
}
})
} catch (error) {
console.error('Error in route handler:', error)
res.status(500).send(`Server error: ${error.message}`)
}
})
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 === 'reset' && ws === electronConnection) {
if (browserConnection && browserConnection.readyState === WebSocket.OPEN) {
console.log('[Server] Relaying RESET command to browser')
browserConnection.send(JSON.stringify({ type: 'reset' }))
} else {
console.log('[Server] Cannot relay RESET: 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
}
})
})
// 添加进程错误处理
process.on('uncaughtException', (error) => {
console.error('[Server] Uncaught exception:', error)
// 不立即退出,给日志输出的时间
setTimeout(() => process.exit(1), 1000)
})
process.on('unhandledRejection', (reason, promise) => {
console.error('[Server] Unhandled rejection at:', promise, 'reason:', reason)
})
// 尝试启动服务器
try {
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
})
} catch (error) {
console.error('[Server] Critical error starting server:', error)
process.exit(1)
}

View File

@@ -0,0 +1,114 @@
/**
* 独立的ASR服务器
* 这个文件是一个简化版的server.js用于在打包后的应用中运行
*/
// 基本依赖
const http = require('http')
const express = require('express')
const path = require('path')
const fs = require('fs')
// 输出环境信息
console.log('ASR Server starting...')
console.log('Node.js version:', process.version)
console.log('Current directory:', __dirname)
console.log('Current working directory:', process.cwd())
console.log('Command line arguments:', process.argv)
// 创建Express应用
const app = express()
const port = 34515
// 提供静态文件
app.use(express.static(__dirname))
// 提供网页给浏览器
app.get('/', (req, res) => {
try {
// 尝试多个可能的路径
const possiblePaths = [
// 当前目录
path.join(__dirname, 'index.html'),
// 上级目录
path.join(__dirname, '..', 'index.html'),
// 应用根目录
path.join(process.cwd(), 'index.html')
]
console.log('Possible index.html paths:', possiblePaths)
// 查找第一个存在的文件
let indexPath = null
for (const p of possiblePaths) {
try {
if (fs.existsSync(p)) {
indexPath = p
console.log(`Found index.html at: ${p}`)
break
}
} catch (e) {
console.error(`Error checking existence of ${p}:`, e)
}
}
if (indexPath) {
res.sendFile(indexPath)
} else {
// 如果找不到文件返回一个简单的HTML页面
console.error('Could not find index.html, serving fallback page')
res.send(`
<!DOCTYPE html>
<html>
<head>
<title>ASR Server</title>
<style>
body { font-family: sans-serif; padding: 2em; }
h1 { color: #333; }
</style>
</head>
<body>
<h1>ASR Server is running</h1>
<p>This is a fallback page because the index.html file could not be found.</p>
<p>Server is running at: http://localhost:${port}</p>
<p>Current directory: ${__dirname}</p>
<p>Working directory: ${process.cwd()}</p>
</body>
</html>
`)
}
} catch (error) {
console.error('Error serving index.html:', error)
res.status(500).send(`Server error: ${error.message}`)
}
})
// 创建HTTP服务器
const server = http.createServer(app)
// 添加进程错误处理
process.on('uncaughtException', (error) => {
console.error('[Server] Uncaught exception:', error)
// 不立即退出,给日志输出的时间
setTimeout(() => process.exit(1), 1000)
})
process.on('unhandledRejection', (reason, promise) => {
console.error('[Server] Unhandled rejection at:', promise, 'reason:', reason)
})
// 尝试启动服务器
try {
server.listen(port, () => {
console.log(`[Server] Server running at http://localhost:${port}`)
})
// 处理服务器错误
server.on('error', (error) => {
console.error(`[Server] Failed to start server:`, error)
process.exit(1) // Exit if server fails to start
})
} catch (error) {
console.error('[Server] Critical error starting server:', error)
process.exit(1)
}

View File

@@ -0,0 +1,5 @@
@echo off
echo Starting ASR Server...
cd /d %~dp0
node standalone.js
pause

View File

@@ -1,8 +1,10 @@
const { Arch } = require('electron-builder')
const { default: removeLocales } = require('./remove-locales')
const fs = require('fs')
const path = require('path')
exports.default = async function (context) {
await removeLocales(context)
const platform = context.packager.platform.name
const arch = context.arch

View File

@@ -3,7 +3,7 @@ Object.defineProperty(exports, '__esModule', { value: true })
var fs = require('fs')
var path = require('path')
var translationsDir = path.join(__dirname, '../src/renderer/src/i18n/locales')
var baseLocale = 'zh-CN'
var baseLocale = 'zh-cn'
var baseFileName = ''.concat(baseLocale, '.json')
var baseFilePath = path.join(translationsDir, baseFileName)
/**

View File

@@ -2,7 +2,7 @@ import * as fs from 'fs'
import * as path from 'path'
const translationsDir = path.join(__dirname, '../src/renderer/src/i18n/locales')
const baseLocale = 'zh-CN'
const baseLocale = 'zh-cn'
const baseFileName = `${baseLocale}.json`
const baseFilePath = path.join(translationsDir, baseFileName)

58
scripts/remove-locales.js Normal file
View File

@@ -0,0 +1,58 @@
const fs = require('fs')
const path = require('path')
exports.default = async function (context) {
const platform = context.packager.platform.name
// 根据平台确定 locales 目录位置
let resourceDirs = []
if (platform === 'mac') {
// macOS 的语言文件位置
resourceDirs = [
path.join(context.appOutDir, 'Cherry Studio.app', 'Contents', 'Resources'),
path.join(
context.appOutDir,
'Cherry Studio.app',
'Contents',
'Frameworks',
'Electron Framework.framework',
'Resources'
)
]
} else {
// Windows 和 Linux 的语言文件位置
resourceDirs = [path.join(context.appOutDir, 'locales')]
}
// 处理每个资源目录
for (const resourceDir of resourceDirs) {
if (!fs.existsSync(resourceDir)) {
console.log(`Resource directory not found: ${resourceDir}, skipping...`)
continue
}
// 读取所有文件和目录
const items = fs.readdirSync(resourceDir)
// 遍历并删除不需要的语言文件
for (const item of items) {
if (platform === 'mac') {
// 在 macOS 上检查 .lproj 目录
if (item.endsWith('.lproj') && !item.match(/^(en|zh|ru)/)) {
const dirPath = path.join(resourceDir, item)
fs.rmSync(dirPath, { recursive: true, force: true })
console.log(`Removed locale directory: ${item} from ${resourceDir}`)
}
} else {
// 其他平台处理 .pak 文件
if (!item.match(/^(en|zh|ru)/)) {
const filePath = path.join(resourceDir, item)
fs.unlinkSync(filePath)
console.log(`Removed locale file: ${item} from ${resourceDir}`)
}
}
}
}
console.log('Locale cleanup completed!')
}

View File

@@ -5,7 +5,6 @@ import { app, ipcMain } from 'electron'
import installExtension, { REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS } from 'electron-devtools-installer'
import Logger from 'electron-log'
import { initSentry } from './integration/sentry'
import { registerIpc } from './ipc'
import { configManager } from './services/ConfigManager'
import mcpService from './services/MCPService'
@@ -49,6 +48,9 @@ if (!app.requestSingleInstanceLock()) {
registerIpc(mainWindow, app)
// 注意: MsTTS IPC处理程序已在ipc.ts中注册
// 不需要再次调用registerMsTTSIpcHandlers()
replaceDevtoolsFont(mainWindow)
if (process.env.NODE_ENV === 'development') {
@@ -111,5 +113,3 @@ if (!app.requestSingleInstanceLock()) {
// In this file you can include the rest of your app"s specific main process
// code. You can also put them in separate files and require them here.
}
initSentry()

View File

@@ -1,12 +0,0 @@
import { configManager } from '@main/services/ConfigManager'
import * as Sentry from '@sentry/electron/main'
import { app } from 'electron'
export function initSentry() {
if (configManager.getEnableDataCollection()) {
Sentry.init({
dsn: 'https://194ceab3bd44e686bd3ebda9de3c20fd@o4509184559218688.ingest.us.sentry.io/4509184569442304',
environment: app.isPackaged ? 'production' : 'development'
})
}
}

View File

@@ -9,8 +9,8 @@ import { BrowserWindow, ipcMain, session, shell } from 'electron'
import log from 'electron-log'
import { titleBarOverlayDark, titleBarOverlayLight } from './config'
import { initSentry } from './integration/sentry'
import AppUpdater from './services/AppUpdater'
import { asrServerService } from './services/ASRServerService'
import BackupManager from './services/BackupManager'
import { configManager } from './services/ConfigManager'
import CopilotService from './services/CopilotService'
@@ -20,6 +20,7 @@ import FileStorage from './services/FileStorage'
import { GeminiService } from './services/GeminiService'
import KnowledgeService from './services/KnowledgeService'
import mcpService from './services/MCPService'
import * as MsTTSService from './services/MsTTSService'
import * as NutstoreService from './services/NutstoreService'
import ObsidianVaultService from './services/ObsidianVaultService'
import { ProxyConfig, proxyManager } from './services/ProxyManager'
@@ -49,8 +50,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
appDataPath: app.getPath('userData'),
resourcesPath: getResourcePath(),
logsPath: log.transports.file.getFile().path,
arch: arch(),
isPortable: isWin && 'PORTABLE_EXECUTABLE_DIR' in process.env
arch: arch()
}))
ipcMain.handle(IpcChannel.App_Proxy, async (_, proxy: string) => {
@@ -104,7 +104,6 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
// auto update
ipcMain.handle(IpcChannel.App_SetAutoUpdate, (_, isActive: boolean) => {
appUpdater.setAutoUpdate(isActive)
configManager.setAutoUpdate(isActive)
})
@@ -168,7 +167,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
})
)
await fileManager.clearTemp()
await fs.writeFileSync(log.transports.file.getFile().path, '')
fs.writeFileSync(log.transports.file.getFile().path, '')
return { success: true }
} catch (error: any) {
log.error('Failed to clear cache:', error)
@@ -178,14 +177,6 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
// check for update
ipcMain.handle(IpcChannel.App_CheckForUpdate, async () => {
// 在 Windows 上,如果架构是 arm64则不检查更新
if (isWin && (arch().includes('arm') || 'PORTABLE_EXECUTABLE_DIR' in process.env)) {
return {
currentVersion: app.getVersion(),
updateInfo: null
}
}
const update = await appUpdater.autoUpdater.checkForUpdates()
return {
@@ -334,16 +325,18 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
)
// search window
ipcMain.handle(IpcChannel.SearchWindow_Open, async (_, uid: string) => {
await searchService.openSearchWindow(uid)
})
ipcMain.handle(IpcChannel.SearchWindow_Close, async (_, uid: string) => {
await searchService.closeSearchWindow(uid)
})
ipcMain.handle(IpcChannel.SearchWindow_OpenUrl, async (_, uid: string, url: string) => {
return await searchService.openUrlInSearchWindow(uid, url)
})
ipcMain.handle(IpcChannel.SearchWindow_Open, (_, uid: string) => searchService.openSearchWindow(uid))
ipcMain.handle(IpcChannel.SearchWindow_Close, (_, uid: string) => searchService.closeSearchWindow(uid))
ipcMain.handle(IpcChannel.SearchWindow_OpenUrl, (_, uid: string, url: string) =>
searchService.openUrlInSearchWindow(uid, url)
)
// sentry
ipcMain.handle(IpcChannel.Sentry_Init, () => initSentry())
// 注册ASR服务器IPC处理程序
asrServerService.registerIpcHandlers()
// 注册MsTTS IPC处理程序
ipcMain.handle(IpcChannel.MsTTS_GetVoices, MsTTSService.getVoices)
ipcMain.handle(IpcChannel.MsTTS_Synthesize, (_, text: string, voice: string, outputFormat: string) =>
MsTTSService.synthesize(text, voice, outputFormat)
)
}

View File

@@ -0,0 +1,131 @@
import { ChildProcess, spawn } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
import { IpcChannel } from '@shared/IpcChannel'
import { app, ipcMain } from 'electron'
import log from 'electron-log'
/**
* ASR服务器服务用于管理ASR服务器进程
*/
class ASRServerService {
private asrServerProcess: ChildProcess | null = null
/**
* 注册IPC处理程序
*/
public registerIpcHandlers(): void {
// 启动ASR服务器
ipcMain.handle(IpcChannel.Asr_StartServer, this.startServer.bind(this))
// 停止ASR服务器
ipcMain.handle(IpcChannel.Asr_StopServer, this.stopServer.bind(this))
}
/**
* 启动ASR服务器
* @returns Promise<{success: boolean, pid?: number, error?: string}>
*/
private async startServer(): Promise<{ success: boolean; pid?: number; error?: string }> {
try {
if (this.asrServerProcess) {
return { success: true, pid: this.asrServerProcess.pid }
}
// 获取服务器文件路径
log.info('App path:', app.getAppPath())
// 在开发环境和生产环境中使用不同的路径
let serverPath = ''
const isPackaged = app.isPackaged
if (isPackaged) {
// 生产环境 (打包后) - 使用 extraResources 复制的路径
// 注意: 'app' 是 extraResources 配置中 'to' 字段的一部分
serverPath = path.join(process.resourcesPath, 'app', 'asr-server', 'server.js')
log.info('生产环境ASR 服务器路径:', serverPath)
} else {
// 开发环境 - 指向项目根目录的 asr-server
serverPath = path.join(app.getAppPath(), 'asr-server', 'server.js')
log.info('开发环境ASR 服务器路径:', serverPath)
}
// 注意:删除了 isExeFile 检查逻辑, 假设总是用 node 启动
// Removed unused variable 'isExeFile'
log.info('ASR服务器路径:', serverPath)
// 检查文件是否存在
if (!fs.existsSync(serverPath)) {
return { success: false, error: '服务器文件不存在' }
}
// 启动服务器进程
// 始终使用 node 启动 server.js
log.info(`尝试使用 node 启动: ${serverPath}`)
this.asrServerProcess = spawn('node', [serverPath], {
stdio: 'pipe', // 'pipe' 用于捕获输出, 如果需要调试可以临时改为 'inherit'
detached: false // false 通常足够
})
// 处理服务器输出
this.asrServerProcess.stdout?.on('data', (data) => {
log.info(`[ASR Server] ${data.toString()}`)
})
this.asrServerProcess.stderr?.on('data', (data) => {
log.error(`[ASR Server Error] ${data.toString()}`)
})
// 处理服务器退出
this.asrServerProcess.on('close', (code) => {
log.info(`[ASR Server] 进程退出,退出码: ${code}`)
this.asrServerProcess = null
})
// 等待一段时间确保服务器启动
await new Promise((resolve) => setTimeout(resolve, 1000))
return { success: true, pid: this.asrServerProcess.pid }
} catch (error) {
log.error('启动ASR服务器失败:', error)
return { success: false, error: (error as Error).message }
}
}
/**
* 停止ASR服务器
* @param _event IPC事件
* @param pid 进程ID
* @returns Promise<{success: boolean, error?: string}>
*/
private async stopServer(
_event: Electron.IpcMainInvokeEvent,
pid?: number
): Promise<{ success: boolean; error?: string }> {
try {
if (!this.asrServerProcess) {
return { success: true }
}
// 检查PID是否匹配
if (pid && this.asrServerProcess.pid !== pid) {
log.warn(`请求停止的PID (${pid}) 与当前运行的ASR服务器PID (${this.asrServerProcess.pid}) 不匹配`)
}
// 杀死进程
this.asrServerProcess.kill()
// 等待一段时间确保进程已经退出
await new Promise((resolve) => setTimeout(resolve, 500))
this.asrServerProcess = null
return { success: true }
} catch (error) {
log.error('停止ASR服务器失败:', error)
return { success: false, error: (error as Error).message }
}
}
}
// 导出单例实例
export const asrServerService = new ASRServerService()

View File

@@ -55,11 +55,6 @@ export default class AppUpdater {
this.autoUpdater = autoUpdater
}
public setAutoUpdate(isActive: boolean) {
autoUpdater.autoDownload = isActive
autoUpdater.autoInstallOnAppQuit = isActive
}
public async showUpdateDialog(mainWindow: BrowserWindow) {
if (!this.releaseInfo) {
return

View File

@@ -1,27 +1,25 @@
import { AxiosInstance, default as axios_ } from 'axios'
import { ProxyAgent } from 'proxy-agent'
import { proxyManager } from './ProxyManager'
class AxiosProxy {
private cacheAxios: AxiosInstance | null = null
private proxyAgent: ProxyAgent | null = null
private cacheAxios: AxiosInstance | undefined
private proxyURL: string | undefined
get axios(): AxiosInstance {
const currentProxyAgent = proxyManager.getProxyAgent()
// 如果代理发生变化或尚未初始化,则重新创建 axios 实例
if (this.cacheAxios === null || (currentProxyAgent !== null && this.proxyAgent !== currentProxyAgent)) {
this.proxyAgent = currentProxyAgent
// 创建带有代理配置的 axios 实例
const currentProxyURL = proxyManager.getProxyUrl()
if (this.proxyURL !== currentProxyURL) {
this.proxyURL = currentProxyURL
const agent = proxyManager.getProxyAgent()
this.cacheAxios = axios_.create({
proxy: false,
httpAgent: currentProxyAgent || undefined,
httpsAgent: currentProxyAgent || undefined
...(agent && { httpAgent: agent, httpsAgent: agent })
})
}
if (this.cacheAxios === undefined) {
this.cacheAxios = axios_.create({ proxy: false })
}
return this.cacheAxios
}
}

View File

@@ -1,10 +1,9 @@
import { IpcChannel } from '@shared/IpcChannel'
import { WebDavConfig } from '@types'
import archiver from 'archiver'
import AdmZip from 'adm-zip'
import { exec } from 'child_process'
import { app } from 'electron'
import Logger from 'electron-log'
import extract from 'extract-zip'
import * as fs from 'fs-extra'
import * as path from 'path'
import { createClient, CreateDirectoryOptions, FileStat } from 'webdav'
@@ -92,7 +91,6 @@ class BackupManager {
// 使用流的方式写入 data.json
const tempDataPath = path.join(this.tempDir, 'data.json')
await new Promise<void>((resolve, reject) => {
const writeStream = fs.createWriteStream(tempDataPath)
writeStream.write(data)
@@ -101,7 +99,6 @@ class BackupManager {
writeStream.on('finish', () => resolve())
writeStream.on('error', (error) => reject(error))
})
onProgress({ stage: 'writing_data', progress: 20, total: 100 })
// 复制 Data 目录到临时目录
@@ -115,92 +112,18 @@ class BackupManager {
// 使用流式复制
await this.copyDirWithProgress(sourcePath, tempDataDir, (size) => {
copiedSize += size
const progress = Math.min(50, Math.floor((copiedSize / totalSize) * 50))
const progress = Math.min(80, 20 + Math.floor((copiedSize / totalSize) * 60))
onProgress({ stage: 'copying_files', progress, total: 100 })
})
await this.setWritableRecursive(tempDataDir)
onProgress({ stage: 'preparing_compression', progress: 50, total: 100 })
onProgress({ stage: 'compressing', progress: 80, total: 100 })
// 创建输出文件
// 使用 adm-zip 创建压缩文件
const zip = new AdmZip()
zip.addLocalFolder(this.tempDir)
const backupedFilePath = path.join(destinationPath, fileName)
const output = fs.createWriteStream(backupedFilePath)
// 创建 archiver 实例,启用 ZIP64 支持
const archive = archiver('zip', {
zlib: { level: 1 }, // 使用最低压缩级别以提高速度
zip64: true // 启用 ZIP64 支持以处理大文件
})
let lastProgress = 50
let totalEntries = 0
let processedEntries = 0
let totalBytes = 0
let processedBytes = 0
// 首先计算总文件数和总大小
const calculateTotals = async (dirPath: string) => {
const items = await fs.readdir(dirPath, { withFileTypes: true })
for (const item of items) {
const fullPath = path.join(dirPath, item.name)
if (item.isDirectory()) {
await calculateTotals(fullPath)
} else {
totalEntries++
const stats = await fs.stat(fullPath)
totalBytes += stats.size
}
}
}
await calculateTotals(this.tempDir)
// 监听文件添加事件
archive.on('entry', () => {
processedEntries++
if (totalEntries > 0) {
const progressPercent = Math.min(55, 50 + Math.floor((processedEntries / totalEntries) * 5))
if (progressPercent > lastProgress) {
lastProgress = progressPercent
onProgress({ stage: 'compressing', progress: progressPercent, total: 100 })
}
}
})
// 监听数据写入事件
archive.on('data', (chunk) => {
processedBytes += chunk.length
if (totalBytes > 0) {
const progressPercent = Math.min(99, 55 + Math.floor((processedBytes / totalBytes) * 44))
if (progressPercent > lastProgress) {
lastProgress = progressPercent
onProgress({ stage: 'compressing', progress: progressPercent, total: 100 })
}
}
})
// 使用 Promise 等待压缩完成
await new Promise<void>((resolve, reject) => {
output.on('close', () => {
onProgress({ stage: 'compressing', progress: 100, total: 100 })
resolve()
})
archive.on('error', reject)
archive.on('warning', (err: any) => {
if (err.code !== 'ENOENT') {
Logger.warn('[BackupManager] Archive warning:', err)
}
})
// 将输出流连接到压缩器
archive.pipe(output)
// 添加整个临时目录到压缩文件
archive.directory(this.tempDir, false)
// 完成压缩
archive.finalize()
})
zip.writeZip(backupedFilePath)
// 清理临时目录
await fs.remove(this.tempDir)
@@ -210,8 +133,6 @@ class BackupManager {
return backupedFilePath
} catch (error) {
Logger.error('[BackupManager] Backup failed:', error)
// 确保清理临时目录
await fs.remove(this.tempDir).catch(() => {})
throw error
}
}
@@ -230,22 +151,16 @@ class BackupManager {
onProgress({ stage: 'preparing', progress: 0, total: 100 })
Logger.log('[backup] step 1: unzip backup file', this.tempDir)
// 使用 extract-zip 解压
await extract(backupPath, {
dir: this.tempDir,
onEntry: () => {
// 这里可以处理进度,但 extract-zip 不提供总条目数信息
onProgress({ stage: 'extracting', progress: 15, total: 100 })
}
})
onProgress({ stage: 'extracting', progress: 25, total: 100 })
// 使用 adm-zip 解压
const zip = new AdmZip(backupPath)
zip.extractAllTo(this.tempDir, true) // true 表示覆盖已存在的文件
onProgress({ stage: 'extracting', progress: 20, total: 100 })
Logger.log('[backup] step 2: read data.json')
// 读取 data.json
const dataPath = path.join(this.tempDir, 'data.json')
const data = await fs.readFile(dataPath, 'utf-8')
onProgress({ stage: 'reading_data', progress: 35, total: 100 })
onProgress({ stage: 'reading_data', progress: 40, total: 100 })
Logger.log('[backup] step 3: restore Data directory')
// 恢复 Data 目录
@@ -262,7 +177,7 @@ class BackupManager {
// 使用流式复制
await this.copyDirWithProgress(sourcePath, destPath, (size) => {
copiedSize += size
const progress = Math.min(85, 35 + Math.floor((copiedSize / totalSize) * 50))
const progress = Math.min(90, 40 + Math.floor((copiedSize / totalSize) * 50))
onProgress({ stage: 'copying_files', progress, total: 100 })
})

View File

@@ -15,8 +15,7 @@ enum ConfigKeys {
Shortcuts = 'shortcuts',
ClickTrayToShowQuickAssistant = 'clickTrayToShowQuickAssistant',
EnableQuickAssistant = 'enableQuickAssistant',
AutoUpdate = 'autoUpdate',
EnableDataCollection = 'enableDataCollection'
AutoUpdate = 'autoUpdate'
}
export class ConfigManager {
@@ -146,14 +145,6 @@ export class ConfigManager {
this.set(ConfigKeys.AutoUpdate, value)
}
getEnableDataCollection(): boolean {
return this.get<boolean>(ConfigKeys.EnableDataCollection, true)
}
setEnableDataCollection(value: boolean) {
this.set(ConfigKeys.EnableDataCollection, value)
}
set(key: string, value: unknown) {
this.store.set(key, value)
}

View File

@@ -1,7 +1,12 @@
import fs from 'node:fs'
export default class FileService {
public static async readFile(_: Electron.IpcMainInvokeEvent, path: string) {
return fs.readFileSync(path, 'utf8')
public static async readFile(_: Electron.IpcMainInvokeEvent, path: string, encoding?: BufferEncoding) {
// 如果指定了编码,则返回字符串,否则返回二进制数据
if (encoding) {
return fs.readFileSync(path, encoding)
} else {
return fs.readFileSync(path)
}
}
}

View File

@@ -158,9 +158,6 @@ class McpService {
return new StreamableHTTPClientTransport(new URL(server.baseUrl!), options)
} else if (server.type === 'sse') {
const options: SSEClientTransportOptions = {
eventSourceInit: {
fetch: (url, init) => fetch(url, { ...init, headers: server.headers || {} }),
},
requestInit: {
headers: server.headers || {}
},

View File

@@ -0,0 +1,137 @@
import fs from 'node:fs'
import path from 'node:path'
import { app } from 'electron'
import log from 'electron-log'
import { EdgeTTS } from 'node-edge-tts'
/**
* Microsoft Edge TTS服务
* 使用Microsoft Edge的在线TTS服务不需要API密钥
*/
class MsEdgeTTSService {
private static instance: MsEdgeTTSService
private tempDir: string
private constructor() {
this.tempDir = path.join(app.getPath('temp'), 'cherry-tts')
// 确保临时目录存在
if (!fs.existsSync(this.tempDir)) {
fs.mkdirSync(this.tempDir, { recursive: true })
}
}
/**
* 获取单例实例
*/
public static getInstance(): MsEdgeTTSService {
if (!MsEdgeTTSService.instance) {
MsEdgeTTSService.instance = new MsEdgeTTSService()
}
return MsEdgeTTSService.instance
}
/**
* 获取可用的语音列表
* @returns 语音列表
*/
public async getVoices(): Promise<any[]> {
try {
// 返回预定义的中文语音列表
return [
{ name: 'zh-CN-XiaoxiaoNeural', locale: 'zh-CN', gender: 'Female' },
{ name: 'zh-CN-YunxiNeural', locale: 'zh-CN', gender: 'Male' },
{ name: 'zh-CN-YunyangNeural', locale: 'zh-CN', gender: 'Male' },
{ name: 'zh-CN-XiaohanNeural', locale: 'zh-CN', gender: 'Female' },
{ name: 'zh-CN-XiaomoNeural', locale: 'zh-CN', gender: 'Female' },
{ name: 'zh-CN-XiaoxuanNeural', locale: 'zh-CN', gender: 'Female' },
{ name: 'zh-CN-XiaoruiNeural', locale: 'zh-CN', gender: 'Female' },
{ name: 'zh-CN-YunfengNeural', locale: 'zh-CN', gender: 'Male' }
]
} catch (error) {
log.error('获取Microsoft Edge TTS语音列表失败:', error)
throw error
}
}
/**
* 合成语音
* @param text 要合成的文本
* @param voice 语音
* @param outputFormat 输出格式
* @returns 音频文件路径
*/
public async synthesize(text: string, voice: string, outputFormat: string): Promise<string> {
try {
log.info(`Microsoft Edge TTS合成语音: 文本="${text.substring(0, 30)}...", 语音=${voice}, 格式=${outputFormat}`)
// 验证输入参数
if (!text || text.trim() === '') {
throw new Error('要合成的文本不能为空')
}
if (!voice || voice.trim() === '') {
throw new Error('语音名称不能为空')
}
// 创建一个新的EdgeTTS实例并设置参数
const tts = new EdgeTTS({
voice: voice,
outputFormat: outputFormat,
timeout: 30000, // 30秒超时
rate: '+0%', // 正常语速
pitch: '+0Hz', // 正常音调
volume: '+0%' // 正常音量
})
// 生成临时文件路径
const timestamp = Date.now()
const fileExtension = outputFormat.includes('mp3') ? 'mp3' : outputFormat.split('-').pop() || 'audio'
const outputPath = path.join(this.tempDir, `tts_${timestamp}.${fileExtension}`)
log.info(`开始生成语音文件: ${outputPath}`)
// 使用ttsPromise方法生成文件
await tts.ttsPromise(text, outputPath)
// 验证生成的文件是否存在且大小大于0
if (!fs.existsSync(outputPath)) {
throw new Error(`生成的语音文件不存在: ${outputPath}`)
}
const stats = fs.statSync(outputPath)
if (stats.size === 0) {
throw new Error(`生成的语音文件大小为0: ${outputPath}`)
}
log.info(`Microsoft Edge TTS合成成功: ${outputPath}, 文件大小: ${stats.size} 字节`)
return outputPath
} catch (error: any) {
// 记录详细的错误信息
log.error(`Microsoft Edge TTS语音合成失败 (语音=${voice}):`, error)
// 尝试提供更有用的错误信息
if (error.message && typeof error.message === 'string') {
if (error.message.includes('Timed out')) {
throw new Error(`语音合成超时,请检查网络连接或尝试其他语音`)
} else if (error.message.includes('ENOTFOUND')) {
throw new Error(`无法连接到Microsoft语音服务请检查网络连接`)
} else if (error.message.includes('ECONNREFUSED')) {
throw new Error(`连接被拒绝,请检查网络设置或代理配置`)
}
}
throw error
}
}
}
// 导出单例方法
export const getVoices = async () => {
return await MsEdgeTTSService.getInstance().getVoices()
}
export const synthesize = async (text: string, voice: string, outputFormat: string) => {
return await MsEdgeTTSService.getInstance().synthesize(text, voice, outputFormat)
}

View File

@@ -0,0 +1,50 @@
import { IpcChannel } from '@shared/IpcChannel'
import { BrowserWindow, ipcMain } from 'electron'
import * as MsTTSService from './MsTTSService'
/**
* 注册MsTTS相关的IPC处理程序
*/
export function registerMsTTSIpcHandlers(): void {
// 获取可用的语音列表
ipcMain.handle(IpcChannel.MsTTS_GetVoices, MsTTSService.getVoices)
// 合成语音
ipcMain.handle(IpcChannel.MsTTS_Synthesize, (_, text: string, voice: string, outputFormat: string) =>
MsTTSService.synthesize(text, voice, outputFormat)
)
// 流式合成语音
ipcMain.handle(
IpcChannel.MsTTS_SynthesizeStream,
async (event, requestId: string, text: string, voice: string, outputFormat: string) => {
const window = BrowserWindow.fromWebContents(event.sender)
if (!window) return
try {
await MsTTSService.synthesizeStream(
text,
voice,
outputFormat,
(chunk: Uint8Array) => {
// 发送音频数据块
if (!window.isDestroyed()) {
window.webContents.send(IpcChannel.MsTTS_StreamData, requestId, chunk)
}
},
() => {
// 发送流结束信号
if (!window.isDestroyed()) {
window.webContents.send(IpcChannel.MsTTS_StreamEnd, requestId)
}
}
)
return { success: true }
} catch (error) {
console.error('流式TTS合成失败:', error)
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
}
)
}

View File

@@ -0,0 +1,643 @@
import fs from 'node:fs'
import path from 'node:path'
import { MsEdgeTTS, OUTPUT_FORMAT } from 'edge-tts-node' // 新版支持流式的TTS库
import { app } from 'electron'
import log from 'electron-log'
import { EdgeTTS } from 'node-edge-tts' // 旧版TTS库
// --- START OF HARDCODED VOICE LIST ---
// WARNING: This list is static and may become outdated.
// It's generally recommended to use listVoices() for the most up-to-date list.
const hardcodedVoices = [
{
Name: 'Microsoft Server Speech Text to Speech Voice (af-ZA, AdriNeural)',
ShortName: 'af-ZA-AdriNeural',
Gender: 'Female',
Locale: 'af-ZA'
},
{
Name: 'Microsoft Server Speech Text to Speech Voice (am-ET, MekdesNeural)',
ShortName: 'am-ET-MekdesNeural',
Gender: 'Female',
Locale: 'am-ET'
},
{
Name: 'Microsoft Server Speech Text to Speech Voice (ar-AE, FatimaNeural)',
ShortName: 'ar-AE-FatimaNeural',
Gender: 'Female',
Locale: 'ar-AE'
},
{
Name: 'Microsoft Server Speech Text to Speech Voice (ar-AE, HamdanNeural)',
ShortName: 'ar-AE-HamdanNeural',
Gender: 'Male',
Locale: 'ar-AE'
},
{
Name: 'Microsoft Server Speech Text to Speech Voice (ar-BH, AliNeural)',
ShortName: 'ar-BH-AliNeural',
Gender: 'Male',
Locale: 'ar-BH'
},
{
Name: 'Microsoft Server Speech Text to Speech Voice (ar-BH, LailaNeural)',
ShortName: 'ar-BH-LailaNeural',
Gender: 'Female',
Locale: 'ar-BH'
},
// ... (Many other Arabic locales/voices) ...
{
Name: 'Microsoft Server Speech Text to Speech Voice (ar-SA, ZariyahNeural)',
ShortName: 'ar-SA-ZariyahNeural',
Gender: 'Female',
Locale: 'ar-SA'
},
{
Name: 'Microsoft Server Speech Text to Speech Voice (az-AZ, BabekNeural)',
ShortName: 'az-AZ-BabekNeural',
Gender: 'Male',
Locale: 'az-AZ'
},
{
Name: 'Microsoft Server Speech Text to Speech Voice (az-AZ, BanuNeural)',
ShortName: 'az-AZ-BanuNeural',
Gender: 'Female',
Locale: 'az-AZ'
},
{
Name: 'Microsoft Server Speech Text to Speech Voice (bg-BG, BorislavNeural)',
ShortName: 'bg-BG-BorislavNeural',
Gender: 'Male',
Locale: 'bg-BG'
},
{
Name: 'Microsoft Server Speech Text to Speech Voice (bg-BG, KalinaNeural)',
ShortName: 'bg-BG-KalinaNeural',
Gender: 'Female',
Locale: 'bg-BG'
},
{
Name: 'Microsoft Server Speech Text to Speech Voice (bn-BD, NabanitaNeural)',
ShortName: 'bn-BD-NabanitaNeural',
Gender: 'Female',
Locale: 'bn-BD'
},
{
Name: 'Microsoft Server Speech Text to Speech Voice (bn-BD, PradeepNeural)',
ShortName: 'bn-BD-PradeepNeural',
Gender: 'Male',
Locale: 'bn-BD'
},
// ... (Catalan, Czech, Welsh, Danish, German, Greek, English variants) ...
{
Name: 'Microsoft Server Speech Text to Speech Voice (en-AU, NatashaNeural)',
ShortName: 'en-AU-NatashaNeural',
Gender: 'Female',
Locale: 'en-AU'
},
{
Name: 'Microsoft Server Speech Text to Speech Voice (en-AU, WilliamNeural)',
ShortName: 'en-AU-WilliamNeural',
Gender: 'Male',
Locale: 'en-AU'
},
{
Name: 'Microsoft Server Speech Text to Speech Voice (en-CA, ClaraNeural)',
ShortName: 'en-CA-ClaraNeural',
Gender: 'Female',
Locale: 'en-CA'
},
{
Name: 'Microsoft Server Speech Text to Speech Voice (en-CA, LiamNeural)',
ShortName: 'en-CA-LiamNeural',
Gender: 'Male',
Locale: 'en-CA'
},
{
Name: 'Microsoft Server Speech Text to Speech Voice (en-GB, LibbyNeural)',
ShortName: 'en-GB-LibbyNeural',
Gender: 'Female',
Locale: 'en-GB'
},
{
Name: 'Microsoft Server Speech Text to Speech Voice (en-GB, MaisieNeural)',
ShortName: 'en-GB-MaisieNeural',
Gender: 'Female',
Locale: 'en-GB'
},
{
Name: 'Microsoft Server Speech Text to Speech Voice (en-GB, RyanNeural)',
ShortName: 'en-GB-RyanNeural',
Gender: 'Male',
Locale: 'en-GB'
},
{
Name: 'Microsoft Server Speech Text to Speech Voice (en-GB, SoniaNeural)',
ShortName: 'en-GB-SoniaNeural',
Gender: 'Female',
Locale: 'en-GB'
},
{
Name: 'Microsoft Server Speech Text to Speech Voice (en-GB, ThomasNeural)',
ShortName: 'en-GB-ThomasNeural',
Gender: 'Male',
Locale: 'en-GB'
},
{
Name: 'Microsoft Server Speech Text to Speech Voice (en-HK, SamNeural)',
ShortName: 'en-HK-SamNeural',
Gender: 'Male',
Locale: 'en-HK'
},
{
Name: 'Microsoft Server Speech Text to Speech Voice (en-HK, YanNeural)',
ShortName: 'en-HK-YanNeural',
Gender: 'Female',
Locale: 'en-HK'
},
{
Name: 'Microsoft Server Speech Text to Speech Voice (en-IE, ConnorNeural)',
ShortName: 'en-IE-ConnorNeural',
Gender: 'Male',
Locale: 'en-IE'
},
{
Name: 'Microsoft Server Speech Text to Speech Voice (en-IE, EmilyNeural)',
ShortName: 'en-IE-EmilyNeural',
Gender: 'Female',
Locale: 'en-IE'
},
{
Name: 'Microsoft Server Speech Text to Speech Voice (en-IN, NeerjaNeural)',
ShortName: 'en-IN-NeerjaNeural',
Gender: 'Female',
Locale: 'en-IN'
},
{
Name: 'Microsoft Server Speech Text to Speech Voice (en-IN, PrabhatNeural)',
ShortName: 'en-IN-PrabhatNeural',
Gender: 'Male',
Locale: 'en-IN'
},
// ... (Many more English variants: KE, NG, NZ, PH, SG, TZ, US, ZA) ...
{
Name: 'Microsoft Server Speech Text to Speech Voice (en-US, AriaNeural)',
ShortName: 'en-US-AriaNeural',
Gender: 'Female',
Locale: 'en-US'
},
{
Name: 'Microsoft Server Speech Text to Speech Voice (en-US, AnaNeural)',
ShortName: 'en-US-AnaNeural',
Gender: 'Female',
Locale: 'en-US'
},
{
Name: 'Microsoft Server Speech Text to Speech Voice (en-US, ChristopherNeural)',
ShortName: 'en-US-ChristopherNeural',
Gender: 'Male',
Locale: 'en-US'
},
{
Name: 'Microsoft Server Speech Text to Speech Voice (en-US, EricNeural)',
ShortName: 'en-US-EricNeural',
Gender: 'Male',
Locale: 'en-US'
},
{
Name: 'Microsoft Server Speech Text to Speech Voice (en-US, GuyNeural)',
ShortName: 'en-US-GuyNeural',
Gender: 'Male',
Locale: 'en-US'
},
{
Name: 'Microsoft Server Speech Text to Speech Voice (en-US, JennyNeural)',
ShortName: 'en-US-JennyNeural',
Gender: 'Female',
Locale: 'en-US'
},
{
Name: 'Microsoft Server Speech Text to Speech Voice (en-US, MichelleNeural)',
ShortName: 'en-US-MichelleNeural',
Gender: 'Female',
Locale: 'en-US'
},
{
Name: 'Microsoft Server Speech Text to Speech Voice (en-US, RogerNeural)',
ShortName: 'en-US-RogerNeural',
Gender: 'Male',
Locale: 'en-US'
},
{
Name: 'Microsoft Server Speech Text to Speech Voice (en-US, SteffanNeural)',
ShortName: 'en-US-SteffanNeural',
Gender: 'Male',
Locale: 'en-US'
},
// ... (Spanish variants) ...
{
Name: 'Microsoft Server Speech Text to Speech Voice (es-MX, DaliaNeural)',
ShortName: 'es-MX-DaliaNeural',
Gender: 'Female',
Locale: 'es-MX'
},
{
Name: 'Microsoft Server Speech Text to Speech Voice (es-MX, JorgeNeural)',
ShortName: 'es-MX-JorgeNeural',
Gender: 'Male',
Locale: 'es-MX'
},
// ... (Estonian, Basque, Persian, Finnish, Filipino, French, Irish, Galician, Gujarati, Hebrew, Hindi, Croatian, Hungarian, Indonesian, Icelandic, Italian, Japanese) ...
{
Name: 'Microsoft Server Speech Text to Speech Voice (ja-JP, KeitaNeural)',
ShortName: 'ja-JP-KeitaNeural',
Gender: 'Male',
Locale: 'ja-JP'
},
{
Name: 'Microsoft Server Speech Text to Speech Voice (ja-JP, NanamiNeural)',
ShortName: 'ja-JP-NanamiNeural',
Gender: 'Female',
Locale: 'ja-JP'
},
// ... (Javanese, Georgian, Kazakh, Khmer, Kannada, Korean) ...
{
Name: 'Microsoft Server Speech Text to Speech Voice (ko-KR, InJoonNeural)',
ShortName: 'ko-KR-InJoonNeural',
Gender: 'Male',
Locale: 'ko-KR'
},
{
Name: 'Microsoft Server Speech Text to Speech Voice (ko-KR, SunHiNeural)',
ShortName: 'ko-KR-SunHiNeural',
Gender: 'Female',
Locale: 'ko-KR'
},
// ... (Lao, Lithuanian, Latvian, Macedonian, Malayalam, Mongolian, Marathi, Malay, Maltese, Burmese, Norwegian, Dutch, Polish, Pashto, Portuguese) ...
{
Name: 'Microsoft Server Speech Text to Speech Voice (pt-BR, AntonioNeural)',
ShortName: 'pt-BR-AntonioNeural',
Gender: 'Male',
Locale: 'pt-BR'
},
{
Name: 'Microsoft Server Speech Text to Speech Voice (pt-BR, FranciscaNeural)',
ShortName: 'pt-BR-FranciscaNeural',
Gender: 'Female',
Locale: 'pt-BR'
},
{
Name: 'Microsoft Server Speech Text to Speech Voice (pt-PT, DuarteNeural)',
ShortName: 'pt-PT-DuarteNeural',
Gender: 'Male',
Locale: 'pt-PT'
},
{
Name: 'Microsoft Server Speech Text to Speech Voice (pt-PT, RaquelNeural)',
ShortName: 'pt-PT-RaquelNeural',
Gender: 'Female',
Locale: 'pt-PT'
},
// ... (Romanian, Russian, Sinhala, Slovak, Slovenian, Somali, Albanian, Serbian, Sundanese, Swedish, Swahili, Tamil, Telugu, Thai) ...
{
Name: 'Microsoft Server Speech Text to Speech Voice (th-TH, NiwatNeural)',
ShortName: 'th-TH-NiwatNeural',
Gender: 'Male',
Locale: 'th-TH'
},
{
Name: 'Microsoft Server Speech Text to Speech Voice (th-TH, PremwadeeNeural)',
ShortName: 'th-TH-PremwadeeNeural',
Gender: 'Female',
Locale: 'th-TH'
},
// ... (Turkish, Ukrainian, Urdu, Uzbek, Vietnamese) ...
{
Name: 'Microsoft Server Speech Text to Speech Voice (vi-VN, HoaiMyNeural)',
ShortName: 'vi-VN-HoaiMyNeural',
Gender: 'Female',
Locale: 'vi-VN'
},
{
Name: 'Microsoft Server Speech Text to Speech Voice (vi-VN, NamMinhNeural)',
ShortName: 'vi-VN-NamMinhNeural',
Gender: 'Male',
Locale: 'vi-VN'
},
// ... (Chinese variants) ...
{
Name: 'Microsoft Server Speech Text to Speech Voice (zh-CN, XiaoxiaoNeural)',
ShortName: 'zh-CN-XiaoxiaoNeural',
Gender: 'Female',
Locale: 'zh-CN'
},
{
Name: 'Microsoft Server Speech Text to Speech Voice (zh-CN, YunxiNeural)',
ShortName: 'zh-CN-YunxiNeural',
Gender: 'Male',
Locale: 'zh-CN'
},
{
Name: 'Microsoft Server Speech Text to Speech Voice (zh-CN, YunjianNeural)',
ShortName: 'zh-CN-YunjianNeural',
Gender: 'Male',
Locale: 'zh-CN'
},
{
Name: 'Microsoft Server Speech Text to Speech Voice (zh-CN, YunxiaNeural)',
ShortName: 'zh-CN-YunxiaNeural',
Gender: 'Male',
Locale: 'zh-CN'
},
{
Name: 'Microsoft Server Speech Text to Speech Voice (zh-CN, YunyangNeural)',
ShortName: 'zh-CN-YunyangNeural',
Gender: 'Male',
Locale: 'zh-CN'
},
{
Name: 'Microsoft Server Speech Text to Speech Voice (zh-CN-liaoning, XiaobeiNeural)',
ShortName: 'zh-CN-liaoning-XiaobeiNeural',
Gender: 'Female',
Locale: 'zh-CN-liaoning'
},
// { Name: 'Microsoft Server Speech Text to Speech Voice (zh-CN-shaanxi, XiaoniNeural)', ShortName: 'zh-CN-shaanxi-XiaoniNeural', Gender: 'Female', Locale: 'zh-CN-shaanxi' }, // Example regional voice
{
Name: 'Microsoft Server Speech Text to Speech Voice (zh-HK, HiuGaaiNeural)',
ShortName: 'zh-HK-HiuGaaiNeural',
Gender: 'Female',
Locale: 'zh-HK'
},
{
Name: 'Microsoft Server Speech Text to Speech Voice (zh-HK, HiuMaanNeural)',
ShortName: 'zh-HK-HiuMaanNeural',
Gender: 'Female',
Locale: 'zh-HK'
},
{
Name: 'Microsoft Server Speech Text to Speech Voice (zh-HK, WanLungNeural)',
ShortName: 'zh-HK-WanLungNeural',
Gender: 'Male',
Locale: 'zh-HK'
},
{
Name: 'Microsoft Server Speech Text to Speech Voice (zh-TW, HsiaoChenNeural)',
ShortName: 'zh-TW-HsiaoChenNeural',
Gender: 'Female',
Locale: 'zh-TW'
},
{
Name: 'Microsoft Server Speech Text to Speech Voice (zh-TW, HsiaoYuNeural)',
ShortName: 'zh-TW-HsiaoYuNeural',
Gender: 'Female',
Locale: 'zh-TW'
},
{
Name: 'Microsoft Server Speech Text to Speech Voice (zh-TW, YunJheNeural)',
ShortName: 'zh-TW-YunJheNeural',
Gender: 'Male',
Locale: 'zh-TW'
},
// ... (Zulu) ...
{
Name: 'Microsoft Server Speech Text to Speech Voice (zu-ZA, ThandoNeural)',
ShortName: 'zu-ZA-ThandoNeural',
Gender: 'Female',
Locale: 'zu-ZA'
},
{
Name: 'Microsoft Server Speech Text to Speech Voice (zu-ZA, ThembaNeural)',
ShortName: 'zu-ZA-ThembaNeural',
Gender: 'Male',
Locale: 'zu-ZA'
}
]
// --- END OF HARDCODED VOICE LIST ---
/**
* 免费在线TTS服务
* 使用免费的在线TTS服务不需要API密钥
*/
class MsTTSService {
private static instance: MsTTSService
private tempDir: string
private constructor() {
this.tempDir = path.join(app.getPath('temp'), 'cherry-tts')
if (!fs.existsSync(this.tempDir)) {
fs.mkdirSync(this.tempDir, { recursive: true })
}
log.info('初始化免费在线TTS服务 (使用硬编码语音列表)')
}
public static getInstance(): MsTTSService {
if (!MsTTSService.instance) {
MsTTSService.instance = new MsTTSService()
}
return MsTTSService.instance
}
/**
* 流式合成语音
* @param text 要合成的文本
* @param voice 语音的 ShortName (例如 'zh-CN-XiaoxiaoNeural')
* @param outputFormat 输出格式 (例如 'audio-24khz-48kbitrate-mono-mp3')
* @param onData 数据块回调
* @param onEnd 结束回调
*/
public async synthesizeStream(
text: string,
voice: string,
outputFormat: string,
onData: (chunk: Uint8Array) => void,
onEnd: () => void
): Promise<void> {
try {
// 记录详细的请求信息
log.info(`流式微软在线TTS合成语音: 文本="${text.substring(0, 30)}...", 语音=${voice}, 格式=${outputFormat}`)
// 验证输入参数
if (!text || text.trim() === '') {
throw new Error('要合成的文本不能为空')
}
if (!voice || voice.trim() === '') {
throw new Error('语音名称不能为空')
}
// 创建一个新的MsEdgeTTS实例
const tts = new MsEdgeTTS({
enableLogger: false // 禁用内部日志
})
// 设置元数据
let msOutputFormat: OUTPUT_FORMAT
if (outputFormat.includes('mp3')) {
msOutputFormat = OUTPUT_FORMAT.AUDIO_24KHZ_48KBITRATE_MONO_MP3
} else if (outputFormat.includes('webm')) {
msOutputFormat = OUTPUT_FORMAT.WEBM_24KHZ_16BIT_MONO_OPUS
} else {
msOutputFormat = OUTPUT_FORMAT.AUDIO_24KHZ_48KBITRATE_MONO_MP3
}
await tts.setMetadata(voice, msOutputFormat)
// 创建流
const audioStream = tts.toStream(text)
// 监听数据事件
audioStream.on('data', (data: Buffer) => {
onData(data)
})
// 监听结束事件
audioStream.on('end', () => {
log.info(`流式微软在线TTS合成成功`)
onEnd()
})
// 监听错误事件
audioStream.on('error', (error: Error) => {
log.error(`流式微软在线TTS语音合成失败:`, error)
throw error
})
} catch (error: any) {
// 记录详细的错误信息
log.error(`流式微软在线TTS语音合成失败 (语音=${voice}):`, error)
throw error
}
}
/**
* 获取可用的语音列表 (返回硬编码列表)
* @returns 语音列表
*/
public async getVoices(): Promise<any[]> {
try {
log.info(`返回硬编码的 ${hardcodedVoices.length} 个语音列表`)
// 直接返回硬编码的列表
// 注意:保持 async 是为了接口兼容性,虽然这里没有实际的异步操作
return hardcodedVoices
} catch (error) {
// 这个 try/catch 在这里意义不大了,因为返回静态数据不会出错
// 但保留结构以防未来改动
log.error('获取硬编码语音列表时出错 (理论上不应发生):', error)
return [] // 返回空列表以防万一
}
}
/**
* 合成语音
* @param text 要合成的文本
* @param voice 语音的 ShortName (例如 'zh-CN-XiaoxiaoNeural')
* @param outputFormat 输出格式 (例如 'audio-24khz-48kbitrate-mono-mp3')
* @returns 音频文件路径
*/
public async synthesize(text: string, voice: string, outputFormat: string): Promise<string> {
try {
// 记录详细的请求信息
log.info(`微软在线TTS合成语音: 文本="${text.substring(0, 30)}...", 语音=${voice}, 格式=${outputFormat}`)
// 验证输入参数
if (!text || text.trim() === '') {
throw new Error('要合成的文本不能为空')
}
if (!voice || voice.trim() === '') {
throw new Error('语音名称不能为空')
}
// 创建一个新的EdgeTTS实例并设置参数
// 添加超时设置默认为30秒
const tts = new EdgeTTS({
voice: voice,
outputFormat: outputFormat,
timeout: 30000, // 30秒超时
rate: '+0%', // 正常语速
pitch: '+0Hz', // 正常音调
volume: '+0%' // 正常音量
})
// 生成临时文件路径
const timestamp = Date.now()
const fileExtension = outputFormat.includes('mp3') ? 'mp3' : outputFormat.split('-').pop() || 'audio'
const outputPath = path.join(this.tempDir, `tts_${timestamp}.${fileExtension}`)
log.info(`开始生成语音文件: ${outputPath}`)
// 使用ttsPromise方法生成文件
await tts.ttsPromise(text, outputPath)
// 验证生成的文件是否存在且大小大于0
if (!fs.existsSync(outputPath)) {
throw new Error(`生成的语音文件不存在: ${outputPath}`)
}
const stats = fs.statSync(outputPath)
if (stats.size === 0) {
throw new Error(`生成的语音文件大小为0: ${outputPath}`)
}
log.info(`微软在线TTS合成成功: ${outputPath}, 文件大小: ${stats.size} 字节`)
return outputPath
} catch (error: any) {
// 记录详细的错误信息
log.error(`微软在线TTS语音合成失败 (语音=${voice}):`, error)
// 尝试提供更有用的错误信息
if (error.message && typeof error.message === 'string') {
if (error.message.includes('Timed out')) {
throw new Error(`语音合成超时,请检查网络连接或尝试其他语音`)
} else if (error.message.includes('ENOTFOUND')) {
throw new Error(`无法连接到微软语音服务,请检查网络连接`)
} else if (error.message.includes('ECONNREFUSED')) {
throw new Error(`连接被拒绝,请检查网络设置或代理配置`)
}
}
throw error
}
}
/**
* (可选) 清理临时文件目录
*/
public async cleanupTempDir(): Promise<void> {
// (Cleanup method remains the same)
try {
const files = await fs.promises.readdir(this.tempDir)
for (const file of files) {
if (file.startsWith('tts_')) {
await fs.promises.unlink(path.join(this.tempDir, file))
}
}
log.info('TTS 临时文件已清理')
} catch (error) {
log.error('清理 TTS 临时文件失败:', error)
}
}
}
// 导出单例方法 (保持不变)
export const getVoices = async () => {
return await MsTTSService.getInstance().getVoices()
}
export const synthesize = async (text: string, voice: string, outputFormat: string) => {
return await MsTTSService.getInstance().synthesize(text, voice, outputFormat)
}
export const synthesizeStream = async (
text: string,
voice: string,
outputFormat: string,
onData: (chunk: Uint8Array) => void,
onEnd: () => void
) => {
return await MsTTSService.getInstance().synthesizeStream(text, voice, outputFormat, onData, onEnd)
}
export const cleanupTtsTempFiles = async () => {
await MsTTSService.getInstance().cleanupTempDir()
}

View File

@@ -1,6 +1,5 @@
import { ProxyConfig as _ProxyConfig, session } from 'electron'
import { socksDispatcher } from 'fetch-socks'
import { getSystemProxy } from 'os-proxy-config'
import { ProxyAgent as GeneralProxyAgent } from 'proxy-agent'
import { ProxyAgent, setGlobalDispatcher } from 'undici'
@@ -71,14 +70,15 @@ export class ProxyManager {
private async setSystemProxy(): Promise<void> {
try {
const currentProxy = await getSystemProxy()
if (!currentProxy || currentProxy.proxyUrl === this.config.url) {
return
}
await this.setSessionsProxy({ mode: 'system' })
this.config.url = currentProxy.proxyUrl.toLowerCase()
this.setEnvironment(this.config.url)
this.proxyAgent = new GeneralProxyAgent()
const proxyString = await session.defaultSession.resolveProxy('https://dummy.com')
const [protocol, address] = proxyString.split(';')[0].split(' ')
const url = protocol === 'PROXY' ? `http://${address}` : null
if (url && url !== this.config.url) {
this.config.url = url.toLowerCase()
this.setEnvironment(this.config.url)
this.proxyAgent = new GeneralProxyAgent()
}
} catch (error) {
console.error('Failed to set system proxy:', error)
throw error

View File

@@ -4,7 +4,8 @@ import path from 'node:path'
import { app } from 'electron'
export function getResourcePath() {
return path.join(app.getAppPath(), 'resources')
// 在打包环境中使用process.resourcesPath否则使用app.getAppPath()/resources
return app.isPackaged ? process.resourcesPath : path.join(app.getAppPath(), 'resources')
}
export function getDataPath() {

View File

@@ -33,9 +33,6 @@ declare global {
setAutoUpdate: (isActive: boolean) => void
reload: () => void
clearCache: () => Promise<{ success: boolean; error?: string }>
sentry: {
init: () => Promise<void>
}
system: {
getDeviceType: () => Promise<'mac' | 'windows' | 'linux'>
getHostname: () => Promise<string>

View File

@@ -23,9 +23,6 @@ const api = {
setAutoUpdate: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetAutoUpdate, isActive),
openWebsite: (url: string) => ipcRenderer.invoke(IpcChannel.Open_Website, url),
clearCache: () => ipcRenderer.invoke(IpcChannel.App_ClearCache),
sentry: {
init: () => ipcRenderer.invoke(IpcChannel.Sentry_Init)
},
system: {
getDeviceType: () => ipcRenderer.invoke(IpcChannel.System_GetDeviceType),
getHostname: () => ipcRenderer.invoke(IpcChannel.System_GetHostname)
@@ -72,7 +69,7 @@ const api = {
binaryFile: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_BinaryFile, fileId)
},
fs: {
read: (path: string) => ipcRenderer.invoke(IpcChannel.Fs_Read, path)
read: (path: string, encoding?: BufferEncoding) => ipcRenderer.invoke(IpcChannel.Fs_Read, path, encoding)
},
export: {
toWord: (markdown: string, fileName: string) => ipcRenderer.invoke(IpcChannel.Export_Word, markdown, fileName)
@@ -127,6 +124,11 @@ const api = {
toggle: () => ipcRenderer.invoke(IpcChannel.MiniWindow_Toggle),
setPin: (isPinned: boolean) => ipcRenderer.invoke(IpcChannel.MiniWindow_SetPin, isPinned)
},
msTTS: {
getVoices: () => ipcRenderer.invoke(IpcChannel.MsTTS_GetVoices),
synthesize: (text: string, voice: string, outputFormat: string) =>
ipcRenderer.invoke(IpcChannel.MsTTS_Synthesize, text, voice, outputFormat)
},
aes: {
encrypt: (text: string, secretKey: string, iv: string) =>
ipcRenderer.invoke(IpcChannel.Aes_Encrypt, text, secretKey, iv),
@@ -188,6 +190,10 @@ const api = {
openSearchWindow: (uid: string) => ipcRenderer.invoke(IpcChannel.SearchWindow_Open, uid),
closeSearchWindow: (uid: string) => ipcRenderer.invoke(IpcChannel.SearchWindow_Close, uid),
openUrlInSearchWindow: (uid: string, url: string) => ipcRenderer.invoke(IpcChannel.SearchWindow_OpenUrl, uid, url)
},
asrServer: {
startServer: () => ipcRenderer.invoke(IpcChannel.Asr_StartServer),
stopServer: (pid: number) => ipcRenderer.invoke(IpcChannel.Asr_StopServer, pid)
}
}

View File

@@ -1,42 +1,43 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="initial-scale=1, width=device-width" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; connect-src blob: *; script-src 'self' 'unsafe-eval' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: * blob:; frame-src * file:" />
<title>Cherry Studio</title>
<style>
html,
body {
margin: 0;
}
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="initial-scale=1, width=device-width" />
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; connect-src blob: *; script-src 'self' 'unsafe-eval' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: * blob:; media-src blob: *; frame-src * file:" />
<title>Cherry Studio</title>
#spinner {
position: fixed;
width: 100vw;
height: 100vh;
flex-direction: row;
justify-content: center;
align-items: center;
display: none;
}
<style>
html,
body {
margin: 0;
}
#spinner img {
width: 100px;
border-radius: 50px;
}
</style>
</head>
#spinner {
position: fixed;
width: 100vw;
height: 100vh;
flex-direction: row;
justify-content: center;
align-items: center;
display: none;
}
<body>
<div id="root"></div>
<div id="spinner">
<img src="/src/assets/images/logo.png" />
</div>
<script type="module" src="/src/init.ts"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
#spinner img {
width: 100px;
border-radius: 50px;
}
</style>
</head>
<body>
<div id="root"></div>
<div id="spinner">
<img src="/src/assets/images/logo.png" />
</div>
<script type="module" src="/src/init.ts"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,395 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Browser 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>浏览器语音识别中继页面</h1>
<p>这个页面需要在浏览器中保持打开,以便应用使用其语音识别功能。</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 if (data.type === 'reset') {
// 强制重置语音识别
forceResetRecognition();
} 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("识别未运行。");
}
}
function forceResetRecognition() {
console.log('[Browser Page] Force resetting recognition...');
updateStatus("强制重置语音识别...");
// 先尝试停止当前的识别
if (recognition) {
try {
recognition.stop();
} catch (e) {
console.error('[Browser Page] Error stopping recognition during reset:', e);
}
}
// 强制设置为null丢弃所有后续结果
recognition = null;
// 通知服务器已重置
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'status', message: 'reset_complete' }));
}
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,179 @@
const http = require('http')
const WebSocket = require('ws')
const express = require('express')
const path = require('path') // Need path module
const app = express()
const port = 34515 // 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')
}
// 提供网页给浏览器
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 === 'reset' && ws === electronConnection) {
if (browserConnection && browserConnection.readyState === WebSocket.OPEN) {
console.log('[Server] Relaying RESET command to browser')
browserConnection.send(JSON.stringify({ type: 'reset' }))
} else {
console.log('[Server] Cannot relay RESET: 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

@@ -16,40 +16,3 @@
--pulse-size: 8px;
animation: animation-pulse 1.5s infinite;
}
// Modal动画
@keyframes animation-move-down-in {
0% {
transform: translate3d(0, 100%, 0);
transform-origin: 0 0;
opacity: 0;
}
100% {
transform: translate3d(0, 0, 0);
transform-origin: 0 0;
opacity: 1;
}
}
@keyframes animation-move-down-out {
0% {
transform: translate3d(0, 0, 0);
transform-origin: 0 0;
opacity: 1;
}
100% {
transform: translate3d(0, 100%, 0);
transform-origin: 0 0;
opacity: 0;
}
}
.animation-move-down-enter,
.animation-move-down-appear {
animation-name: animation-move-down-in;
animation-fill-mode: both;
animation-duration: 0.25s;
}
.animation-move-down-leave {
animation-name: animation-move-down-out;
animation-fill-mode: both;
animation-duration: 0.25s;
}

View File

@@ -0,0 +1,243 @@
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, isFinal?: boolean) => 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;
border: none; /* 移除边框 */
&.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

@@ -0,0 +1,621 @@
import {
AudioMutedOutlined,
AudioOutlined,
CloseOutlined,
DownOutlined,
DragOutlined,
PauseCircleOutlined,
PlayCircleOutlined,
SettingOutlined,
SoundOutlined,
UpOutlined
} from '@ant-design/icons'
import { Button, Space, Tooltip } from 'antd'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useDispatch } from 'react-redux'
import styled from 'styled-components'
import { VoiceCallService } from '../services/VoiceCallService'
import { setIsVoiceCallActive, setLastPlayedMessageId, setSkipNextAutoTTS } from '../store/settings'
import VoiceVisualizer from './VoiceVisualizer'
interface Props {
visible: boolean
onClose: () => void
position?: { x: number; y: number }
onPositionChange?: (position: { x: number; y: number }) => void
}
// --- 样式组件 ---
const Container = styled.div`
width: 300px;
background-color: var(--color-background);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
overflow: hidden;
display: flex;
flex-direction: column;
transform-origin: top left;
will-change: transform;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
cursor: default;
`
const Header = styled.div`
padding: 8px 12px;
background-color: var(--color-primary);
color: white;
font-weight: bold;
display: flex;
align-items: center;
cursor: move;
user-select: none;
position: relative;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background-color: rgba(255, 255, 255, 0.2);
}
&:hover::before {
background-color: rgba(255, 255, 255, 0.4);
}
.drag-icon {
margin-right: 8px; // DragOutlined 的样式
}
.settings-button {
margin-left: auto; // 推到最右边
color: white; // 设置按钮颜色
}
`
const CloseButton = styled.div`
margin-left: 8px; // 与设置按钮保持间距
cursor: pointer;
`
const Content = styled.div`
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px;
`
const VisualizerContainer = styled.div`
display: flex;
justify-content: space-between;
height: 60px;
`
const TranscriptContainer = styled.div`
flex: 1;
min-height: 60px;
max-height: 100px;
overflow-y: auto;
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 8px;
background-color: var(--color-background-2);
`
const TranscriptText = styled.div`
margin-bottom: 8px;
`
const UserLabel = styled.span`
font-weight: bold;
color: var(--color-primary);
`
const ControlsContainer = styled.div`
display: flex;
justify-content: center;
padding: 8px 0;
`
const RecordButton = styled(Button)`
min-width: 120px;
`
// 设置面板的样式
const SettingsPanel = styled.div`
margin-bottom: 10px;
padding: 10px;
border: 1px solid var(--color-border);
border-radius: 8px;
`
const SettingsTitle = styled.div`
margin-bottom: 8px;
`
const ShortcutKeyButton = styled(Button)`
min-width: 120px;
`
const SettingsTip = styled.div`
margin-top: 8px;
font-size: 12px;
color: var(--color-text-secondary);
`
// --- 样式组件结束 ---
const DraggableVoiceCallWindow: React.FC<Props> = ({
visible,
onClose,
position = { x: 20, y: 20 },
onPositionChange
}) => {
const { t } = useTranslation()
const dispatch = useDispatch()
const [isDragging, setIsDragging] = useState(false)
const [currentPosition, setCurrentPosition] = useState(position)
const dragStartRef = useRef<{ startX: number; startY: number; initialX: number; initialY: number } | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
// --- 语音通话状态 ---
const [transcript, setTranscript] = useState('')
const [isListening, setIsListening] = useState(false)
const [isSpeaking, setIsSpeaking] = useState(false)
const [isRecording, setIsRecording] = useState(false)
const [isProcessing, setIsProcessing] = useState(false)
const [isPaused, setIsPaused] = useState(false)
const [isMuted, setIsMuted] = useState(false)
// --- 语音通话状态结束 ---
// --- 快捷键相关状态 ---
const [shortcutKey, setShortcutKey] = useState('Space')
const [isShortcutPressed, setIsShortcutPressed] = useState(false)
const [isSettingsVisible, setIsSettingsVisible] = useState(false)
const [tempShortcutKey, setTempShortcutKey] = useState(shortcutKey)
const [isRecordingShortcut, setIsRecordingShortcut] = useState(false)
const [isCollapsed, setIsCollapsed] = useState(false)
// --- 快捷键相关状态结束 ---
const isInitializedRef = useRef(false)
// --- 拖拽逻辑 ---
const handleDragStart = useCallback(
(e: React.MouseEvent) => {
if ((e.target as HTMLElement).closest('button, input, a')) {
return
}
e.preventDefault()
setIsDragging(true)
dragStartRef.current = {
startX: e.clientX,
startY: e.clientY,
initialX: currentPosition.x,
initialY: currentPosition.y
}
},
[currentPosition]
)
const handleDrag = useCallback(
(e: MouseEvent) => {
if (!isDragging || !dragStartRef.current) return
e.preventDefault()
const deltaX = e.clientX - dragStartRef.current.startX
const deltaY = e.clientY - dragStartRef.current.startY
let newX = dragStartRef.current.initialX + deltaX
let newY = dragStartRef.current.initialY + deltaY
const windowWidth = window.innerWidth
const windowHeight = window.innerHeight
const containerWidth = containerRef.current?.offsetWidth || 300
const containerHeight = containerRef.current?.offsetHeight || 300
newX = Math.max(0, Math.min(newX, windowWidth - containerWidth))
newY = Math.max(0, Math.min(newY, windowHeight - containerHeight))
const newPosition = { x: newX, y: newY }
setCurrentPosition(newPosition)
onPositionChange?.(newPosition)
},
[isDragging, onPositionChange]
)
const handleDragEnd = useCallback(
(e: MouseEvent) => {
if (isDragging) {
e.preventDefault()
setIsDragging(false)
dragStartRef.current = null
}
},
[isDragging] // 移除了 currentPosition 依赖,因为它只在 handleDragStart 中读取一次
)
const throttle = useMemo(() => {
let lastCall = 0
const delay = 16 // ~60fps
return (func: (e: MouseEvent) => void) => {
return (e: MouseEvent) => {
const now = new Date().getTime()
if (now - lastCall < delay) {
return
}
lastCall = now
func(e)
}
}
}, [])
const throttledHandleDrag = useMemo(() => throttle(handleDrag), [handleDrag, throttle])
useEffect(() => {
if (isDragging) {
document.addEventListener('mousemove', throttledHandleDrag)
document.addEventListener('mouseup', handleDragEnd)
document.body.style.cursor = 'move'
} else {
document.removeEventListener('mousemove', throttledHandleDrag)
document.removeEventListener('mouseup', handleDragEnd)
document.body.style.cursor = 'default'
}
return () => {
document.removeEventListener('mousemove', throttledHandleDrag)
document.removeEventListener('mouseup', handleDragEnd)
document.body.style.cursor = 'default'
}
}, [isDragging, throttledHandleDrag, handleDragEnd])
// --- 拖拽逻辑结束 ---
// --- 状态和副作用管理 ---
useEffect(() => {
const handleTTSStateChange = (event: CustomEvent) => {
const { isPlaying } = event.detail
setIsSpeaking(isPlaying)
}
const startVoiceCall = async () => {
try {
window.message.loading({ content: t('voice_call.initializing'), key: 'voice-call-init' })
try {
await VoiceCallService.initialize()
} catch (initError) {
console.warn('语音识别服务初始化警告:', initError)
}
await VoiceCallService.startCall({
onTranscript: setTranscript,
onResponse: () => {
/* 响应在聊天界面处理 */
},
onListeningStateChange: setIsListening,
onSpeakingStateChange: setIsSpeaking
})
window.message.success({ content: t('voice_call.ready'), key: 'voice-call-init' })
isInitializedRef.current = true
} catch (error) {
console.error('Voice call error:', error)
window.message.error({ content: t('voice_call.error'), key: 'voice-call-init' })
onClose()
}
}
if (visible) {
dispatch(setIsVoiceCallActive(true))
dispatch(setLastPlayedMessageId(null))
dispatch(setSkipNextAutoTTS(true))
if (!isInitializedRef.current) {
startVoiceCall()
}
window.addEventListener('tts-state-change', handleTTSStateChange as EventListener)
} else if (!visible && isInitializedRef.current) {
dispatch(setIsVoiceCallActive(false))
dispatch(setSkipNextAutoTTS(false))
VoiceCallService.endCall()
setTranscript('')
setIsListening(false)
setIsSpeaking(false)
setIsRecording(false)
setIsProcessing(false)
setIsPaused(false)
setIsMuted(false)
isInitializedRef.current = false
window.removeEventListener('tts-state-change', handleTTSStateChange as EventListener)
}
return () => {
window.removeEventListener('tts-state-change', handleTTSStateChange as EventListener)
}
}, [visible, dispatch, t, onClose])
// --- 状态和副作用管理结束 ---
// --- 语音通话控制函数 ---
const toggleMute = useCallback(() => {
const newMuteState = !isMuted
setIsMuted(newMuteState)
VoiceCallService.setMuted(newMuteState)
}, [isMuted]) // 添加依赖
const togglePause = useCallback(() => {
const newPauseState = !isPaused
setIsPaused(newPauseState)
VoiceCallService.setPaused(newPauseState)
}, [isPaused]) // 添加依赖
// !! 将这些函数定义移到 handleKeyDown/handleKeyUp 之前 !!
const handleRecordStart = useCallback(
async (e: React.MouseEvent | React.TouchEvent | KeyboardEvent) => {
e.preventDefault()
if (isProcessing || isPaused) return
setTranscript('')
VoiceCallService.stopTTS()
setIsSpeaking(false)
setIsRecording(true)
setIsProcessing(true)
try {
await VoiceCallService.startRecording()
setIsProcessing(false)
} catch (error) {
window.message.error({ content: '启动语音识别失败,请确保语音识别服务已启动', key: 'voice-call-error' })
setIsRecording(false)
setIsProcessing(false)
}
},
[isProcessing, isPaused]
)
const handleRecordEnd = useCallback(
async (e: React.MouseEvent | React.TouchEvent | KeyboardEvent) => {
e.preventDefault()
if (!isRecording) return
setIsRecording(false)
setIsProcessing(true)
VoiceCallService.stopTTS()
setIsSpeaking(false)
try {
const success = await VoiceCallService.stopRecordingAndSendToChat()
if (success) {
window.message.success({ content: '语音识别已完成,正在发送消息...', key: 'voice-call-send' })
} else {
window.message.error({ content: '发送语音识别结果失败', key: 'voice-call-error' })
}
} catch (error) {
window.message.error({ content: '停止录音出错', key: 'voice-call-error' })
} finally {
setTimeout(() => setIsProcessing(false), 500)
}
},
[isRecording]
)
const handleRecordCancel = useCallback(
async (e: React.MouseEvent | React.TouchEvent | KeyboardEvent) => {
e.preventDefault()
if (isRecording) {
setIsRecording(false)
setIsProcessing(true)
VoiceCallService.stopTTS()
setIsSpeaking(false)
try {
await VoiceCallService.cancelRecording()
setTranscript('')
} catch (error) {
console.error('取消录音出错:', error)
} finally {
setTimeout(() => setIsProcessing(false), 500)
}
}
},
[isRecording]
)
// --- 语音通话控制函数结束 ---
// --- 快捷键相关函数 ---
const getKeyDisplayName = (keyCode: string) => {
const keyMap: Record<string, string> = {
Space: '空格键',
Enter: '回车键',
ShiftLeft: '左Shift键',
ShiftRight: '右Shift键',
ControlLeft: '左Ctrl键',
ControlRight: '右Ctrl键',
AltLeft: '左Alt键',
AltRight: '右Alt键'
}
return keyMap[keyCode] || keyCode
}
const handleShortcutKeyChange = useCallback(
(e: KeyboardEvent) => {
e.preventDefault()
if (isRecordingShortcut) {
setTempShortcutKey(e.code)
setIsRecordingShortcut(false)
}
},
[isRecordingShortcut]
)
const saveShortcutKey = useCallback(() => {
setShortcutKey(tempShortcutKey)
localStorage.setItem('voiceCallShortcutKey', tempShortcutKey)
setIsSettingsVisible(false)
}, [tempShortcutKey])
// 现在可以安全地使用 handleRecordStart/End
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (isRecordingShortcut) {
handleShortcutKeyChange(e)
return
}
if (e.code === shortcutKey && !isProcessing && !isPaused && visible && !isShortcutPressed) {
e.preventDefault()
setIsShortcutPressed(true)
const mockEvent = new MouseEvent('mousedown') as unknown as React.MouseEvent // 类型断言
handleRecordStart(mockEvent) // 现在 handleRecordStart 已经定义
}
},
[
shortcutKey,
isProcessing,
isPaused,
visible,
isShortcutPressed,
handleRecordStart, // 依赖项
isRecordingShortcut,
handleShortcutKeyChange
]
)
const handleKeyUp = useCallback(
(e: KeyboardEvent) => {
if (e.code === shortcutKey && isShortcutPressed && visible) {
e.preventDefault()
setIsShortcutPressed(false)
const mockEvent = new MouseEvent('mouseup') as unknown as React.MouseEvent // 类型断言
handleRecordEnd(mockEvent) // 现在 handleRecordEnd 已经定义
}
},
[shortcutKey, isShortcutPressed, visible, handleRecordEnd]
) // 依赖项
useEffect(() => {
const savedShortcut = localStorage.getItem('voiceCallShortcutKey')
if (savedShortcut) {
setShortcutKey(savedShortcut)
setTempShortcutKey(savedShortcut)
}
}, [])
useEffect(() => {
if (visible) {
window.addEventListener('keydown', handleKeyDown)
window.addEventListener('keyup', handleKeyUp)
}
return () => {
window.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('keyup', handleKeyUp)
}
}, [visible, handleKeyDown, handleKeyUp])
// --- 快捷键相关函数结束 ---
// 如果不可见,直接返回 null
if (!visible) return null
// --- JSX 渲染 ---
return (
<Container
ref={containerRef}
style={{
transform: `translate(${currentPosition.x}px, ${currentPosition.y}px)` // 使用 transform 定位
}}>
{/* 将 onMouseDown 移到 Header 上 */}
<Header onMouseDown={handleDragStart}>
<DragOutlined className="drag-icon" /> {/* 应用样式类 */}
{t('voice_call.title')}
<Button
type="text"
icon={isCollapsed ? <DownOutlined /> : <UpOutlined />}
onClick={() => setIsCollapsed(!isCollapsed)}
className="settings-button"
/>
<Button
type="text"
icon={<SettingOutlined />}
onClick={() => setIsSettingsVisible(!isSettingsVisible)}
className="settings-button" // 应用样式类
/>
<CloseButton onClick={onClose}>
<CloseOutlined />
</CloseButton>
</Header>
<Content>
{!isCollapsed && (
<>
{isSettingsVisible && (
<SettingsPanel>
{' '}
{/* 使用 styled-component */}
<SettingsTitle>{t('voice_call.shortcut_key_setting')}</SettingsTitle> {/* 使用 styled-component */}
<Space>
<ShortcutKeyButton onClick={() => setIsRecordingShortcut(true)}>
{' '}
{/* 使用 styled-component */}
{isRecordingShortcut ? t('voice_call.press_any_key') : getKeyDisplayName(tempShortcutKey)}
</ShortcutKeyButton>
<Button type="primary" onClick={saveShortcutKey}>
{t('voice_call.save')}
</Button>
<Button onClick={() => setIsSettingsVisible(false)}>{t('voice_call.cancel')}</Button>
</Space>
<SettingsTip>
{' '}
{/* 使用 styled-component */}
{t('voice_call.shortcut_key_tip')}
</SettingsTip>
</SettingsPanel>
)}
<VisualizerContainer>
<VoiceVisualizer isActive={isListening || isRecording} type="input" />
<VoiceVisualizer isActive={isSpeaking} type="output" />
</VisualizerContainer>
<TranscriptContainer>
{transcript && (
<TranscriptText>
<UserLabel>{t('voice_call.you')}:</UserLabel> {transcript}
</TranscriptText>
)}
{/* 可以在这里添加 AI 回复的显示 */}
</TranscriptContainer>
</>
)}
<ControlsContainer>
<Space>
<Button
type="text"
icon={isMuted ? <AudioMutedOutlined /> : <AudioOutlined />}
onClick={toggleMute}
size="large"
title={isMuted ? t('voice_call.unmute') : t('voice_call.mute')}
/>
<Button
type="text"
icon={isPaused ? <PlayCircleOutlined /> : <PauseCircleOutlined />}
onClick={togglePause}
size="large"
title={isPaused ? t('voice_call.resume') : t('voice_call.pause')}
/>
<Tooltip title={`${t('voice_call.press_to_talk')} (${getKeyDisplayName(shortcutKey)})`}>
<RecordButton
type={isRecording ? 'primary' : 'default'}
icon={<SoundOutlined />}
onMouseDown={handleRecordStart}
onMouseUp={handleRecordEnd}
onMouseLeave={handleRecordCancel}
onTouchStart={handleRecordStart}
onTouchEnd={handleRecordEnd}
onTouchCancel={handleRecordCancel}
size="large"
disabled={isProcessing || isPaused}>
{isRecording ? t('voice_call.release_to_send') : t('voice_call.press_to_talk')}
</RecordButton>
</Tooltip>
</Space>
</ControlsContainer>
</Content>
</Container>
)
}
export default DraggableVoiceCallWindow

View File

@@ -12,7 +12,6 @@ import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
import { useBridge } from '@renderer/hooks/useBridge'
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
import { useMinapps } from '@renderer/hooks/useMinapps'
import useNavBackgroundColor from '@renderer/hooks/useNavBackgroundColor'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { MinAppType } from '@renderer/types'
import { delay } from '@renderer/utils'
@@ -39,7 +38,6 @@ const MinappPopupContainer: React.FC = () => {
const { closeMinapp, hideMinappPopup } = useMinappPopup()
const { pinned, updatePinnedMinapps } = useMinapps()
const { t } = useTranslation()
const backgroundColor = useNavBackgroundColor()
/** control the drawer open or close */
const [isPopupShow, setIsPopupShow] = useState(true)
@@ -238,7 +236,7 @@ const MinappPopupContainer: React.FC = () => {
}
return (
<TitleContainer style={{ backgroundColor: backgroundColor, justifyContent: 'space-between' }}>
<TitleContainer style={{ justifyContent: 'space-between' }}>
<Tooltip
title={
<TitleTextTooltip>

View File

@@ -60,9 +60,6 @@ const WebviewContainer = memo(
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appid, url])
//remove the tag of CherryStudio and Electron
const userAgent = navigator.userAgent.replace(/CherryStudio\/\S+\s/, '').replace(/Electron\/\S+\s/, '')
return (
<webview
key={appid}
@@ -70,7 +67,6 @@ const WebviewContainer = memo(
style={WebviewStyle}
allowpopups={'true' as any}
partition="persist:webview"
useragent={userAgent}
/>
)
}

View File

@@ -57,8 +57,6 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
BackupPopup.hide = onCancel
const isDisabled = progressData ? progressData.stage !== 'completed' : false
return (
<Modal
title={t('backup.title')}
@@ -66,10 +64,8 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
onOk={onOk}
onCancel={onCancel}
afterClose={onClose}
okButtonProps={{ disabled: isDisabled }}
cancelButtonProps={{ disabled: isDisabled }}
transitionName="ant-move-down"
okText={t('backup.confirm.button')}
maskClosable={false}
centered>
{!progressData && <div>{t('backup.content')}</div>}
{progressData && (

View File

@@ -26,7 +26,7 @@ const PopupContainer: React.FC<Props> = ({ resolve, fs }) => {
<Modal
open={open}
title={t('settings.data.nutstore.pathSelector.title')}
transitionName="animation-move-down"
transitionName="ant-move-down"
afterClose={onClose}
onCancel={onClose}
footer={null}

View File

@@ -57,8 +57,6 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
RestorePopup.hide = onCancel
const isDisabled = progressData ? progressData.stage !== 'completed' : false
return (
<Modal
title={t('restore.title')}
@@ -66,10 +64,8 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
onOk={onOk}
onCancel={onCancel}
afterClose={onClose}
transitionName="ant-move-down"
okText={t('restore.confirm.button')}
okButtonProps={{ disabled: isDisabled }}
cancelButtonProps={{ disabled: isDisabled }}
maskClosable={false}
centered>
{!progressData && <div>{t('restore.content')}</div>}
{progressData && (

View File

@@ -33,7 +33,7 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
afterClose={onClose}
title={null}
width="920px"
transitionName="animation-move-down"
transitionName="ant-move-down"
styles={{
content: {
padding: 0,

View File

@@ -368,7 +368,7 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
onCancel={onCancel}
afterClose={onClose}
width={600}
transitionName="animation-move-down"
transitionName="ant-move-down"
styles={{
content: {
borderRadius: 20,

View File

@@ -35,7 +35,7 @@ const PopupContainer: React.FC<Props> = ({ title, resolve }) => {
onOk={onOk}
onCancel={onCancel}
afterClose={onClose}
transitionName="animation-move-down"
transitionName="ant-move-down"
centered>
<Box mb={8}>Name</Box>
</Modal>

View File

@@ -69,7 +69,7 @@ const PopupContainer: React.FC<Props> = ({ text, textareaProps, modalProps, reso
title={t('common.edit')}
width="60vw"
style={{ maxHeight: '70vh' }}
transitionName="animation-move-down"
transitionName="ant-move-down"
okText={t('common.save')}
{...modalProps}
open={open}

View File

@@ -127,7 +127,7 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
onOk={onOk}
onCancel={onCancel}
afterClose={onClose}
transitionName="animation-move-down"
transitionName="ant-move-down"
centered>
<Center mt="30px">
<VStack alignItems="center" gap="10px">

View File

@@ -0,0 +1,142 @@
import { SoundOutlined } from '@ant-design/icons'
import TTSService from '@renderer/services/TTSService'
import { Message } from '@renderer/types'
import { Tooltip } from 'antd'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface TTSButtonProps {
message: Message
className?: string
}
interface SegmentedPlaybackState {
isSegmentedPlayback: boolean
segments: {
text: string
isLoaded: boolean
isLoading: boolean
}[]
currentSegmentIndex: number
isPlaying: boolean
}
const TTSButton: React.FC<TTSButtonProps> = ({ message, className }) => {
const { t } = useTranslation()
const [isSpeaking, setIsSpeaking] = useState(false)
// 分段播放状态
const [, setSegmentedPlaybackState] = useState<SegmentedPlaybackState>({
isSegmentedPlayback: false,
segments: [],
currentSegmentIndex: 0,
isPlaying: false
})
// 添加TTS状态变化事件监听器
useEffect(() => {
const handleTTSStateChange = (event: CustomEvent) => {
const { isPlaying } = event.detail
console.log('TTS按钮检测到TTS状态变化:', isPlaying)
setIsSpeaking(isPlaying)
}
// 添加事件监听器
window.addEventListener('tts-state-change', handleTTSStateChange as EventListener)
// 组件卸载时移除事件监听器
return () => {
window.removeEventListener('tts-state-change', handleTTSStateChange as EventListener)
}
}, [])
// 监听分段播放状态变化
useEffect(() => {
const handleSegmentedPlaybackUpdate = (event: CustomEvent) => {
console.log('检测到分段播放状态更新:', event.detail)
setSegmentedPlaybackState(event.detail)
}
// 添加事件监听器
window.addEventListener('tts-segmented-playback-update', handleSegmentedPlaybackUpdate as EventListener)
// 组件卸载时移除事件监听器
return () => {
window.removeEventListener('tts-segmented-playback-update', handleSegmentedPlaybackUpdate as EventListener)
}
}, [])
// 初始化时检查TTS状态
useEffect(() => {
// 检查当前是否正在播放
const isCurrentlyPlaying = TTSService.isCurrentlyPlaying()
if (isCurrentlyPlaying !== isSpeaking) {
setIsSpeaking(isCurrentlyPlaying)
}
}, [isSpeaking])
const handleTTS = useCallback(async () => {
if (isSpeaking) {
TTSService.stop()
return // 不需要手动设置状态,事件监听器会处理
}
try {
console.log('点击TTS按钮开始播放消息')
await TTSService.speakFromMessage(message)
// 不需要手动设置状态,事件监听器会处理
} catch (error) {
console.error('TTS error:', error)
// 出错时才需要手动重置状态
setIsSpeaking(false)
}
}, [isSpeaking, message])
// 处理分段播放按钮点击 - 暂未使用,保留供未来扩展
/* const handleSegmentedTTS = useCallback(async () => {
try {
console.log('点击分段TTS按钮开始分段播放消息')
// 使用修改后的speakFromMessage方法传入segmented=true参数
await TTSService.speakFromMessage(message, true)
} catch (error) {
console.error('Segmented TTS error:', error)
}
}, [message]) */
return (
<Tooltip title={isSpeaking ? t('chat.tts.stop') : t('chat.tts.play')}>
<TTSActionButton className={className} onClick={handleTTS}>
<SoundOutlined style={{ color: isSpeaking ? 'var(--color-primary)' : 'var(--color-icon)' }} />
</TTSActionButton>
</Tooltip>
)
}
const TTSActionButton = styled.div`
cursor: pointer;
border-radius: 8px;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
width: 30px;
height: 30px;
transition: all 0.2s ease;
&:hover {
background-color: var(--color-background-mute);
.anticon {
color: var(--color-text-1);
}
}
.anticon,
.iconfont {
cursor: pointer;
font-size: 14px;
color: var(--color-icon);
}
&:hover {
color: var(--color-text-1);
}
`
export default TTSButton

View File

@@ -0,0 +1,96 @@
import { TextSegmenter } from '@renderer/services/tts/TextSegmenter'
import TTSService from '@renderer/services/TTSService'
import React, { useEffect, useState } from 'react'
import styled from 'styled-components'
interface TTSHighlightedTextProps {
text: string
}
interface SegmentedPlaybackState {
isSegmentedPlayback: boolean
segments: {
text: string
isLoaded: boolean
isLoading: boolean
}[]
currentSegmentIndex: number
isPlaying: boolean
}
const TTSHighlightedText: React.FC<TTSHighlightedTextProps> = ({ text }) => {
const [segments, setSegments] = useState<string[]>([])
const [currentSegmentIndex, setCurrentSegmentIndex] = useState<number>(-1)
// 播放状态变量,用于跟踪当前是否正在播放
const [, setIsPlaying] = useState<boolean>(false)
// 初始化时分割文本
useEffect(() => {
const textSegments = TextSegmenter.splitIntoSentences(text)
setSegments(textSegments)
}, [text])
// 监听分段播放状态变化
useEffect(() => {
const handleSegmentedPlaybackUpdate = (event: CustomEvent) => {
const data = event.detail as SegmentedPlaybackState
if (data.isSegmentedPlayback) {
setCurrentSegmentIndex(data.currentSegmentIndex)
setIsPlaying(data.isPlaying)
} else {
setCurrentSegmentIndex(-1)
setIsPlaying(false)
}
}
// 添加事件监听器
window.addEventListener('tts-segmented-playback-update', handleSegmentedPlaybackUpdate as EventListener)
// 组件卸载时移除事件监听器
return () => {
window.removeEventListener('tts-segmented-playback-update', handleSegmentedPlaybackUpdate as EventListener)
}
}, [])
// 处理段落点击
const handleSegmentClick = (index: number) => {
TTSService.playFromSegment(index)
}
if (segments.length === 0) {
return <div>{text}</div>
}
return (
<TextContainer>
{segments.map((segment, index) => (
<TextSegment
key={index}
className={index === currentSegmentIndex ? 'active' : ''}
onClick={() => handleSegmentClick(index)}>
{segment}
</TextSegment>
))}
</TextContainer>
)
}
const TextContainer = styled.div`
display: inline;
`
const TextSegment = styled.span`
cursor: pointer;
transition: background-color 0.2s ease;
&:hover {
background-color: rgba(0, 0, 0, 0.05);
}
&.active {
background-color: var(--color-primary-bg);
border-radius: 2px;
}
`
export default TTSHighlightedText

View File

@@ -0,0 +1,267 @@
import { RootState } from '@renderer/store'
import React, { useEffect, useState } from 'react'
import { useSelector } from 'react-redux'
import styled from 'styled-components'
interface TTSProgressBarProps {
messageId: string
}
interface TTSProgressState {
isPlaying: boolean
progress: number // 0-100
currentTime: number
duration: number
}
const TTSProgressBar: React.FC<TTSProgressBarProps> = ({ messageId }) => {
// 获取是否显示TTS进度条的设置
const showTTSProgressBar = useSelector((state: RootState) => state.settings.showTTSProgressBar)
const [progressState, setProgressState] = useState<TTSProgressState>({
isPlaying: false,
progress: 0,
currentTime: 0,
duration: 0
})
// 添加拖动状态
const [isDragging, setIsDragging] = useState(false)
// 监听TTS进度更新事件
useEffect(() => {
const handleProgressUpdate = (event: CustomEvent) => {
const { messageId: playingMessageId, isPlaying, progress, currentTime, duration } = event.detail
// 不需要每次都输出日志,避免控制台刷屏
// 只在进度变化较大时输出日志,或者开始/结束时
// 在拖动进度条时不输出日志
// 完全关闭进度更新日志输出
// if (!isDragging &&
// playingMessageId === messageId &&
// (
// // 开始或结束播放
// (isPlaying !== progressState.isPlaying) ||
// // 每10%输出一次日志
// (Math.floor(progress / 10) !== Math.floor(progressState.progress / 10))
// )
// ) {
// console.log('TTS进度更新:', {
// messageId: messageId.substring(0, 8),
// isPlaying,
// progress: Math.round(progress),
// currentTime: Math.round(currentTime),
// duration: Math.round(duration)
// })
// }
// 只有当前消息正在播放时才更新进度
// 增加对playingMessageId的检查确保它存在且不为空
// 这样在语音通话模式下的开场白不会显示进度条
if (playingMessageId && playingMessageId === messageId) {
// 如果收到的是重置信号duration为0则强制设置为非播放状态
if (duration === 0 && currentTime === 0 && progress === 0) {
setProgressState({
isPlaying: false,
progress: 0,
currentTime: 0,
duration: 0
})
} else {
setProgressState({ isPlaying, progress, currentTime, duration })
}
} else if (progressState.isPlaying) {
// 如果当前消息不是正在播放的消息,但状态显示正在播放,则重置状态
setProgressState({
isPlaying: false,
progress: 0,
currentTime: 0,
duration: 0
})
}
}
// 监听TTS状态变化事件
const handleStateChange = (event: CustomEvent) => {
const { isPlaying } = event.detail
// 如果停止播放,重置进度条状态
if (!isPlaying && progressState.isPlaying) {
// console.log('收到TTS停止播放事件重置进度条')
setProgressState({
isPlaying: false,
progress: 0,
currentTime: 0,
duration: 0
})
}
}
// 添加事件监听器
window.addEventListener('tts-progress-update', handleProgressUpdate as EventListener)
window.addEventListener('tts-state-change', handleStateChange as EventListener)
// console.log('添加TTS进度更新事件监听器消息ID:', messageId)
// 组件卸载时移除事件监听器
return () => {
window.removeEventListener('tts-progress-update', handleProgressUpdate as EventListener)
window.removeEventListener('tts-state-change', handleStateChange as EventListener)
// console.log('移除TTS进度更新事件监听器消息ID:', messageId)
}
}, [messageId, progressState.isPlaying, isDragging])
// 如果没有播放或者设置为不显示进度条,则不显示
if (!progressState.isPlaying || !showTTSProgressBar) {
return null
}
// 处理进度条点击
const handleTrackClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (!progressState.isPlaying) return
// 如果是拖动结束的点击事件,忽略
if (e.type === 'click' && e.detail === 0) return
const trackRect = e.currentTarget.getBoundingClientRect()
const clickPosition = e.clientX - trackRect.left
const trackWidth = trackRect.width
const seekPercentage = (clickPosition / trackWidth) * 100
const seekTime = (seekPercentage / 100) * progressState.duration
// console.log(`进度条点击: ${seekPercentage.toFixed(2)}%, 时间: ${seekTime.toFixed(2)}秒`)
// 调用TTS服务的seek方法
import('@renderer/services/TTSService').then(({ default: TTSService }) => {
TTSService.seek(seekTime)
})
}
// 处理拖动
const handleDrag = (e: React.MouseEvent<HTMLDivElement>) => {
if (!progressState.isPlaying) return
e.preventDefault()
e.stopPropagation() // 阻止事件冒泡
// 设置拖动状态为true
setIsDragging(true)
const trackRect = e.currentTarget.getBoundingClientRect()
const trackWidth = trackRect.width
const handleMouseMove = (moveEvent: MouseEvent) => {
if (!isDragging) return
moveEvent.preventDefault()
const dragPosition = Math.max(0, Math.min(moveEvent.clientX - trackRect.left, trackWidth))
const seekPercentage = (dragPosition / trackWidth) * 100
const seekTime = (seekPercentage / 100) * progressState.duration
// 更新本地状态以实时反映拖动位置
setProgressState((prev) => ({
...prev,
progress: seekPercentage,
currentTime: seekTime
}))
}
const handleMouseUp = (upEvent: MouseEvent) => {
if (!isDragging) return
// 设置拖动状态为false
setIsDragging(false)
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
const dragPosition = Math.max(0, Math.min(upEvent.clientX - trackRect.left, trackWidth))
const seekPercentage = (dragPosition / trackWidth) * 100
const seekTime = (seekPercentage / 100) * progressState.duration
// console.log(`拖动结束: ${seekPercentage.toFixed(2)}%, 时间: ${seekTime.toFixed(2)}秒`)
// 调用TTS服务的seek方法
import('@renderer/services/TTSService').then(({ default: TTSService }) => {
TTSService.seek(seekTime)
})
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}
return (
<ProgressBarContainer>
<ProgressBarTrack onClick={handleTrackClick} onMouseDown={handleDrag}>
<ProgressBarFill style={{ width: `${progressState.progress}%` }} />
<ProgressBarHandle style={{ left: `${progressState.progress}%` }} />
</ProgressBarTrack>
<ProgressText>
{formatTime(progressState.currentTime)} / {formatTime(progressState.duration)}
</ProgressText>
</ProgressBarContainer>
)
}
// 格式化时间为 mm:ss 格式
const formatTime = (seconds: number): string => {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
const ProgressBarContainer = styled.div`
margin-top: 8px;
margin-bottom: 8px;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
`
const ProgressBarTrack = styled.div`
width: 100%;
height: 8px;
background-color: var(--color-background-mute);
border-radius: 4px;
overflow: visible;
position: relative;
cursor: pointer;
`
const ProgressBarFill = styled.div`
height: 100%;
background-color: var(--color-primary);
border-radius: 4px;
transition: width 0.1s linear;
pointer-events: none;
`
const ProgressBarHandle = styled.div`
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 12px;
height: 12px;
background-color: var(--color-primary);
border-radius: 50%;
cursor: pointer;
box-shadow: 0 0 4px rgba(0, 0, 0, 0.2);
z-index: 1;
opacity: 0;
transition:
opacity 0.2s ease,
transform 0.2s ease;
pointer-events: none;
${ProgressBarTrack}:hover & {
opacity: 1;
}
`
const ProgressText = styled.div`
margin-top: 4px;
font-size: 12px;
color: var(--color-text-2);
`
export default TTSProgressBar

View File

@@ -0,0 +1,76 @@
import { Spin } from 'antd'
import React from 'react'
import styled from 'styled-components'
interface TTSSegmentedTextProps {
segments: {
text: string
isLoaded: boolean
isLoading: boolean
}[]
currentSegmentIndex: number
isPlaying: boolean
onSegmentClick: (index: number) => void
}
const TTSSegmentedText: React.FC<TTSSegmentedTextProps> = ({
segments,
currentSegmentIndex,
// isPlaying, // 未使用的参数
onSegmentClick
}) => {
if (!segments || segments.length === 0) {
return null
}
return (
<SegmentedTextContainer>
{segments.map((segment, index) => (
<Segment
key={index}
className={`${index === currentSegmentIndex ? 'active' : ''}`}
onClick={() => onSegmentClick(index)}>
<SegmentText>{segment.text}</SegmentText>
{segment.isLoading && <Spin size="small" className="segment-loading" />}
</Segment>
))}
</SegmentedTextContainer>
)
}
const SegmentedTextContainer = styled.div`
margin: 10px 0;
padding: 10px;
border: 1px solid var(--color-border);
border-radius: 4px;
max-height: 300px;
overflow-y: auto;
`
const Segment = styled.div`
padding: 5px;
margin: 2px 0;
cursor: pointer;
border-radius: 4px;
display: flex;
align-items: center;
&:hover {
background-color: var(--color-background-soft);
}
&.active {
background-color: var(--color-primary-bg);
border-left: 3px solid var(--color-primary);
}
.segment-loading {
margin-left: 5px;
}
`
const SegmentText = styled.span`
flex: 1;
`
export default TTSSegmentedText

View File

@@ -0,0 +1,62 @@
import { LoadingOutlined, PhoneOutlined } from '@ant-design/icons'
import { Button, Tooltip } from 'antd'
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { VoiceCallService } from '../services/VoiceCallService'
import DraggableVoiceCallWindow from './DraggableVoiceCallWindow'
interface Props {
disabled?: boolean
style?: React.CSSProperties
}
const VoiceCallButton: React.FC<Props> = ({ disabled = false, style }) => {
const { t } = useTranslation()
const [isWindowVisible, setIsWindowVisible] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [windowPosition, setWindowPosition] = useState({ x: 20, y: 20 })
const handleClick = async () => {
if (disabled || isLoading) return
setIsLoading(true)
try {
// 初始化语音服务
await VoiceCallService.initialize()
// 先设置窗口可见然后在DraggableVoiceCallWindow组件中处理状态更新
setIsWindowVisible(true)
// 注意不在这里调用dispatch而是在DraggableVoiceCallWindow组件中处理
} catch (error) {
console.error('Failed to initialize voice call:', error)
window.message.error(t('voice_call.initialization_failed'))
} finally {
setIsLoading(false)
}
}
return (
<>
<Tooltip title={t('voice_call.start')}>
<Button
type="text"
icon={isLoading ? <LoadingOutlined /> : <PhoneOutlined />}
onClick={handleClick}
disabled={disabled || isLoading}
style={style}
/>
</Tooltip>
<DraggableVoiceCallWindow
visible={isWindowVisible}
onClose={() => {
setIsWindowVisible(false)
// 注意不在这里调用dispatch而是在DraggableVoiceCallWindow组件中处理
}}
position={windowPosition}
onPositionChange={setWindowPosition}
/>
</>
)
}
export default VoiceCallButton

View File

@@ -0,0 +1,321 @@
import {
AudioMutedOutlined,
AudioOutlined,
CloseOutlined,
PauseCircleOutlined,
PlayCircleOutlined,
SoundOutlined
} from '@ant-design/icons'
import { Button, Modal, Space, Tooltip } from 'antd'
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import { VoiceCallService } from '../services/VoiceCallService'
import VoiceVisualizer from './VoiceVisualizer'
interface Props {
visible: boolean
onClose: () => void
}
const VoiceCallModal: React.FC<Props> = ({ visible, onClose }) => {
const { t } = useTranslation()
const [isMuted, setIsMuted] = useState(false)
const [isPaused, setIsPaused] = useState(false)
const [transcript, setTranscript] = useState('')
const [response, setResponse] = useState('')
const [isListening, setIsListening] = useState(false)
const [isSpeaking, setIsSpeaking] = useState(false)
const [isRecording, setIsRecording] = useState(false)
const [isProcessing, setIsProcessing] = useState(false)
// 使用useCallback包裹handleClose函数避免useEffect依赖项变化
const handleClose = React.useCallback(() => {
VoiceCallService.endCall()
onClose()
}, [onClose])
useEffect(() => {
const startVoiceCall = async () => {
try {
// 显示加载中提示
window.message.loading({ content: t('voice_call.initializing'), key: 'voice-call-init' })
// 预先初始化语音识别服务
try {
await VoiceCallService.initialize()
} catch (initError) {
console.warn('语音识别服务初始化警告:', initError)
// 不抛出异常,允许程序继续运行
}
// 启动语音通话
await VoiceCallService.startCall({
onTranscript: (text) => setTranscript(text),
onResponse: (text) => setResponse(text),
onListeningStateChange: setIsListening,
onSpeakingStateChange: setIsSpeaking
})
// 关闭加载中提示
window.message.success({ content: t('voice_call.ready'), key: 'voice-call-init' })
} catch (error) {
console.error('Voice call error:', error)
window.message.error({ content: t('voice_call.error'), key: 'voice-call-init' })
handleClose()
}
}
// 添加TTS状态变化事件监听器
const handleTTSStateChange = (event: CustomEvent) => {
const { isPlaying } = event.detail
console.log('TTS状态变化事件:', isPlaying)
setIsSpeaking(isPlaying)
}
if (visible) {
startVoiceCall()
// 添加事件监听器
window.addEventListener('tts-state-change', handleTTSStateChange as EventListener)
}
return () => {
VoiceCallService.endCall()
// 移除事件监听器
window.removeEventListener('tts-state-change', handleTTSStateChange as EventListener)
}
}, [visible, t, handleClose])
const toggleMute = () => {
const newMuteState = !isMuted
setIsMuted(newMuteState)
VoiceCallService.setMuted(newMuteState)
}
const togglePause = () => {
const newPauseState = !isPaused
setIsPaused(newPauseState)
VoiceCallService.setPaused(newPauseState)
}
// 长按说话相关处理
const handleRecordStart = async (e: React.MouseEvent | React.TouchEvent) => {
e.preventDefault() // 防止触摸事件的默认行为
if (isProcessing || isPaused) return
// 先清除之前的语音识别结果
setTranscript('')
// 无论是否正在播放都强制停止TTS
VoiceCallService.stopTTS()
setIsSpeaking(false)
// 更新UI状态
setIsRecording(true)
setIsProcessing(true) // 设置处理状态,防止重复点击
// 开始录音
try {
await VoiceCallService.startRecording()
console.log('开始录音')
setIsProcessing(false) // 录音开始后取消处理状态
} catch (error) {
console.error('开始录音出错:', error)
window.message.error({ content: '启动语音识别失败,请确保语音识别服务已启动', key: 'voice-call-error' })
setIsRecording(false)
setIsProcessing(false)
}
}
const handleRecordEnd = async (e: React.MouseEvent | React.TouchEvent) => {
e.preventDefault() // 防止触摸事件的默认行为
if (!isRecording) return
// 立即更新UI状态
setIsRecording(false)
setIsProcessing(true)
// 无论是否正在播放都强制停止TTS
VoiceCallService.stopTTS()
setIsSpeaking(false)
// 确保录音完全停止
try {
await VoiceCallService.stopRecording()
console.log('录音已停止')
} catch (error) {
console.error('停止录音出错:', error)
} finally {
// 无论成功与否,都确保在一定时间后重置处理状态
setTimeout(() => {
setIsProcessing(false)
}, 1000) // 增加延迟时间,确保有足够时间处理结果
}
}
// 处理鼠标/触摸离开按钮的情况
const handleRecordCancel = async (e: React.MouseEvent | React.TouchEvent) => {
e.preventDefault()
if (isRecording) {
// 立即更新UI状态
setIsRecording(false)
setIsProcessing(true)
// 无论是否正在播放都强制停止TTS
VoiceCallService.stopTTS()
setIsSpeaking(false)
// 取消录音不发送给AI
try {
await VoiceCallService.cancelRecording()
console.log('录音已取消')
// 清除输入文本
setTranscript('')
} catch (error) {
console.error('取消录音出错:', error)
} finally {
// 无论成功与否,都确保在一定时间后重置处理状态
setTimeout(() => {
setIsProcessing(false)
}, 1000)
}
}
}
return (
<Modal
title={t('voice_call.title')}
open={visible}
onCancel={handleClose}
footer={null}
width={500}
centered
maskClosable={false}>
<Container>
<VisualizerContainer>
<VoiceVisualizer isActive={isListening || isRecording} type="input" />
<VoiceVisualizer isActive={isSpeaking} type="output" />
</VisualizerContainer>
<TranscriptContainer>
{transcript && (
<TranscriptText>
<UserLabel>{t('voice_call.you')}:</UserLabel> {transcript}
</TranscriptText>
)}
{response && (
<ResponseText>
<AILabel>{t('voice_call.ai')}:</AILabel> {response}
</ResponseText>
)}
</TranscriptContainer>
<ControlsContainer>
<Space>
<Button
type="text"
icon={isMuted ? <AudioMutedOutlined /> : <AudioOutlined />}
onClick={toggleMute}
size="large"
title={isMuted ? t('voice_call.unmute') : t('voice_call.mute')}
/>
<Button
type="text"
icon={isPaused ? <PlayCircleOutlined /> : <PauseCircleOutlined />}
onClick={togglePause}
size="large"
title={isPaused ? t('voice_call.resume') : t('voice_call.pause')}
/>
<Tooltip title={t('voice_call.press_to_talk')}>
<RecordButton
type={isRecording ? 'primary' : 'default'}
icon={<SoundOutlined />}
onMouseDown={handleRecordStart}
onMouseUp={handleRecordEnd}
onMouseLeave={handleRecordCancel}
onTouchStart={handleRecordStart}
onTouchEnd={handleRecordEnd}
onTouchCancel={handleRecordCancel}
size="large"
disabled={isProcessing || isPaused}>
{isRecording ? t('voice_call.release_to_send') : t('voice_call.press_to_talk')}
</RecordButton>
</Tooltip>
<Button
type="primary"
icon={<CloseOutlined />}
onClick={handleClose}
danger
size="large"
title={t('voice_call.end')}
/>
</Space>
</ControlsContainer>
</Container>
</Modal>
)
}
const Container = styled.div`
display: flex;
flex-direction: column;
gap: 20px;
height: 400px;
`
const VisualizerContainer = styled.div`
display: flex;
justify-content: space-between;
height: 100px;
`
const TranscriptContainer = styled.div`
flex: 1;
overflow-y: auto;
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 16px;
background-color: var(--color-background-2);
`
const TranscriptText = styled.p`
margin-bottom: 8px;
color: var(--color-text-1);
`
const ResponseText = styled.p`
margin-bottom: 8px;
color: var(--color-primary);
`
const UserLabel = styled.span`
font-weight: bold;
color: var(--color-text-1);
`
const AILabel = styled.span`
font-weight: bold;
color: var(--color-primary);
`
const ControlsContainer = styled.div`
display: flex;
justify-content: center;
padding: 10px 0;
`
const RecordButton = styled(Button)`
min-width: 150px;
transition: all 0.2s;
&:active {
transform: scale(0.95);
}
`
export default VoiceCallModal

View File

@@ -0,0 +1,93 @@
import React, { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface Props {
isActive: boolean
type: 'input' | 'output'
}
const VoiceVisualizer: React.FC<Props> = ({ isActive, type }) => {
const { t } = useTranslation()
const canvasRef = useRef<HTMLCanvasElement>(null)
const animationRef = useRef<number | undefined>(undefined)
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const width = canvas.width
const height = canvas.height
const drawVisualizer = () => {
ctx.clearRect(0, 0, width, height)
if (!isActive) {
// 绘制静态波形
ctx.beginPath()
ctx.moveTo(0, height / 2)
ctx.lineTo(width, height / 2)
ctx.strokeStyle = type === 'input' ? 'var(--color-text-2)' : 'var(--color-primary)'
ctx.lineWidth = 2
ctx.stroke()
return
}
// 绘制动态波形
const barCount = 30
const barWidth = width / barCount
const color = type === 'input' ? 'var(--color-text-1)' : 'var(--color-primary)'
for (let i = 0; i < barCount; i++) {
const barHeight = Math.random() * (height / 2) + 10
const x = i * barWidth
const y = height / 2 - barHeight / 2
ctx.fillStyle = color
ctx.fillRect(x, y, barWidth - 2, barHeight)
}
animationRef.current = requestAnimationFrame(drawVisualizer)
}
drawVisualizer()
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current)
}
}
}, [isActive, type])
return (
<Container $type={type}>
<Label>{type === 'input' ? t('voice_call.you') : t('voice_call.ai')}</Label>
<Canvas ref={canvasRef} width={200} height={50} />
</Container>
)
}
const Container = styled.div<{ $type: 'input' | 'output' }>`
display: flex;
flex-direction: column;
align-items: center;
width: 45%;
border-radius: 8px;
padding: 10px;
background-color: ${(props) => (props.$type === 'input' ? 'var(--color-background-3)' : 'var(--color-primary-bg)')};
`
const Label = styled.div`
margin-bottom: 8px;
font-weight: bold;
`
const Canvas = styled.canvas`
width: 100%;
height: 50px;
`
export default VoiceVisualizer

View File

@@ -98,13 +98,12 @@ export function WebdavBackupManager({ visible, onClose, webdavConfig, restoreMet
return
}
window.modal.confirm({
Modal.confirm({
title: t('settings.data.webdav.backup.manager.delete.confirm.title'),
icon: <ExclamationCircleOutlined />,
content: t('settings.data.webdav.backup.manager.delete.confirm.multiple', { count: selectedRowKeys.length }),
okText: t('common.confirm'),
cancelText: t('common.cancel'),
centered: true,
onOk: async () => {
setDeleting(true)
try {
@@ -137,13 +136,12 @@ export function WebdavBackupManager({ visible, onClose, webdavConfig, restoreMet
return
}
window.modal.confirm({
Modal.confirm({
title: t('settings.data.webdav.backup.manager.delete.confirm.title'),
icon: <ExclamationCircleOutlined />,
content: t('settings.data.webdav.backup.manager.delete.confirm.single', { fileName }),
okText: t('common.confirm'),
cancelText: t('common.cancel'),
centered: true,
onOk: async () => {
setDeleting(true)
try {
@@ -170,13 +168,12 @@ export function WebdavBackupManager({ visible, onClose, webdavConfig, restoreMet
return
}
window.modal.confirm({
Modal.confirm({
title: t('settings.data.webdav.restore.confirm.title'),
icon: <ExclamationCircleOutlined />,
content: t('settings.data.webdav.restore.confirm.content'),
okText: t('common.confirm'),
cancelText: t('common.cancel'),
centered: true,
onOk: async () => {
setRestoring(true)
try {

View File

@@ -1,6 +1,5 @@
import ThreeMinTopAppLogo from '@renderer/assets/images/apps/3mintop.png?url'
import AbacusLogo from '@renderer/assets/images/apps/abacus.webp?url'
import AIStudioLogo from '@renderer/assets/images/apps/aistudio.svg?url'
import BaiduAiAppLogo from '@renderer/assets/images/apps/baidu-ai.png?url'
import BaiduAiSearchLogo from '@renderer/assets/images/apps/baidu-ai-search.webp?url'
import BaicuanAppLogo from '@renderer/assets/images/apps/baixiaoying.webp?url'
@@ -310,12 +309,6 @@ export const DEFAULT_MIN_APPS: MinAppType[] = [
url: 'https://3min.top',
bodered: false
},
{
id: 'aistudio',
name: 'AI Studio',
logo: AIStudioLogo,
url: 'https://aistudio.google.com/'
},
{
id: 'xiaoyi',
name: '小艺',

View File

@@ -1661,28 +1661,34 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
],
openrouter: [
{
id: 'google/gemini-2.5-flash-preview',
id: 'google/gemma-2-9b-it:free',
provider: 'openrouter',
name: 'Google: Gemini 2.5 Flash Preview',
group: 'google'
name: 'Google: Gemma 2 9B',
group: 'Gemma'
},
{
id: 'qwen/qwen-2.5-7b-instruct:free',
id: 'microsoft/phi-3-mini-128k-instruct:free',
provider: 'openrouter',
name: 'Qwen: Qwen-2.5-7B Instruct',
group: 'qwen'
name: 'Phi-3 Mini 128K Instruct',
group: 'Phi'
},
{
id: 'deepseek/deepseek-chat',
id: 'microsoft/phi-3-medium-128k-instruct:free',
provider: 'openrouter',
name: 'DeepSeek: V3',
group: 'deepseek'
name: 'Phi-3 Medium 128K Instruct',
group: 'Phi'
},
{
id: 'meta-llama/llama-3-8b-instruct:free',
provider: 'openrouter',
name: 'Meta: Llama 3 8B Instruct',
group: 'Llama3'
},
{
id: 'mistralai/mistral-7b-instruct:free',
provider: 'openrouter',
name: 'Mistral: Mistral 7B Instruct',
group: 'mistralai'
group: 'Mistral'
}
],
groq: [

View File

@@ -1,5 +1,66 @@
import i18n from '@renderer/i18n'
import dayjs from 'dayjs'
// 语音通话提示词(多语言支持)
export const VOICE_CALL_PROMPTS: Record<string, string> = {
'zh-CN': `当前是语音通话模式。请注意:
1. 简洁直接地回答问题,避免冗长的引导和总结。
2. 避免使用复杂的格式化内容如表格、代码块、Markdown等。
3. 使用自然、口语化的表达方式,就像与人对话一样。
4. 如果需要列出要点,使用简单的数字或文字标记,而不是复杂的格式。
5. 回答应该简短有力,便于用户通过语音理解。
6. 避免使用特殊符号、表情符号、标点符号等,因为这些在语音播放时会影响理解。
7. 使用完整的句子而非简单的关键词列表。
8. 尽量使用常见词汇,避免生僻或专业术语,除非用户特别询问。`,
'en-US': `This is voice call mode. Please note:
1. Answer questions concisely and directly, avoiding lengthy introductions and summaries.
2. Avoid complex formatted content such as tables, code blocks, Markdown, etc.
3. Use natural, conversational language as if speaking to a person.
4. If you need to list points, use simple numbers or text markers rather than complex formats.
5. Responses should be brief and powerful, easy for users to understand through voice.
6. Avoid special symbols, emojis, punctuation marks, etc., as these can affect comprehension during voice playback.
7. Use complete sentences rather than simple keyword lists.
8. Try to use common vocabulary, avoiding obscure or technical terms unless specifically asked by the user.`,
'zh-TW': `當前是語音通話模式。請注意:
1. 簡潔直接地回答問題,避免冗長的引導和總結。
2. 避免使用複雜的格式化內容如表格、代碼塊、Markdown等。
3. 使用自然、口語化的表達方式,就像與人對話一樣。
4. 如果需要列出要點,使用簡單的數字或文字標記,而不是複雜的格式。
5. 回答應該簡短有力,便於用戶通過語音理解。
6. 避免使用特殊符號、表情符號、標點符號等,因為這些在語音播放時會影響理解。
7. 使用完整的句子而非簡單的關鍵詞列表。
8. 盡量使用常見詞彙,避免生僻或專業術語,除非用戶特別詢問。`,
'ja-JP': `これは音声通話モードです。ご注意ください:
1. 質問に簡潔かつ直接的に答え、長い導入や要約を避けてください。
2. 表、コードブロック、Markdownなどの複雑な書式付きコンテンツを避けてください。
3. 人と話すように、自然で会話的な言葉を使ってください。
4. ポイントをリストアップする必要がある場合は、複雑な形式ではなく、単純な数字やテキストマーカーを使用してください。
5. 応答は簡潔で力強く、ユーザーが音声で理解しやすいものにしてください。
6. 特殊記号、絵文字、句読点などは、音声再生中に理解に影響を与える可能性があるため、避けてください。
7. 単純なキーワードリストではなく、完全な文を使用してください。
8. ユーザーから特に質問されない限り、わかりにくい専門用語を避け、一般的な語彙を使用するようにしてください。`,
'ru-RU': `Это режим голосового вызова. Обратите внимание:
1. Отвечайте на вопросы кратко и прямо, избегая длинных введений и резюме.
2. Избегайте сложного форматированного содержания, такого как таблицы, блоки кода, Markdown и т.д.
3. Используйте естественный, разговорный язык, как при разговоре с человеком.
4. Если вам нужно перечислить пункты, используйте простые цифры или текстовые маркеры, а не сложные форматы.
5. Ответы должны быть краткими и содержательными, легкими для понимания пользователем через голос.
6. Избегайте специальных символов, эмодзи, знаков препинания и т.д., так как они могут затруднить понимание при воспроизведении голосом.
7. Используйте полные предложения, а не простые списки ключевых слов.
8. Старайтесь использовать общеупотребительную лексику, избегая малоизвестных или технических терминов, если пользователь специально не спрашивает о них.`
// 可以添加更多语言...
}
// 获取当前语言的默认语音通话提示词
export function getDefaultVoiceCallPrompt(): string {
const language = i18n.language || 'en-US'
// 如果没有对应语言的提示词,使用英文提示词作为后备
return VOICE_CALL_PROMPTS[language] || VOICE_CALL_PROMPTS['en-US']
}
// 为了向后兼容,保留原来的常量
export const DEFAULT_VOICE_CALL_PROMPT = getDefaultVoiceCallPrompt()
export const AGENT_PROMPT = `
You are a Prompt Generator. You will integrate user input information into a structured Prompt using Markdown syntax. Please do not use code blocks for output, display directly!
@@ -52,7 +113,6 @@ export const SUMMARIZE_PROMPT =
// https://github.com/ItzCrazyKns/Perplexica/blob/master/src/lib/prompts/webSearch.ts
export const SEARCH_SUMMARY_PROMPT = `
You are an AI question rephraser. Your role is to rephrase follow-up queries from a conversation into standalone queries that can be used by another LLM to retrieve information, either through web search or from a knowledge base.
**Use user's language to rephrase the question.**
Follow these guidelines:
1. If the question is a simple writing task, greeting (e.g., Hi, Hello, How are you), or does not require searching for information (unless the greeting contains a follow-up question), return 'not_needed' in the 'question' XML block. This indicates that no search is required.
2. If the user asks a question related to a specific URL, PDF, or webpage, include the links in the 'links' XML block and the question in the 'question' XML block. If the request is to summarize content from a URL or PDF, return 'summarize' in the 'question' XML block and include the relevant links in the 'links' XML block.
@@ -60,7 +120,7 @@ export const SEARCH_SUMMARY_PROMPT = `
4. Websearch: Always return the rephrased question inside the 'question' XML block. If there are no links in the follow-up question, do not insert a 'links' XML block in your response.
5. Knowledge: Always return the rephrased question inside the 'question' XML block.
6. Always wrap the rephrased question in the appropriate XML blocks to specify the tool(s) for retrieving information: use <websearch></websearch> for queries requiring real-time or external information, <knowledge></knowledge> for queries that can be answered from a pre-existing knowledge base, or both if the question could be applicable to either tool. Ensure that the rephrased question is always contained within a <question></question> block inside these wrappers.
7. *use {tools} to rephrase the question*
7. If you are not sure to use knowledge or websearch, you need use both of them.
There are several examples attached for your reference inside the below 'examples' XML block.
@@ -194,7 +254,6 @@ export const SEARCH_SUMMARY_PROMPT = `
{chat_history}
</conversation>
**Use user's language to rephrase the question.**
Follow up question: {question}
Rephrased question:
`

View File

@@ -3,10 +3,11 @@ import { isLocalAi } from '@renderer/config/env'
import { useTheme } from '@renderer/context/ThemeProvider'
import db from '@renderer/databases'
import i18n from '@renderer/i18n'
import { initSentry } from '@renderer/init'
import ASRServerService from '@renderer/services/ASRServerService'
import { useAppDispatch } from '@renderer/store'
import { setAvatar, setFilesPath, setResourcesPath, setUpdateState } from '@renderer/store/runtime'
import { delay, runAsyncFunction } from '@renderer/utils'
import { disableAnalytics, initAnalytics } from '@renderer/utils/analytics'
import { defaultLanguage } from '@shared/config/constant'
import { useLiveQuery } from 'dexie-react-hooks'
import { useEffect } from 'react'
@@ -19,7 +20,18 @@ import useUpdateHandler from './useUpdateHandler'
export function useAppInit() {
const dispatch = useAppDispatch()
const { proxyUrl, language, windowStyle, autoCheckUpdate, proxyMode, customCss, enableDataCollection } = useSettings()
const {
proxyUrl,
language,
windowStyle,
autoCheckUpdate,
proxyMode,
customCss,
enableDataCollection,
asrEnabled,
asrServiceType,
asrAutoStartServer
} = useSettings()
const { minappShow } = useRuntime()
const { setDefaultModel, setTopicNamingModel, setTranslateModel } = useDefaultModel()
const avatar = useLiveQuery(() => db.settings.get('image://avatar'))
@@ -106,6 +118,20 @@ export function useAppInit() {
}, [customCss])
useEffect(() => {
enableDataCollection && initSentry()
enableDataCollection ? initAnalytics() : disableAnalytics()
}, [enableDataCollection])
// 自动启动ASR服务器
useEffect(() => {
if (asrEnabled && asrServiceType === 'local' && asrAutoStartServer) {
console.log('自动启动ASR服务器...')
ASRServerService.startServer().then((success) => {
if (success) {
console.log('ASR服务器自动启动成功')
} else {
console.error('ASR服务器自动启动失败')
}
})
}
}, [asrEnabled, asrServiceType, asrAutoStartServer])
}

View File

@@ -2,7 +2,6 @@ import store, { useAppDispatch, useAppSelector } from '@renderer/store'
import { addMCPServer, deleteMCPServer, setMCPServers, updateMCPServer } from '@renderer/store/mcp'
import { MCPServer } from '@renderer/types'
import { IpcChannel } from '@shared/IpcChannel'
import { useMemo } from 'react'
const ipcRenderer = window.electron.ipcRenderer
@@ -13,7 +12,7 @@ ipcRenderer.on(IpcChannel.Mcp_ServersChanged, (_event, servers) => {
export const useMCPServers = () => {
const mcpServers = useAppSelector((state) => state.mcp.servers)
const activedMcpServers = useMemo(() => mcpServers.filter((server) => server.isActive), [mcpServers])
const activedMcpServers = mcpServers.filter((server) => server.isActive)
const dispatch = useAppDispatch()
return {

View File

@@ -1,30 +1,42 @@
import { useTheme } from '@renderer/context/ThemeProvider'
import { EventEmitter } from '@renderer/services/EventService'
import { ThemeMode } from '@renderer/types'
import { loadScript, runAsyncFunction } from '@renderer/utils'
import { useEffect, useRef } from 'react'
import { useEffect } from 'react'
import { useRuntime } from './useRuntime'
export const useMermaid = () => {
const { theme } = useTheme()
const mermaidLoaded = useRef(false)
const { generating } = useRuntime()
useEffect(() => {
runAsyncFunction(async () => {
if (!window.mermaid) {
await loadScript('https://unpkg.com/mermaid@11.6.0/dist/mermaid.min.js')
}
if (!mermaidLoaded.current) {
await window.mermaid.initialize({
startOnLoad: false,
theme: theme === ThemeMode.dark ? 'dark' : 'default'
})
mermaidLoaded.current = true
EventEmitter.emit('mermaid-loaded')
await loadScript('https://unpkg.com/mermaid@11.4.0/dist/mermaid.min.js')
}
window.mermaid.initialize({
startOnLoad: true,
theme: theme === ThemeMode.dark ? 'dark' : 'default'
})
})
}, [theme])
useEffect(() => {
if (!window.mermaid || generating) return
const renderMermaid = () => {
const mermaidElements = document.querySelectorAll('.mermaid')
mermaidElements.forEach((element) => {
if (!element.querySelector('svg')) {
element.removeAttribute('data-processed')
}
})
window.mermaid.contentLoaded()
}
setTimeout(renderMermaid, 100)
}, [generating])
useEffect(() => {
const handleWheel = (e: WheelEvent) => {
if (e.ctrlKey || e.metaKey) {

View File

@@ -1,12 +1,20 @@
import { isMac } from '@renderer/config/constant'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useRuntime } from './useRuntime'
import { useSettings } from './useSettings'
function useNavBackgroundColor() {
const { windowStyle } = useSettings()
const { theme } = useTheme()
const { minappShow } = useRuntime()
const macTransparentWindow = isMac && windowStyle === 'transparent'
if (minappShow) {
return theme === 'dark' ? 'var(--navbar-background)' : 'var(--color-white)'
}
if (macTransparentWindow) {
return 'transparent'
}

View File

@@ -1,5 +1,27 @@
{
"translation": {
"voice_call": {
"title": "Voice Call",
"start": "Start Voice Call",
"end": "End Call",
"mute": "Mute",
"unmute": "Unmute",
"pause": "Pause",
"resume": "Resume",
"you": "You",
"ai": "AI",
"press_to_talk": "Press to Talk",
"release_to_send": "Release to Send",
"initialization_failed": "Failed to initialize voice call",
"error": "Voice call error",
"initializing": "Initializing voice call...",
"ready": "Voice call ready",
"shortcut_key_setting": "Voice Recognition Shortcut Key Settings",
"press_any_key": "Press any key...",
"save": "Save",
"cancel": "Cancel",
"shortcut_key_tip": "Press this shortcut key to start recording, release to end recording and send"
},
"agents": {
"add.button": "Add to Assistant",
"add.knowledge_base": "Knowledge Base",
@@ -104,6 +126,13 @@
"default.description": "Hello, I'm Default Assistant. You can start chatting with me right away",
"default.name": "Default Assistant",
"default.topic.name": "Default Topic",
"tts": {
"play": "Play speech",
"stop": "Stop playback",
"speak": "Play speech",
"stop_global": "Stop all speech playback",
"stopped": "Speech playback stopped"
},
"history": {
"assistant_node": "Assistant",
"click_to_navigate": "Click to navigate to the message",
@@ -1168,7 +1197,6 @@
"messages.input.enable_delete_model": "Enable the backspace key to delete models/attachments.",
"messages.markdown_rendering_input_message": "Markdown render input message",
"messages.math_engine": "Math engine",
"messages.math_engine.none": "None",
"messages.metrics": "{{time_first_token_millsec}}ms to first token | {{token_speed}} tok/sec",
"messages.model.title": "Model Settings",
"messages.navigation": "Message Navigation",
@@ -1343,6 +1371,8 @@
"check": "Check",
"check_failed": "Verification failed",
"check_success": "Verification successful",
"enhance_mode": "Search enhance mode",
"enhance_mode_tooltip": "Use the default model to extract search keywords from the problem and search",
"get_api_key": "Get API Key",
"no_provider_selected": "Please select a search service provider before checking.",
"search_max_result": "Number of search results",
@@ -1396,6 +1426,171 @@
"privacy": {
"title": "Privacy Settings",
"enable_privacy_mode": "Anonymous reporting of errors and statistics"
},
"tts": {
"title": "Text-to-Speech Settings",
"enable": "Enable Text-to-Speech",
"enable.help": "Enable to convert text to speech",
"reset": "Reset",
"reset_title": "Reset Custom Voices and Models",
"reset_confirm": "Are you sure you want to reset all custom voices and models? This will delete all custom items you've added.",
"reset_success": "Reset successful",
"reset_help": "If voices or models display abnormally, try resetting all custom items",
"api_settings": "API Settings",
"service_type": "Service Type",
"service_type.openai": "OpenAI",
"service_type.edge": "Browser TTS",
"service_type.siliconflow": "SiliconFlow",
"service_type.refresh": "Refresh TTS service type settings",
"service_type.refreshed": "TTS service type settings refreshed",
"siliconflow_api_key": "SiliconFlow API Key",
"siliconflow_api_key.placeholder": "Enter SiliconFlow API key",
"siliconflow_api_url": "SiliconFlow API URL",
"siliconflow_api_url.placeholder": "Example: https://api.siliconflow.cn/v1/audio/speech",
"siliconflow_voice": "SiliconFlow Voice",
"siliconflow_voice.placeholder": "Select a voice",
"siliconflow_model": "SiliconFlow Model",
"siliconflow_model.placeholder": "Select a model",
"siliconflow_response_format": "Response Format",
"siliconflow_response_format.placeholder": "Default is mp3",
"siliconflow_speed": "Speech Speed",
"siliconflow_speed.placeholder": "Default is 1.0",
"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/speech",
"edge_voice": "Edge TTS Voice",
"edge_voice.loading": "Loading...",
"edge_voice.refresh": "Refresh available voices",
"edge_voice.not_found": "No matching voices found",
"voice": "Voice",
"voice.placeholder": "Select a voice",
"voice_input_placeholder": "Enter voice",
"voice_add": "Add",
"voice_empty": "No custom voices yet, please add below",
"model": "Model",
"model.placeholder": "Select a model",
"model_input_placeholder": "Enter model",
"model_add": "Add",
"model_empty": "No custom models yet, please add below",
"filter_options": "Filter Options",
"filter.thinking_process": "Filter thinking process",
"filter.markdown": "Filter Markdown",
"filter.code_blocks": "Filter code blocks",
"filter.html_tags": "Filter HTML tags",
"filter.emojis": "Filter emojis",
"max_text_length": "Maximum text length",
"show_progress_bar": "Show TTS progress bar",
"test": "Test Speech",
"help": "Text-to-speech functionality supports converting text to natural-sounding speech.",
"learn_more": "Learn more",
"tab_title": "Text-to-Speech",
"play": "Play speech",
"stop": "Stop playback",
"speak": "Play speech",
"stop_global": "Stop all speech playback",
"stopped": "Speech playback stopped",
"segmented": "Segmented Playback",
"segmented_play": "Segmented Playback",
"segmented_playback": "Segmented Playback",
"error": {
"not_enabled": "Text-to-speech feature is not enabled",
"no_api_key": "API key is not set",
"no_voice": "Voice is not selected",
"no_model": "Model is not selected",
"no_edge_voice": "Browser TTS voice is not selected",
"browser_not_support": "Browser does not support speech synthesis",
"synthesis_failed": "Speech synthesis failed",
"play_failed": "Speech playback failed",
"empty_text": "Text is empty",
"general": "An error occurred during speech synthesis",
"unsupported_service_type": "Unsupported service type: {{serviceType}}"
},
"service_type.mstts": "Free Online TTS",
"edge_voice.available_count": "Available voices: {{count}}",
"edge_voice.refreshing": "Refreshing voice list...",
"edge_voice.refreshed": "Voice list refreshed",
"mstts.voice": "Free Online TTS Voice",
"mstts.output_format": "Output Format",
"mstts.info": "Free Online TTS service doesn't require an API key, completely free to use.",
"error.no_mstts_voice": "Free Online TTS voice not set"
},
"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 your 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",
"no_api_key": "API key is not set",
"browser_not_support": "Browser does not support speech recognition"
},
"auto_start_server": "Automatically start server when launching the application",
"auto_start_server.help": "When enabled, the speech recognition server will automatically start when the application launches"
},
"voice": {
"title": "Voice Features",
"help": "Voice features include Text-to-Speech (TTS), Automatic Speech Recognition (ASR), and Voice Call.",
"learn_more": "Learn More"
},
"voice_call": {
"tab_title": "Voice Call",
"enable": "Enable Voice Call",
"enable.help": "Enable to use voice call feature to talk with AI",
"model": "Call Model",
"model.select": "Select Model",
"model.current": "Current Model: {{model}}",
"model.info": "Select the AI model for voice calls. Different models may provide different voice interaction experiences",
"welcome_message": "Hello, I'm your AI assistant. Please press and hold the talk button to start a conversation.",
"prompt": {
"label": "Voice Call Prompt",
"placeholder": "Enter voice call prompt",
"save": "Save",
"reset": "Reset",
"saved": "Prompt saved",
"reset_done": "Prompt reset",
"info": "This prompt will guide the AI's responses in voice call mode"
},
"asr_tts_info": "Voice call uses the Speech Recognition (ASR) and Text-to-Speech (TTS) settings above",
"test": "Test Voice Call",
"test_info": "Please use the voice call button on the right side of the input box to test"
}
},
"translate": {

View File

@@ -104,6 +104,13 @@
"default.description": "こんにちは、私はデフォルトのアシスタントです。すぐにチャットを始められます。",
"default.name": "デフォルトアシスタント",
"default.topic.name": "デフォルトトピック",
"tts": {
"play": "音声を再生",
"stop": "再生を停止",
"speak": "音声を再生",
"stop_global": "すべての音声再生を停止",
"stopped": "音声再生を停止しました"
},
"history": {
"assistant_node": "アシスタント",
"click_to_navigate": "メッセージに移動",
@@ -1167,7 +1174,6 @@
"messages.input.enable_delete_model": "バックスペースキーでモデル/添付ファイルを削除します。",
"messages.markdown_rendering_input_message": "Markdownで入力メッセージをレンダリング",
"messages.math_engine": "数式エンジン",
"messages.math_engine.none": "なし",
"messages.metrics": "最初のトークンまでの時間 {{time_first_token_millsec}}ms | トークン速度 {{token_speed}} tok/sec",
"messages.model.title": "モデル設定",
"messages.navigation": "メッセージナビゲーション",
@@ -1341,6 +1347,8 @@
"check": "チェック",
"check_failed": "検証に失敗しました",
"check_success": "検証に成功しました",
"enhance_mode": "検索強化モード",
"enhance_mode_tooltip": "デフォルトモデルを使用して問題から検索キーワードを抽出し、検索を実行します",
"get_api_key": "APIキーを取得",
"no_provider_selected": "検索サービスプロバイダーを選択してから再確認してください。",
"search_max_result": "検索結果の数",
@@ -1396,6 +1404,171 @@
"privacy": {
"title": "プライバシー設定",
"enable_privacy_mode": "匿名エラーレポートとデータ統計の送信"
},
"tts": {
"title": "音声合成設定",
"enable": "音声合成を有効にする",
"enable.help": "テキストを音声に変換する機能を有効にします",
"reset": "リセット",
"reset_title": "カスタム音声とモデルをリセット",
"reset_confirm": "すべてのカスタム音声とモデルをリセットしますか?追加したすべてのカスタム項目が削除されます。",
"reset_success": "リセットに成功しました",
"reset_help": "音声やモデルの表示に異常がある場合は、すべてのカスタム項目をリセットしてみてください",
"api_settings": "API設定",
"service_type": "サービスタイプ",
"service_type.openai": "OpenAI",
"service_type.edge": "ブラウザ TTS",
"test": "テスト",
"error": {
"not_enabled": "音声合成が有効になっていません",
"no_edge_voice": "ブラウザ TTSの音声が選択されていません",
"no_api_key": "APIキーが設定されていません",
"browser_not_support": "ブラウザが音声合成をサポートしていません",
"no_voice": "音声が選択されていません",
"no_model": "モデルが選択されていません",
"synthesis_failed": "音声合成に失敗しました",
"play_failed": "音声再生に失敗しました",
"empty_text": "テキストが空です",
"general": "音声合成エラーが発生しました",
"unsupported_service_type": "サポートされていないサービスタイプ: {{serviceType}}"
},
"help": "OpenAIのTTS APIを使用するには、APIキーが必要です。ブラウザ TTSはブラウザの機能を使用するため、APIキーは不要です。",
"learn_more": "詳細はこちら",
"tab_title": "音声合成",
"service_type.refresh": "TTS サービスタイプ設定を更新",
"service_type.refreshed": "TTS サービスタイプ設定が更新されました",
"api_key": "API キー",
"api_key.placeholder": "OpenAI API キーを入力してください",
"api_url": "API アドレス",
"api_url.placeholder": "例https://api.openai.com/v1/audio/speech",
"edge_voice": "ブラウザ TTS 音声",
"edge_voice.loading": "読み込み中...",
"edge_voice.refresh": "利用可能な音声リストを更新",
"edge_voice.not_found": "一致する音声が見つかりません",
"voice": "音声",
"voice.placeholder": "音声を選択してください",
"voice_input_placeholder": "音声を入力",
"voice_add": "追加",
"voice_empty": "カスタム音声がありません。下に追加してください",
"model": "モデル",
"model.placeholder": "モデルを選択してください",
"model_input_placeholder": "モデルを入力",
"model_add": "追加",
"model_empty": "カスタムモデルがありません。下に追加してください",
"filter_options": "フィルターオプション",
"filter.thinking_process": "思考プロセスをフィルター",
"filter.markdown": "Markdownタグをフィルター",
"filter.code_blocks": "コードブロックをフィルター",
"filter.html_tags": "HTMLタグをフィルター",
"max_text_length": "最大テキスト長",
"service_type.siliconflow": "シリコンフロー",
"service_type.mstts": "無料オンライン TTS",
"siliconflow_api_key": "シリコンフロー API キー",
"siliconflow_api_key.placeholder": "シリコンフロー API キーを入力してください",
"siliconflow_api_url": "シリコンフロー API アドレス",
"siliconflow_api_url.placeholder": "例https://api.siliconflow.cn/v1/audio/speech",
"siliconflow_voice": "シリコンフロー音声",
"siliconflow_voice.placeholder": "音声を選択してください",
"siliconflow_model": "シリコンフローモデル",
"siliconflow_model.placeholder": "モデルを選択してください",
"siliconflow_response_format": "レスポンス形式",
"siliconflow_response_format.placeholder": "デフォルトはmp3",
"siliconflow_speed": "話す速度",
"siliconflow_speed.placeholder": "デフォルトは1.0",
"edge_voice.available_count": "利用可能な音声: {{count}}個",
"edge_voice.refreshing": "音声リストを更新中...",
"edge_voice.refreshed": "音声リストが更新されました",
"mstts.voice": "無料オンライン TTS 音声",
"mstts.output_format": "出力形式",
"mstts.info": "無料オンラインTTSサービスはAPIキーが不要で、完全に無料で使用できます。",
"error.no_mstts_voice": "無料オンライン TTS 音声が設定されていません",
"play": "音声を再生",
"stop": "再生を停止",
"speak": "音声を再生",
"stop_global": "すべての音声再生を停止",
"stopped": "音声再生を停止しました",
"segmented": "分割",
"segmented_play": "分割再生",
"segmented_playback": "分割再生",
"filter.emojis": "絵文字をフィルター",
"show_progress_bar": "TTS進行バーを表示"
},
"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": "このページをブラウザで開き、ブラウザウィンドウを開いたままにしてください",
"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": "音声の文字起こしに失敗しました",
"no_api_key": "APIキーが設定されていません",
"browser_not_support": "ブラウザが音声認識をサポートしていません"
},
"auto_start_server": "アプリ起動時にサーバーを自動起動",
"auto_start_server.help": "有効にすると、アプリ起動時に音声認識サーバーが自動的に起動します"
},
"voice": {
"title": "音声機能",
"help": "音声機能にはテキスト読み上げ(TTS)と音声認識(ASR)が含まれます。",
"learn_more": "詳細を見る"
},
"voice_call": {
"tab_title": "通話機能",
"enable": "音声通話を有効にする",
"enable.help": "有効にすると、音声通話機能を使用してAIと対話できます",
"model": "通話モデル",
"model.select": "モデルを選択",
"model.current": "現在のモデル: {{model}}",
"model.info": "音声通話用のAIモデルを選択します。モデルによって音声対話の体験が異なる場合があります",
"welcome_message": "こんにちは、AIアシスタントです。会話を始めるには、ボタンを長押ししてください。",
"prompt": {
"label": "音声通話プロンプト",
"placeholder": "音声通話プロンプトを入力",
"save": "保存",
"reset": "リセット",
"saved": "プロンプトが保存されました",
"reset_done": "プロンプトがリセットされました",
"info": "このプロンプトは音声通話モードでのAIの応答方法を指導します"
},
"asr_tts_info": "音声通話は上記の音声認識(ASR)と音声合成(TTS)の設定を使用します",
"test": "音声通話テスト",
"test_info": "入力ボックスの右側にある音声通話ボタンを使用してテストしてください"
}
},
"translate": {
@@ -1436,6 +1609,28 @@
"quit": "終了",
"show_window": "ウィンドウを表示",
"visualization": "可視化"
},
"voice_call": {
"title": "音声通話",
"start": "音声通話を開始",
"end": "通話を終了",
"mute": "ミュート",
"unmute": "ミュート解除",
"pause": "一時停止",
"resume": "再開",
"you": "あなた",
"ai": "AI",
"press_to_talk": "長押しして話す",
"release_to_send": "離すと送信",
"initialization_failed": "音声通話の初期化に失敗しました",
"error": "音声通話エラー",
"initializing": "音声通話を初期化中...",
"ready": "音声通話の準備が完了しました",
"shortcut_key_setting": "音声認識ショートカットキー設定",
"press_any_key": "任意のキーを押してください...",
"save": "保存",
"cancel": "キャンセル",
"shortcut_key_tip": "このショートカットキーを押すと録音が始まり、キーを離すと録音が終了して送信されます"
}
}
}
}

View File

@@ -104,6 +104,13 @@
"default.description": "Привет, я Ассистент по умолчанию. Вы можете начать общаться со мной прямо сейчас",
"default.name": "Ассистент по умолчанию",
"default.topic.name": "Топик по умолчанию",
"tts": {
"play": "Воспроизвести речь",
"stop": "Остановить воспроизведение",
"speak": "Воспроизвести речь",
"stop_global": "Остановить все воспроизведение речи",
"stopped": "Воспроизведение речи остановлено"
},
"history": {
"assistant_node": "Ассистент",
"click_to_navigate": "Перейти к сообщению",
@@ -324,6 +331,9 @@
"503": "Серверная ошибка. Пожалуйста, попробуйте позже",
"504": "Серверная ошибка. Пожалуйста, попробуйте позже"
},
"asr": {
"browser_not_support": "Браузер не поддерживает распознавание речи"
},
"model.exists": "Модель уже существует",
"no_api_key": "Ключ API не настроен",
"provider_disabled": "Провайдер моделей не включен",
@@ -1167,7 +1177,6 @@
"messages.input.enable_delete_model": "Включите удаление модели/вложения с помощью клавиши Backspace",
"messages.markdown_rendering_input_message": "Отображение ввода в формате Markdown",
"messages.math_engine": "Математический движок",
"messages.math_engine.none": "Нет",
"messages.metrics": "{{time_first_token_millsec}}ms до первого токена | {{token_speed}} tok/sec",
"messages.model.title": "Настройки модели",
"messages.navigation": "Навигация сообщений",
@@ -1341,6 +1350,8 @@
"check": "проверка",
"check_failed": "Проверка не прошла",
"check_success": "Проверка успешна",
"enhance_mode": "Режим улучшения поиска",
"enhance_mode_tooltip": "Используйте модель по умолчанию для извлечения ключевых слов из проблемы и поиска",
"get_api_key": "Получить ключ API",
"no_provider_selected": "Пожалуйста, выберите поставщика поисковых услуг, затем проверьте.",
"search_max_result": "Количество результатов поиска",
@@ -1396,6 +1407,171 @@
"privacy": {
"title": "Настройки приватности",
"enable_privacy_mode": "Анонимная отправка отчетов об ошибках и статистики"
},
"tts": {
"title": "Настройки преобразования текста в речь",
"enable": "Включить преобразование текста в речь",
"enable.help": "Включить функцию преобразования текста в речь",
"reset": "Сбросить",
"reset_title": "Сбросить пользовательские голоса и модели",
"reset_confirm": "Вы уверены, что хотите сбросить все пользовательские голоса и модели? Это удалит все добавленные вами пользовательские элементы.",
"reset_success": "Сброс выполнен успешно",
"reset_help": "Если голоса или модели отображаются некорректно, попробуйте сбросить все пользовательские элементы",
"api_settings": "Настройки API",
"service_type": "Тип сервиса",
"service_type.openai": "OpenAI",
"service_type.edge": "Edge TTS",
"test": "Тест",
"error": {
"not_enabled": "Преобразование текста в речь не включено",
"no_edge_voice": "Голос Edge TTS не выбран",
"no_api_key": "Ключ API не настроен",
"browser_not_support": "Браузер не поддерживает синтез речи",
"no_voice": "Голос не выбран",
"no_model": "Модель не выбрана",
"synthesis_failed": "Ошибка синтеза речи",
"play_failed": "Ошибка воспроизведения речи",
"empty_text": "Текст пуст",
"general": "Ошибка синтеза речи",
"unsupported_service_type": "Неподдерживаемый тип службы: {{serviceType}}"
},
"help": "Для использования API TTS OpenAI требуется ключ API. Edge TTS использует функции браузера и не требует ключа API.",
"learn_more": "Узнать больше",
"tab_title": "Синтез речи",
"service_type.refresh": "Обновить настройки типа службы TTS",
"service_type.refreshed": "Настройки типа службы TTS обновлены",
"api_key": "Ключ API",
"api_key.placeholder": "Пожалуйста, введите ключ API OpenAI",
"api_url": "URL-адрес API",
"api_url.placeholder": "Например: https://api.openai.com/v1/audio/speech",
"edge_voice": "Голос TTS браузера",
"edge_voice.loading": "Загрузка...",
"edge_voice.refresh": "Обновить список доступных голосов",
"edge_voice.not_found": "Не найдено подходящего голоса",
"voice": "Голос",
"voice.placeholder": "Пожалуйста, выберите голос",
"voice_input_placeholder": "Введите голос",
"voice_add": "Добавить",
"voice_empty": "Пользовательские голоса отсутствуют, пожалуйста, добавьте ниже",
"model": "Модель",
"model.placeholder": "Пожалуйста, выберите модель",
"model_input_placeholder": "Введите модель",
"model_add": "Добавить",
"model_empty": "Пользовательские модели отсутствуют, пожалуйста, добавьте ниже",
"filter_options": "Параметры фильтрации",
"filter.thinking_process": "Фильтровать процесс рассуждения",
"filter.markdown": "Фильтровать разметку Markdown",
"filter.code_blocks": "Фильтровать блоки кода",
"filter.html_tags": "Фильтровать HTML-теги",
"max_text_length": "Максимальная длина текста",
"service_type.siliconflow": "SiliconFlow",
"service_type.mstts": "Бесплатный онлайн TTS",
"siliconflow_api_key": "Ключ API SiliconFlow",
"siliconflow_api_key.placeholder": "Пожалуйста, введите ключ API SiliconFlow",
"siliconflow_api_url": "URL-адрес API SiliconFlow",
"siliconflow_api_url.placeholder": "Например: https://api.siliconflow.cn/v1/audio/speech",
"siliconflow_voice": "Голос SiliconFlow",
"siliconflow_voice.placeholder": "Пожалуйста, выберите голос",
"siliconflow_model": "Модель SiliconFlow",
"siliconflow_model.placeholder": "Пожалуйста, выберите модель",
"siliconflow_response_format": "Формат ответа",
"siliconflow_response_format.placeholder": "По умолчанию mp3",
"siliconflow_speed": "Скорость речи",
"siliconflow_speed.placeholder": "По умолчанию 1.0",
"edge_voice.available_count": "Доступные голоса: {{count}}",
"edge_voice.refreshing": "Обновление списка голосов...",
"edge_voice.refreshed": "Список голосов обновлен",
"mstts.voice": "Бесплатный онлайн голос TTS",
"mstts.output_format": "Формат вывода",
"mstts.info": "Бесплатная онлайн-служба TTS не требует ключа API и полностью бесплатна для использования.",
"error.no_mstts_voice": "Бесплатный онлайн голос TTS не установлен",
"play": "Воспроизвести речь",
"stop": "Остановить воспроизведение",
"speak": "Воспроизвести речь",
"stop_global": "Остановить все воспроизведения речи",
"stopped": "Воспроизведение речи остановлено",
"segmented": "Сегментация",
"segmented_play": "Сегментированное воспроизведение",
"segmented_playback": "Сегментированное воспроизведение",
"filter.emojis": "Фильтровать эмодзи",
"show_progress_bar": "Показать индикатор выполнения TTS"
},
"voice": {
"title": "Голосовые функции",
"help": "Голосовые функции включают преобразование текста в речь (TTS) и распознавание речи (ASR).",
"learn_more": "Узнать больше"
},
"asr": {
"title": "Распознавание речи",
"tab_title": "Распознавание речи",
"enable": "Включить распознавание речи",
"enable.help": "После включения можно преобразовывать речь в текст",
"service_type": "Тип службы",
"service_type.browser": "Браузер",
"service_type.local": "Локальный сервер",
"api_key": "Ключ API",
"api_key.placeholder": "Пожалуйста, введите ключ API OpenAI",
"api_url": "URL-адрес API",
"api_url.placeholder": "Например: https://api.openai.com/v1/audio/transcriptions",
"model": "Модель",
"browser.info": "Используйте встроенную функцию распознавания речи браузера, дополнительные настройки не требуются",
"local.info": "Используйте локальный сервер и браузер для распознавания речи, необходимо сначала запустить сервер и открыть страницу браузера",
"local.browser_tip": "Пожалуйста, откройте эту страницу в браузере и держите окно браузера открытым",
"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": "Не удалось распознать речь"
},
"auto_start_server": "Автоматически запускать сервер при запуске приложения",
"auto_start_server.help": "После включения сервер распознавания речи будет автоматически запускаться при запуске приложения"
},
"voice_call": {
"tab_title": "Функция вызова",
"enable": "Включить голосовой вызов",
"enable.help": "После включения вы сможете использовать функцию голосового вызова для разговора с ИИ",
"model": "Модель вызова",
"model.select": "Выбрать модель",
"model.current": "Текущая модель: {{model}}",
"model.info": "Выберите модель ИИ для голосовых вызовов. Разные модели могут обеспечивать различный опыт голосового взаимодействия",
"prompt": {
"label": "Подсказка для голосового вызова",
"placeholder": "Введите подсказку для голосового вызова",
"save": "Сохранить",
"reset": "Сбросить",
"saved": "Подсказка сохранена",
"reset_done": "Подсказка сброшена",
"info": "Эта подсказка будет направлять ответы ИИ в режиме голосового вызова"
},
"asr_tts_info": "Голосовой вызов использует настройки распознавания речи (ASR) и синтеза речи (TTS), указанные выше",
"test": "Тестировать голосовой вызов",
"test_info": "Используйте кнопку голосового вызова справа от поля ввода для тестирования",
"welcome_message": "Здравствуйте, я ваш ИИ-ассистент. Пожалуйста, нажмите и удерживайте кнопку разговора для начала диалога."
}
},
"translate": {
@@ -1436,6 +1612,28 @@
"quit": "Выйти",
"show_window": "Показать окно",
"visualization": "Визуализация"
},
"voice_call": {
"title": "Голосовой вызов",
"start": "Начать голосовой вызов",
"end": "Завершить вызов",
"mute": "Отключить звук",
"unmute": "Включить звук",
"pause": "Пауза",
"resume": "Продолжить",
"you": "Вы",
"ai": "ИИ",
"press_to_talk": "Нажмите и удерживайте для разговора",
"release_to_send": "Отпустите для отправки",
"initialization_failed": "Не удалось инициализировать голосовой вызов",
"error": "Ошибка голосового вызова",
"initializing": "Инициализация голосового вызова...",
"ready": "Голосовой вызов готов",
"shortcut_key_setting": "Настройки горячих клавиш для распознавания речи",
"press_any_key": "Нажмите любую клавишу...",
"save": "Сохранить",
"cancel": "Отмена",
"shortcut_key_tip": "Нажмите эту горячую клавишу, чтобы начать запись, отпустите, чтобы закончить запись и отправить"
}
}
}
}

View File

@@ -1,5 +1,27 @@
{
"translation": {
"voice_call": {
"title": "语音通话",
"start": "开始语音通话",
"end": "结束通话",
"mute": "静音",
"unmute": "取消静音",
"pause": "暂停",
"resume": "继续",
"you": "您",
"ai": "AI",
"press_to_talk": "长按说话",
"release_to_send": "松开发送",
"initialization_failed": "初始化语音通话失败",
"error": "语音通话出错",
"initializing": "正在初始化语音通话...",
"ready": "语音通话已就绪",
"shortcut_key_setting": "语音识别快捷键设置",
"press_any_key": "请按任意键...",
"save": "保存",
"cancel": "取消",
"shortcut_key_tip": "按下此快捷键开始录音,松开快捷键结束录音并发送"
},
"agents": {
"add.button": "添加到助手",
"add.knowledge_base": "知识库",
@@ -104,6 +126,13 @@
"default.description": "你好,我是默认助手。你可以立刻开始跟我聊天。",
"default.name": "默认助手",
"default.topic.name": "默认话题",
"tts": {
"play": "播放语音",
"stop": "停止播放",
"speak": "播放语音",
"stop_global": "停止所有语音播放",
"stopped": "已停止语音播放"
},
"history": {
"assistant_node": "助手",
"click_to_navigate": "点击跳转到对应消息",
@@ -1168,7 +1197,6 @@
"messages.input.enable_delete_model": "启用删除键删除输入的模型/附件",
"messages.markdown_rendering_input_message": "Markdown 渲染输入消息",
"messages.math_engine": "数学公式引擎",
"messages.math_engine.none": "无",
"messages.metrics": "首字时延 {{time_first_token_millsec}}ms | 每秒 {{token_speed}} tokens",
"messages.model.title": "模型设置",
"messages.navigation": "对话导航按钮",
@@ -1343,6 +1371,8 @@
"check": "检查",
"check_failed": "验证失败",
"check_success": "验证成功",
"enhance_mode": "搜索增强模式",
"enhance_mode_tooltip": "使用默认模型提取关键词后搜索",
"overwrite": "覆盖服务商搜索",
"overwrite_tooltip": "强制使用搜索服务商而不是大语言模型进行搜索",
"get_api_key": "点击这里获取密钥",
@@ -1396,6 +1426,171 @@
"privacy": {
"title": "隐私设置",
"enable_privacy_mode": "匿名发送错误报告和数据统计"
},
"voice": {
"title": "语音功能",
"help": "语音功能包括文本转语音(TTS)、语音识别(ASR)和语音通话。",
"learn_more": "了解更多"
},
"tts": {
"title": "语音合成",
"tab_title": "语音合成",
"enable": "启用语音合成",
"enable.help": "启用后可以将文本转换为语音",
"reset": "重置",
"reset_title": "重置自定义音色和模型",
"reset_confirm": "确定要重置所有自定义音色和模型吗?这将删除所有已添加的自定义项。",
"reset_success": "重置成功",
"reset_help": "如果音色或模型显示异常,可以尝试重置所有自定义项",
"api_settings": "API设置",
"service_type": "服务类型",
"service_type.openai": "OpenAI",
"service_type.edge": "浏览器 TTS",
"service_type.siliconflow": "硅基流动",
"service_type.mstts": "免费在线 TTS",
"service_type.refresh": "刷新TTS服务类型设置",
"service_type.refreshed": "已刷新TTS服务类型设置",
"siliconflow_api_key": "硅基流动API密钥",
"siliconflow_api_key.placeholder": "请输入硅基流动API密钥",
"siliconflow_api_url": "硅基流动API地址",
"siliconflow_api_url.placeholder": "例如https://api.siliconflow.cn/v1/audio/speech",
"siliconflow_voice": "硅基流动音色",
"siliconflow_voice.placeholder": "请选择音色",
"siliconflow_model": "硅基流动模型",
"siliconflow_model.placeholder": "请选择模型",
"siliconflow_response_format": "响应格式",
"siliconflow_response_format.placeholder": "默认为mp3",
"siliconflow_speed": "语速",
"siliconflow_speed.placeholder": "默认为1.0",
"api_key": "API密钥",
"api_key.placeholder": "请输入OpenAI API密钥",
"api_url": "API地址",
"api_url.placeholder": "例如https://api.openai.com/v1/audio/speech",
"edge_voice": "浏览器 TTS音色",
"edge_voice.loading": "加载中...",
"edge_voice.refresh": "刷新可用音色列表",
"edge_voice.not_found": "未找到匹配的音色",
"edge_voice.available_count": "可用语音: {{count}}个",
"edge_voice.refreshing": "正在刷新语音列表...",
"edge_voice.refreshed": "语音列表已刷新",
"mstts.voice": "免费在线 TTS音色",
"mstts.output_format": "输出格式",
"mstts.info": "免费在线TTS服务不需要API密钥完全免费使用。",
"error.no_mstts_voice": "未设置免费在线 TTS音色",
"voice": "音色",
"voice.placeholder": "请选择音色",
"voice_input_placeholder": "输入音色",
"voice_add": "添加",
"voice_empty": "暂无自定义音色,请在下方添加",
"model": "模型",
"model.placeholder": "请选择模型",
"model_input_placeholder": "输入模型",
"model_add": "添加",
"model_empty": "暂无自定义模型,请在下方添加",
"filter_options": "过滤选项",
"filter.thinking_process": "过滤思考过程",
"filter.markdown": "过滤Markdown标记",
"filter.code_blocks": "过滤代码块",
"filter.html_tags": "过滤HTML标签",
"filter.emojis": "过滤表情符号",
"max_text_length": "最大文本长度",
"show_progress_bar": "显示TTS进度条",
"test": "测试语音",
"help": "语音合成功能支持将文本转换为自然语音。",
"learn_more": "了解更多",
"play": "播放语音",
"stop": "停止播放",
"speak": "播放语音",
"stop_global": "停止所有语音播放",
"stopped": "已停止语音播放",
"segmented": "分段",
"segmented_play": "分段播放",
"segmented_playback": "分段播放",
"error": {
"not_enabled": "语音合成功能未启用",
"no_api_key": "未设置API密钥",
"no_voice": "未选择音色",
"no_model": "未选择模型",
"no_edge_voice": "未选择浏览器 TTS音色",
"browser_not_support": "浏览器不支持语音合成",
"synthesis_failed": "语音合成失败",
"play_failed": "语音播放失败",
"empty_text": "文本为空",
"general": "语音合成出现错误",
"unsupported_service_type": "不支持的服务类型: {{serviceType}}"
}
},
"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": "请在浏览器中打开此页面,并保持浏览器窗口打开",
"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": "已取消录音",
"auto_start_server": "启动应用自动开启服务器",
"auto_start_server.help": "启用后,应用启动时会自动开启语音识别服务器",
"error": {
"not_enabled": "语音识别功能未启用",
"no_api_key": "未设置API密钥",
"browser_not_support": "浏览器不支持语音识别",
"start_failed": "开始录音失败",
"transcribe_failed": "语音识别失败"
}
},
"voice_call": {
"tab_title": "通话功能",
"enable": "启用语音通话",
"enable.help": "启用后可以使用语音通话功能与AI进行对话",
"model": "通话模型",
"model.select": "选择模型",
"model.current": "当前模型: {{model}}",
"model.info": "选择用于语音通话的AI模型不同模型可能有不同的语音交互体验",
"welcome_message": "您好我是您的AI助手请长按说话按钮进行对话。",
"prompt": {
"label": "语音通话提示词",
"placeholder": "请输入语音通话提示词",
"save": "保存",
"reset": "重置",
"saved": "提示词已保存",
"reset_done": "提示词已重置",
"info": "此提示词将指导AI在语音通话模式下的回复方式"
},
"asr_tts_info": "语音通话使用上面的语音识别(ASR)和语音合成(TTS)设置",
"test": "测试通话",
"test_info": "请使用输入框右侧的语音通话按钮进行测试"
}
},
"translate": {

View File

@@ -104,6 +104,13 @@
"default.description": "你好,我是預設助手。你可以立即開始與我聊天。",
"default.name": "預設助手",
"default.topic.name": "預設話題",
"tts": {
"play": "播放語音",
"stop": "停止播放",
"speak": "播放語音",
"stop_global": "停止所有語音播放",
"stopped": "已停止語音播放"
},
"history": {
"assistant_node": "助手",
"click_to_navigate": "點擊跳轉到對應訊息",
@@ -1166,8 +1173,7 @@
"messages.input.enable_quick_triggers": "啟用 '/' 和 '@' 觸發快捷選單",
"messages.input.enable_delete_model": "啟用刪除鍵刪除模型/附件",
"messages.markdown_rendering_input_message": "Markdown 渲染輸入訊息",
"messages.math_engine": "數學公式引擎",
"messages.math_engine.none": "無",
"messages.math_engine": "Markdown 渲染輸入訊息",
"messages.metrics": "首字延遲 {{time_first_token_millsec}}ms | 每秒 {{token_speed}} tokens",
"messages.model.title": "模型設定",
"messages.navigation": "訊息導航",
@@ -1337,6 +1343,8 @@
"tray.title": "系统匣",
"websearch": {
"check_success": "驗證成功",
"enhance_mode": "搜索增強模式",
"enhance_mode_tooltip": "使用預設模型提取關鍵詞後搜索",
"get_api_key": "點選這裡取得金鑰",
"search_with_time": "搜尋包含日期",
"tavily": {
@@ -1396,6 +1404,171 @@
"privacy": {
"title": "隱私設定",
"enable_privacy_mode": "匿名發送錯誤報告和資料統計"
},
"tts": {
"title": "語音設定",
"enable": "啟用語音合成",
"enable.help": "啟用後可以將文字轉換為語音",
"reset": "重置",
"reset_title": "重置自定義音色和模型",
"reset_confirm": "確定要重置所有自定義音色和模型嗎?這將刪除所有已添加的自定義項。",
"reset_success": "重置成功",
"reset_help": "如果音色或模型顯示異常,可以嘗試重置所有自定義項",
"api_settings": "API設定",
"service_type": "服務類型",
"service_type.openai": "OpenAI",
"service_type.edge": "Edge TTS",
"test": "測試",
"error": {
"not_enabled": "語音合成未啟用",
"no_edge_voice": "未選擇Edge TTS音色",
"no_api_key": "未設定API金鑰",
"browser_not_support": "瀏覽器不支援語音合成",
"no_voice": "未選擇音色",
"no_model": "未選擇模型",
"synthesis_failed": "語音合成失敗",
"play_failed": "語音播放失敗",
"empty_text": "文本為空",
"general": "語音合成出現錯誤",
"unsupported_service_type": "不支援的服務類型: {{serviceType}}"
},
"help": "使用OpenAI的TTS API需要API金鑰。Edge TTS使用瀏覽器功能不需要API金鑰。",
"learn_more": "了解更多",
"tab_title": "語音合成",
"service_type.refresh": "刷新TTS服務類型設置",
"service_type.refreshed": "已刷新TTS服務類型設置",
"api_key": "API金鑰",
"api_key.placeholder": "請輸入OpenAI API金鑰",
"api_url": "API位址",
"api_url.placeholder": "例如https://api.openai.com/v1/audio/speech",
"edge_voice": "瀏覽器 TTS音色",
"edge_voice.loading": "載入中...",
"edge_voice.refresh": "刷新可用音色列表",
"edge_voice.not_found": "未找到符合的音色",
"voice": "音色",
"voice.placeholder": "請選擇音色",
"voice_input_placeholder": "輸入音色",
"voice_add": "新增",
"voice_empty": "暫無自訂音色,請在下方新增",
"model": "模型",
"model.placeholder": "請選擇模型",
"model_input_placeholder": "輸入模型",
"model_add": "新增",
"model_empty": "暫無自訂模型,請在下方新增",
"filter_options": "篩選選項",
"filter.thinking_process": "篩選思考過程",
"filter.markdown": "篩選Markdown標記",
"filter.code_blocks": "篩選程式碼區塊",
"filter.html_tags": "篩選HTML標籤",
"max_text_length": "最大文字長度",
"service_type.siliconflow": "矽基流動",
"service_type.mstts": "免費線上 TTS",
"siliconflow_api_key": "矽基流動API金鑰",
"siliconflow_api_key.placeholder": "請輸入矽基流動API金鑰",
"siliconflow_api_url": "矽基流動API位址",
"siliconflow_api_url.placeholder": "例如https://api.siliconflow.cn/v1/audio/speech",
"siliconflow_voice": "矽基流動音色",
"siliconflow_voice.placeholder": "請選擇音色",
"siliconflow_model": "矽基流動模型",
"siliconflow_model.placeholder": "請選擇模型",
"siliconflow_response_format": "回應格式",
"siliconflow_response_format.placeholder": "預設為mp3",
"siliconflow_speed": "語速",
"siliconflow_speed.placeholder": "預設為1.0",
"edge_voice.available_count": "可用語音: {{count}}個",
"edge_voice.refreshing": "正在刷新語音列表...",
"edge_voice.refreshed": "語音列表已刷新",
"mstts.voice": "免費線上 TTS音色",
"mstts.output_format": "輸出格式",
"mstts.info": "免費線上TTS服務不需要API金鑰完全免費使用。",
"error.no_mstts_voice": "未設定免費線上 TTS音色",
"play": "播放語音",
"stop": "停止播放",
"speak": "播放語音",
"stop_global": "停止所有語音播放",
"stopped": "已停止語音播放",
"segmented": "分段",
"segmented_play": "分段播放",
"segmented_playback": "分段播放",
"filter.emojis": "篩選表情符號",
"show_progress_bar": "顯示TTS進度條"
},
"voice": {
"title": "語音功能",
"help": "語音功能包括文字轉語音(TTS)和語音辨識(ASR)。",
"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位址",
"api_url.placeholder": "例如https://api.openai.com/v1/audio/transcriptions",
"model": "模型",
"browser.info": "使用瀏覽器內建的語音辨識功能,無需額外設定",
"local.info": "使用本地伺服器和瀏覽器進行語音辨識,需要先啟動伺服器並開啟瀏覽器頁面",
"local.browser_tip": "請在瀏覽器中開啟此頁面,並保持瀏覽器視窗開啟",
"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": "語音辨識失敗"
},
"auto_start_server": "啟動應用程式自動開啟伺服器",
"auto_start_server.help": "啟用後,應用程式啟動時會自動開啟語音辨識伺服器"
},
"voice_call": {
"tab_title": "通話功能",
"enable": "啟用語音通話",
"enable.help": "啟用後可以使用語音通話功能與AI進行對話",
"model": "通話模型",
"model.select": "選擇模型",
"model.current": "目前模型: {{model}}",
"model.info": "選擇用於語音通話的AI模型不同模型可能有不同的語音互動體驗",
"prompt": {
"label": "語音通話提示詞",
"placeholder": "請輸入語音通話提示詞",
"save": "保存",
"reset": "重置",
"saved": "提示詞已保存",
"reset_done": "提示詞已重置",
"info": "此提示詞將指導AI在語音通話模式下的回覆方式"
},
"asr_tts_info": "語音通話使用上面的語音識別(ASR)和語音合成(TTS)設置",
"test": "測試通話",
"test_info": "請使用輸入框右側的語音通話按鈕進行測試",
"welcome_message": "您好我是您的AI助理請長按說話按鈕進行對話。"
}
},
"translate": {
@@ -1436,6 +1609,28 @@
"quit": "結束",
"show_window": "顯示視窗",
"visualization": "視覺化"
},
"voice_call": {
"title": "語音通話",
"start": "開始語音通話",
"end": "結束通話",
"mute": "靜音",
"unmute": "取消靜音",
"pause": "暫停",
"resume": "繼續",
"you": "您",
"ai": "AI",
"press_to_talk": "長按說話",
"release_to_send": "放開傳送",
"initialization_failed": "初始化語音通話失敗",
"error": "語音通話出錯",
"initializing": "正在初始化語音通話...",
"ready": "語音通話已就緒",
"shortcut_key_setting": "語音辨識快速鍵設定",
"press_any_key": "請按任意鍵...",
"save": "儲存",
"cancel": "取消",
"shortcut_key_tip": "按下此快速鍵開始錄音,放開快速鍵結束錄音並傳送"
}
}
}
}

View File

@@ -1114,6 +1114,8 @@
"check": "Έλεγχος",
"check_failed": "Αποτυχία του έλεγχου",
"check_success": "Έλεγχος επιτυχής",
"enhance_mode": "Ρύθμιση βελτιστοποίησης αναζήτησης",
"enhance_mode_tooltip": "Αναζητήστε με βάση τις λέξεις-κλειδιά που αντικαταστάθηκαν από το πρότυπο μοντέλο",
"get_api_key": "Κάντε κλικ εδώ για να λάβετε το κλειδί",
"no_provider_selected": "Παρακαλούμε επιλέξτε παρόχο αναζήτησης πριν να ελέγξετε",
"search_max_result": "Αριθμός αποτελεσμάτων αναζήτησης",

View File

@@ -1114,6 +1114,8 @@
"check": "Comprobar",
"check_failed": "Verificación fallida",
"check_success": "Verificación exitosa",
"enhance_mode": "Modo de búsqueda mejorada",
"enhance_mode_tooltip": "Utilice el modelo predeterminado para extraer palabras clave y luego busque",
"get_api_key": "Haz clic aquí para obtener la clave",
"no_provider_selected": "Por favor, seleccione un proveedor de búsqueda antes de comprobar",
"search_max_result": "Número de resultados de búsqueda",

View File

@@ -1114,6 +1114,8 @@
"check": "Vérifier",
"check_failed": "Échec de la vérification",
"check_success": "Vérification réussie",
"enhance_mode": "Mode de recherche amélioré",
"enhance_mode_tooltip": "Utilisez le modèle par défaut pour extraire les mots-clés avant de rechercher",
"get_api_key": "Cliquez ici pour obtenir la clé",
"no_provider_selected": "Veuillez sélectionner un fournisseur de recherche avant de vérifier",
"search_max_result": "Nombre de résultats de recherche",

View File

@@ -1114,6 +1114,8 @@
"check": "Verificar",
"check_failed": "Verificação falhou",
"check_success": "Verificação bem-sucedida",
"enhance_mode": "Modo de pesquisa avançada",
"enhance_mode_tooltip": "Use o modelo padrão para extrair palavras-chave e depois pesquise",
"get_api_key": "Clique aqui para obter a chave",
"no_provider_selected": "Selecione um provedor de pesquisa antes de verificar",
"search_max_result": "Número de resultados da pesquisa",

View File

@@ -1,6 +1,6 @@
import './utils/analytics'
import KeyvStorage from '@kangfenmao/keyv-storage'
import * as Sentry from '@sentry/electron/renderer'
import { init as reactInit } from '@sentry/react'
import { startAutoSync } from './services/BackupService'
import { startNutstoreAutoSync } from './services/NutstoreService'
@@ -28,18 +28,7 @@ function initAutoSync() {
if (nutstoreAutoSync) {
startNutstoreAutoSync()
}
}, 8000)
}
export function initSentry() {
Sentry.init(
{
sendDefaultPii: true,
tracesSampleRate: 1.0,
integrations: [Sentry.browserTracingIntegration()]
},
reactInit as any
)
}, 2000)
}
initSpinner()

View File

@@ -1,7 +1,10 @@
import { HolderOutlined } from '@ant-design/icons'
import ASRButton from '@renderer/components/ASRButton'
import { QuickPanelListItem, QuickPanelView, useQuickPanel } from '@renderer/components/QuickPanel'
import TranslateButton from '@renderer/components/TranslateButton'
import VoiceCallButton from '@renderer/components/VoiceCallButton'
import { isGenerateImageModel, isVisionModel, isWebSearchModel } from '@renderer/config/models'
import { getDefaultVoiceCallPrompt } from '@renderer/config/prompts'
import db from '@renderer/databases'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { useKnowledgeBases } from '@renderer/hooks/useKnowledge'
@@ -19,10 +22,10 @@ import { getModelUniqId } from '@renderer/services/ModelService'
import { estimateMessageUsage, estimateTextTokens as estimateTxtTokens } from '@renderer/services/TokenService'
import { translateText } from '@renderer/services/TranslateService'
import WebSearchService from '@renderer/services/WebSearchService'
import { useAppDispatch } from '@renderer/store'
import store, { useAppDispatch } from '@renderer/store'
import { sendMessage as _sendMessage } from '@renderer/store/messages'
import { setSearching } from '@renderer/store/runtime'
import { Assistant, FileType, KnowledgeBase, KnowledgeItem, Message, Model, Topic } from '@renderer/types'
import { Assistant, FileType, KnowledgeBase, KnowledgeItem, MCPServer, Message, Model, Topic } from '@renderer/types'
import { classNames, delay, formatFileSize, getFileExtension } from '@renderer/utils'
import { getFilesFromDropEvent } from '@renderer/utils/input'
import { documentExts, imageExts, textExts } from '@shared/config/constant'
@@ -77,6 +80,8 @@ let _files: FileType[] = []
const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) => {
const [text, setText] = useState(_text)
// 用于存储语音识别的中间结果,不直接显示在输入框中
const [, setAsrCurrentText] = useState('')
const [inputFocus, setInputFocus] = useState(false)
const { assistant, addTopic, model, setModel, updateAssistant } = useAssistant(_assistant.id)
const {
@@ -107,6 +112,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
const [isTranslating, setIsTranslating] = useState(false)
const [selectedKnowledgeBases, setSelectedKnowledgeBases] = useState<KnowledgeBase[]>([])
const [mentionModels, setMentionModels] = useState<Model[]>([])
const [enabledMCPs, setEnabledMCPs] = useState<MCPServer[]>(assistant.mcpServers || [])
const [isDragging, setIsDragging] = useState(false)
const [textareaHeight, setTextareaHeight] = useState<number>()
const startDragY = useRef<number>(0)
@@ -121,6 +127,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
const quickPanel = useQuickPanel()
const showKnowledgeIcon = useSidebarIconShow('knowledge')
// const showMCPToolsIcon = isFunctionCallingModel(model)
const [tokenCount, setTokenCount] = useState(0)
@@ -166,6 +173,11 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
}
}, [textareaHeight])
// Reset to assistant knowledge mcp servers
useEffect(() => {
setEnabledMCPs(assistant.mcpServers || [])
}, [assistant.mcpServers])
const sendMessage = useCallback(async () => {
if (inputEmpty || loading) {
return
@@ -195,10 +207,8 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
userMessage.mentions = mentionModels
}
if (!isEmpty(assistant.mcpServers) && !isEmpty(activedMcpServers)) {
userMessage.enabledMCPs = activedMcpServers.filter((server) =>
assistant.mcpServers?.some((s) => s.id === server.id)
)
if (!isEmpty(enabledMCPs) && !isEmpty(activedMcpServers)) {
userMessage.enabledMCPs = activedMcpServers.filter((server) => enabledMCPs?.some((s) => s.id === server.id))
}
userMessage.usage = await estimateMessageUsage(userMessage)
@@ -220,9 +230,9 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
console.error('Failed to send message:', error)
}
}, [
activedMcpServers,
assistant,
dispatch,
enabledMCPs,
files,
inputEmpty,
loading,
@@ -230,7 +240,8 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
resizeTextArea,
selectedKnowledgeBases,
text,
topic
topic,
activedMcpServers
])
const translate = useCallback(async () => {
@@ -393,7 +404,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
}, [files.length, model, openSelectFileMenu, t, text, translate])
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
const isEnterPressed = event.keyCode == 13
const isEnterPressed = event.key === 'Enter'
// 按下Tab键自动选中${xxx}
if (event.key === 'Tab' && inputFocus) {
@@ -501,6 +512,9 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
// Reset to assistant default model
assistant.defaultModel && setModel(assistant.defaultModel)
// Reset to assistant knowledge mcp servers
!isEmpty(assistant.mcpServers) && setEnabledMCPs(assistant.mcpServers || [])
addTopic(topic)
setActiveTopic(topic)
@@ -711,10 +725,139 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
return newText
})
textareaRef.current?.focus()
})
}),
// 监听语音通话消息
EventEmitter.on(
EVENT_NAMES.VOICE_CALL_MESSAGE,
(data: {
text: string
model: any
isVoiceCall?: boolean
useVoiceCallModel?: boolean
voiceCallModelId?: string
}) => {
console.log('收到语音通话消息:', data)
// 先设置输入框文本
setText(data.text)
// 使用延时确保文本已经设置到输入框
setTimeout(() => {
// 直接调用发送消息函数而不检查inputEmpty
console.log('准备自动发送语音识别消息:', data.text)
// 直接使用正确的方式发送消息
// 创建用户消息
const userMessage = getUserMessage({
assistant,
topic,
type: 'text',
content: data.text
})
// 如果是语音通话消息,使用语音通话专用模型
if (data.isVoiceCall || data.useVoiceCallModel) {
// 从全局设置中获取语音通话专用模型
const { voiceCallModel } = store.getState().settings
// 打印调试信息
console.log('语音通话消息,尝试使用语音通话专用模型')
console.log('全局设置中的语音通话模型:', voiceCallModel ? JSON.stringify(voiceCallModel) : 'null')
console.log('事件中传递的模型:', data.model ? JSON.stringify(data.model) : 'null')
// 如果全局设置中有语音通话专用模型,优先使用
if (voiceCallModel) {
userMessage.model = voiceCallModel
console.log('使用全局设置中的语音通话专用模型:', voiceCallModel.name)
// 强制覆盖消息中的模型
userMessage.modelId = voiceCallModel.id
}
// 如果没有全局设置,但事件中传递了模型,使用事件中的模型
else if (data.model && typeof data.model === 'object') {
userMessage.model = data.model
console.log('使用事件中传递的模型:', data.model.name || data.model.id)
// 强制覆盖消息中的模型
userMessage.modelId = data.model.id
}
// 如果没有模型对象但有模型ID尝试使用模型ID
else if (data.voiceCallModelId) {
console.log('使用事件中传递的模型ID:', data.voiceCallModelId)
userMessage.modelId = data.voiceCallModelId
}
// 如果以上都没有,使用当前助手模型
else {
console.log('没有找到语音通话专用模型,使用当前助手模型')
}
}
// 非语音通话消息,使用当前助手模型
else if (data.model) {
const modelObj = assistant.model?.id === data.model.id ? assistant.model : undefined
if (modelObj) {
userMessage.model = modelObj
console.log('使用当前助手模型:', modelObj.name || modelObj.id)
}
}
// 如果是语音通话消息,创建一个新的助手对象,并设置模型和提示词
let assistantToUse = assistant
if (data.isVoiceCall || data.useVoiceCallModel) {
// 创建一个新的助手对象,以避免修改原始助手
assistantToUse = { ...assistant }
// 如果有语音通话专用模型,设置助手的模型
if (userMessage.model) {
assistantToUse.model = userMessage.model
console.log(
'为语音通话消息创建了新的助手对象,并设置了模型:',
userMessage.model.name || userMessage.model.id
)
}
// 获取用户自定义提示词
const { voiceCallPrompt } = store.getState().settings
// 使用自定义提示词或当前语言的默认提示词
const promptToUse = voiceCallPrompt || getDefaultVoiceCallPrompt()
// 如果助手已经有提示词,则在其后添加语音通话专属提示词
if (assistantToUse.prompt) {
assistantToUse.prompt += '\n\n' + promptToUse
} else {
assistantToUse.prompt = promptToUse
}
console.log('为语音通话消息添加了专属提示词')
}
// 分发发送消息的action
dispatch(_sendMessage(userMessage, assistantToUse, topic, {}))
// 清空输入框
setText('')
// 重置语音识别状态
setAsrCurrentText('')
console.log('已触发发送消息事件')
}, 300)
}
)
]
return () => unsubscribes.forEach((unsub) => unsub())
}, [addNewTopic, resizeTextArea])
}, [
addNewTopic,
resizeTextArea,
sendMessage,
model,
inputEmpty,
loading,
dispatch,
assistant,
topic,
setText
// getUserMessage 和 _sendMessage 是外部作用域值,不需要作为依赖项
])
useEffect(() => {
textareaRef.current?.focus()
@@ -764,6 +907,17 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
setSelectedKnowledgeBases(newKnowledgeBases ?? [])
}
const toggelEnableMCP = (mcp: MCPServer) => {
setEnabledMCPs((prev) => {
const exists = prev.some((item) => item.id === mcp.id)
if (exists) {
return prev.filter((item) => item.id !== mcp.id)
} else {
return [...prev, mcp]
}
})
}
const showWebSearchEnableModal = () => {
window.modal.confirm({
title: t('chat.input.web_search.enable'),
@@ -947,8 +1101,9 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
/>
)}
<MCPToolsButton
assistant={assistant}
ref={mcpToolsButtonRef}
enabledMCPs={enabledMCPs}
toggelEnableMCP={toggelEnableMCP}
ToolbarButton={ToolbarButton}
setInputValue={setText}
resizeTextArea={resizeTextArea}
@@ -992,6 +1147,32 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
</ToolbarMenu>
<ToolbarMenu>
<TranslateButton text={text} onTranslated={onTranslated} isLoading={isTranslating} />
<ASRButton
onTranscribed={(transcribedText, isFinal) => {
// 如果是空字符串,不做任何处理
if (!transcribedText) return
if (isFinal) {
// 最终结果,添加到输入框中
setText((prevText) => {
// 如果当前输入框为空,直接设置为识别的文本
if (!prevText.trim()) {
return transcribedText
}
// 否则,添加识别的文本到输入框中,用空格分隔
return prevText + ' ' + transcribedText
})
// 清除当前识别的文本
setAsrCurrentText('')
} else {
// 中间结果,保存到状态变量中,但不更新输入框
setAsrCurrentText(transcribedText)
}
}}
/>
<VoiceCallButton disabled={loading} />
{loading && (
<Tooltip placement="top" title={t('chat.input.pause')} arrow>
<ToolbarButton type="text" onClick={onPause} style={{ marginRight: -2, marginTop: 1 }}>

View File

@@ -1,12 +1,9 @@
import { QuickPanelListItem, useQuickPanel } from '@renderer/components/QuickPanel'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { useMCPServers } from '@renderer/hooks/useMCPServers'
import { EventEmitter } from '@renderer/services/EventService'
import { Assistant, MCPPrompt, MCPResource, MCPServer } from '@renderer/types'
import { Form, Input, Tooltip } from 'antd'
import { MCPPrompt, MCPResource, MCPServer } from '@renderer/types'
import { Form, Input, Modal, Tooltip } from 'antd'
import { Plus, SquareTerminal } from 'lucide-react'
import { FC, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
import React from 'react'
import { FC, useCallback, useEffect, useImperativeHandle, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router'
@@ -17,152 +14,40 @@ export interface MCPToolsButtonRef {
}
interface Props {
assistant: Assistant
ref?: React.RefObject<MCPToolsButtonRef | null>
enabledMCPs: MCPServer[]
setInputValue: React.Dispatch<React.SetStateAction<string>>
resizeTextArea: () => void
toggelEnableMCP: (server: MCPServer) => void
ToolbarButton: any
}
// 添加类型定义
interface PromptArgument {
name: string
description?: string
required?: boolean
}
interface MCPPromptWithArgs extends MCPPrompt {
arguments?: PromptArgument[]
}
interface ResourceData {
blob?: string
mimeType?: string
name?: string
text?: string
uri?: string
}
// 提取到组件外的工具函数
const extractPromptContent = (response: any): string | null => {
// Handle string response (backward compatibility)
if (typeof response === 'string') {
return response
}
// Handle GetMCPPromptResponse format
if (response && Array.isArray(response.messages)) {
let formattedContent = ''
for (const message of response.messages) {
if (!message.content) continue
// Add role prefix if available
const rolePrefix = message.role ? `**${message.role.charAt(0).toUpperCase() + message.role.slice(1)}:** ` : ''
// Process different content types
switch (message.content.type) {
case 'text':
formattedContent += `${rolePrefix}${message.content.text}\n\n`
break
case 'image':
if (message.content.data && message.content.mimeType) {
if (rolePrefix) {
formattedContent += `${rolePrefix}\n`
}
formattedContent += `![Image](data:${message.content.mimeType};base64,${message.content.data})\n\n`
}
break
case 'audio':
formattedContent += `${rolePrefix}[Audio content available]\n\n`
break
case 'resource':
if (message.content.text) {
formattedContent += `${rolePrefix}${message.content.text}\n\n`
} else {
formattedContent += `${rolePrefix}[Resource content available]\n\n`
}
break
default:
if (message.content.text) {
formattedContent += `${rolePrefix}${message.content.text}\n\n`
}
}
}
return formattedContent.trim()
}
// Fallback handling for single message format
if (response && response.messages && response.messages.length > 0) {
const message = response.messages[0]
if (message.content && message.content.text) {
const rolePrefix = message.role ? `**${message.role.charAt(0).toUpperCase() + message.role.slice(1)}:** ` : ''
return `${rolePrefix}${message.content.text}`
}
}
return null
}
const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, ToolbarButton, ...props }) => {
const MCPToolsButton: FC<Props> = ({
ref,
setInputValue,
resizeTextArea,
enabledMCPs,
toggelEnableMCP,
ToolbarButton
}) => {
const { activedMcpServers } = useMCPServers()
const { t } = useTranslation()
const quickPanel = useQuickPanel()
const navigate = useNavigate()
// Create form instance at the top level
const [form] = Form.useForm()
const { updateAssistant, assistant } = useAssistant(props.assistant.id)
const availableMCPs = activedMcpServers.filter((server) => enabledMCPs.some((s) => s.id === server.id))
// 使用 useRef 存储不需要触发重渲染的值
const isMountedRef = useRef(true)
useEffect(() => {
return () => {
isMountedRef.current = false
}
}, [])
const mcpServers = useMemo(() => assistant.mcpServers || [], [assistant.mcpServers])
const assistantMcpServers = useMemo(
() => activedMcpServers.filter((server) => mcpServers.some((s) => s.id === server.id)),
[activedMcpServers, mcpServers]
)
const buttonEnabled = assistantMcpServers.length > 0
const handleMcpServerSelect = useCallback(
(server: MCPServer) => {
if (assistantMcpServers.some((s) => s.id === server.id)) {
updateAssistant({ ...assistant, mcpServers: mcpServers?.filter((s) => s.id !== server.id) })
} else {
updateAssistant({ ...assistant, mcpServers: [...mcpServers, server] })
}
},
[assistant, assistantMcpServers, mcpServers, updateAssistant]
)
// 使用 useRef 缓存事件处理函数
const handleMcpServerSelectRef = useRef(handleMcpServerSelect)
handleMcpServerSelectRef.current = handleMcpServerSelect
useEffect(() => {
const handler = (server: MCPServer) => handleMcpServerSelectRef.current(server)
EventEmitter.on('mcp-server-select', handler)
return () => EventEmitter.off('mcp-server-select', handler)
}, [])
const buttonEnabled = availableMCPs.length > 0
const menuItems = useMemo(() => {
const newList: QuickPanelListItem[] = activedMcpServers.map((server) => ({
label: server.name,
description: server.description || server.baseUrl,
icon: <SquareTerminal />,
action: () => EventEmitter.emit('mcp-server-select', server),
isSelected: assistantMcpServers.some((s) => s.id === server.id)
action: () => toggelEnableMCP(server),
isSelected: enabledMCPs.some((s) => s.id === server.id)
}))
newList.push({
@@ -170,9 +55,8 @@ const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, Toolbar
icon: <Plus />,
action: () => navigate('/settings/mcp')
})
return newList
}, [activedMcpServers, t, assistantMcpServers, navigate])
}, [activedMcpServers, t, enabledMCPs, toggelEnableMCP, navigate])
const openQuickPanel = useCallback(() => {
quickPanel.open({
@@ -185,25 +69,97 @@ const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, Toolbar
}
})
}, [menuItems, quickPanel, t])
// Extract and format all content from the prompt response
const extractPromptContent = useCallback((response: any): string | null => {
// Handle string response (backward compatibility)
if (typeof response === 'string') {
return response
}
// 使用 useCallback 优化 insertPromptIntoTextArea
// Handle GetMCPPromptResponse format
if (response && Array.isArray(response.messages)) {
let formattedContent = ''
for (const message of response.messages) {
if (!message.content) continue
// Add role prefix if available
const rolePrefix = message.role ? `**${message.role.charAt(0).toUpperCase() + message.role.slice(1)}:** ` : ''
// Process different content types
switch (message.content.type) {
case 'text':
// Add formatted text content with role
formattedContent += `${rolePrefix}${message.content.text}\n\n`
break
case 'image':
// Format image as markdown with proper attribution
if (message.content.data && message.content.mimeType) {
const imageData = message.content.data
const mimeType = message.content.mimeType
// Include role if available
if (rolePrefix) {
formattedContent += `${rolePrefix}\n`
}
formattedContent += `![Image](data:${mimeType};base64,${imageData})\n\n`
}
break
case 'audio':
// Add indicator for audio content with role
formattedContent += `${rolePrefix}[Audio content available]\n\n`
break
case 'resource':
// Add indicator for resource content with role
if (message.content.text) {
formattedContent += `${rolePrefix}${message.content.text}\n\n`
} else {
formattedContent += `${rolePrefix}[Resource content available]\n\n`
}
break
default:
// Add text content if available with role, otherwise show placeholder
if (message.content.text) {
formattedContent += `${rolePrefix}${message.content.text}\n\n`
}
}
}
return formattedContent.trim()
}
// Fallback handling for single message format
if (response && response.messages && response.messages.length > 0) {
const message = response.messages[0]
if (message.content && message.content.text) {
const rolePrefix = message.role ? `**${message.role.charAt(0).toUpperCase() + message.role.slice(1)}:** ` : ''
return `${rolePrefix}${message.content.text}`
}
}
return null
}, [])
// Helper function to insert prompt into text area
const insertPromptIntoTextArea = useCallback(
(promptText: string) => {
setInputValue((prev) => {
const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement
if (!textArea) return prev + promptText
if (!textArea) return prev + promptText // Fallback if we can't find the textarea
const cursorPosition = textArea.selectionStart
const selectionStart = cursorPosition
const selectionEndPosition = cursorPosition + promptText.length
const newText = prev.slice(0, cursorPosition) + promptText + prev.slice(cursorPosition)
// 使用 requestAnimationFrame 优化 DOM 操作
requestAnimationFrame(() => {
setTimeout(() => {
textArea.focus()
textArea.setSelectionRange(selectionStart, selectionEndPosition)
resizeTextArea()
})
}, 10)
return newText
})
},
@@ -211,104 +167,102 @@ const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, Toolbar
)
const handlePromptSelect = useCallback(
(prompt: MCPPromptWithArgs) => {
const server = activedMcpServers.find((s) => s.id === prompt.serverId)
if (!server) return
(prompt: MCPPrompt) => {
// Using a 10ms delay to ensure the modal or UI updates are fully rendered before executing the logic.
setTimeout(async () => {
const server = enabledMCPs.find((s) => s.id === prompt.serverId)
if (server) {
try {
// Check if the prompt has arguments
if (prompt.arguments && prompt.arguments.length > 0) {
// Reset form when opening a new modal
form.resetFields()
const handlePromptResponse = async (response: any) => {
const promptContent = extractPromptContent(response)
if (promptContent) {
insertPromptIntoTextArea(promptContent)
} else {
throw new Error('Invalid prompt response format')
}
}
Modal.confirm({
title: `${t('settings.mcp.prompts.arguments')}: ${prompt.name}`,
content: (
<Form form={form} layout="vertical">
{prompt.arguments.map((arg, index) => (
<Form.Item
key={index}
name={arg.name}
label={`${arg.name}${arg.required ? ' *' : ''}`}
tooltip={arg.description}
rules={
arg.required ? [{ required: true, message: t('settings.mcp.prompts.requiredField') }] : []
}>
<Input placeholder={arg.description || arg.name} />
</Form.Item>
))}
</Form>
),
onOk: async () => {
try {
// Validate and get form values
const values = await form.validateFields()
const handlePromptWithArgs = async () => {
try {
form.resetFields()
const response = await window.api.mcp.getPrompt({
server,
name: prompt.name,
args: values
})
const result = await new Promise<Record<string, string>>((resolve, reject) => {
window.modal.confirm({
title: `${t('settings.mcp.prompts.arguments')}: ${prompt.name}`,
content: (
<Form form={form} layout="vertical">
{prompt.arguments?.map((arg, index) => (
<Form.Item
key={index}
name={arg.name}
label={`${arg.name}${arg.required ? ' *' : ''}`}
tooltip={arg.description}
rules={
arg.required ? [{ required: true, message: t('settings.mcp.prompts.requiredField') }] : []
}>
<Input placeholder={arg.description || arg.name} />
</Form.Item>
))}
</Form>
),
onOk: async () => {
try {
const values = await form.validateFields()
resolve(values)
} catch (error) {
reject(error)
}
},
onCancel: () => reject(new Error('cancelled')),
okText: t('common.confirm'),
cancelText: t('common.cancel')
})
})
// Extract and format prompt content from the response
const promptContent = extractPromptContent(response)
if (promptContent) {
insertPromptIntoTextArea(promptContent)
} else {
throw new Error('Invalid prompt response format')
}
const response = await window.api.mcp.getPrompt({
server,
name: prompt.name,
args: result
})
return Promise.resolve()
} catch (error: Error | any) {
if (error.errorFields) {
// This is a form validation error, handled by Ant Design
return Promise.reject(error)
}
await handlePromptResponse(response)
} catch (error: Error | any) {
if (error.message !== 'cancelled') {
window.modal.error({
Modal.error({
title: t('common.error'),
content: error.message || t('settings.mcp.prompts.genericError')
})
return Promise.reject(error)
}
},
okText: t('common.confirm'),
cancelText: t('common.cancel')
})
} else {
// If no arguments, get the prompt directly
const response = await window.api.mcp.getPrompt({
server,
name: prompt.name
})
// Extract and format prompt content from the response
const promptContent = extractPromptContent(response)
if (promptContent) {
insertPromptIntoTextArea(promptContent)
} else {
throw new Error('Invalid prompt response format')
}
}
} catch (error: Error | any) {
Modal.error({
title: t('common.error'),
content: error.message || t('settings.mcp.prompts.genericError')
content: error.message || t('settings.mcp.prompt.genericError')
})
}
}
}
const handlePromptWithoutArgs = async () => {
try {
const response = await window.api.mcp.getPrompt({
server,
name: prompt.name
})
await handlePromptResponse(response)
} catch (error: Error | any) {
window.modal.error({
title: t('common.error'),
content: error.message || t('settings.mcp.prompt.genericError')
})
}
}
requestAnimationFrame(() => {
const hasArguments = prompt.arguments && prompt.arguments.length > 0
if (hasArguments) {
handlePromptWithArgs()
} else {
handlePromptWithoutArgs()
}
})
}, 10)
},
[activedMcpServers, form, t, insertPromptIntoTextArea]
[enabledMCPs, form, t, extractPromptContent, insertPromptIntoTextArea] // Add form to dependencies
)
const promptList = useMemo(async () => {
const prompts: MCPPrompt[] = []
for (const server of activedMcpServers) {
for (const server of enabledMCPs) {
const serverPrompts = await window.api.mcp.listPrompts(server)
prompts.push(...serverPrompts)
}
@@ -317,9 +271,9 @@ const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, Toolbar
label: prompt.name,
description: prompt.description,
icon: <SquareTerminal />,
action: () => handlePromptSelect(prompt as MCPPromptWithArgs)
action: () => handlePromptSelect(prompt)
}))
}, [handlePromptSelect, activedMcpServers])
}, [handlePromptSelect, enabledMCPs])
const openPromptList = useCallback(async () => {
const prompts = await promptList
@@ -333,80 +287,99 @@ const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, Toolbar
const handleResourceSelect = useCallback(
(resource: MCPResource) => {
const server = activedMcpServers.find((s) => s.id === resource.serverId)
if (!server) return
setTimeout(async () => {
const server = enabledMCPs.find((s) => s.id === resource.serverId)
if (server) {
try {
// Fetch the resource data
const response = await window.api.mcp.getResource({
server,
uri: resource.uri
})
console.log('Resource Data:', response)
const processResourceContent = (resourceData: ResourceData) => {
if (resourceData.blob) {
if (resourceData.mimeType?.startsWith('image/')) {
const imageMarkdown = `![${resourceData.name || 'Image'}](data:${resourceData.mimeType};base64,${resourceData.blob})`
insertPromptIntoTextArea(imageMarkdown)
} else {
const resourceInfo = `[${resourceData.name || resource.name} - ${resourceData.mimeType || t('settings.mcp.resources.blobInvisible')}]`
insertPromptIntoTextArea(resourceInfo)
// Check if the response has the expected structure
if (response && response.contents && Array.isArray(response.contents)) {
// Process each resource in the contents array
for (const resourceData of response.contents) {
// Determine how to handle the resource based on its MIME type
if (resourceData.blob) {
// Handle binary data (images, etc.)
if (resourceData.mimeType?.startsWith('image/')) {
// Insert image as markdown
const imageMarkdown = `![${resourceData.name || 'Image'}](data:${resourceData.mimeType};base64,${resourceData.blob})`
insertPromptIntoTextArea(imageMarkdown)
} else {
// For other binary types, just mention it's available
const resourceInfo = `[${resourceData.name || resource.name} - ${resourceData.mimeType || t('settings.mcp.resources.blobInvisible')}]`
insertPromptIntoTextArea(resourceInfo)
}
} else if (resourceData.text) {
// Handle text data
insertPromptIntoTextArea(resourceData.text)
} else {
// Fallback for resources without content
const resourceInfo = `[${resourceData.name || resource.name} - ${resourceData.uri || resource.uri}]`
insertPromptIntoTextArea(resourceInfo)
}
}
} else {
// Handle legacy format or direct resource data
const resourceData = response
// Determine how to handle the resource based on its MIME type
if (resourceData.blob) {
// Handle binary data (images, etc.)
if (resourceData.mimeType?.startsWith('image/')) {
// Insert image as markdown
const imageMarkdown = `![${resourceData.name || resource.name}](data:${resourceData.mimeType};base64,${resourceData.blob})`
insertPromptIntoTextArea(imageMarkdown)
} else {
// For other binary types, just mention it's available
const resourceInfo = `[${resourceData.name || resource.name} - ${resourceData.mimeType || t('settings.mcp.resources.blobInvisible')}]`
insertPromptIntoTextArea(resourceInfo)
}
} else if (resourceData.text) {
// Handle text data
insertPromptIntoTextArea(resourceData.text)
} else {
// Fallback for resources without content
const resourceInfo = `[${resourceData.name || resource.name} - ${resourceData.uri || resource.uri}]`
insertPromptIntoTextArea(resourceInfo)
}
}
} catch (error: Error | any) {
Modal.error({
title: t('common.error'),
content: error.message || t('settings.mcp.resources.genericError')
})
}
} else if (resourceData.text) {
insertPromptIntoTextArea(resourceData.text)
} else {
const resourceInfo = `[${resourceData.name || resource.name} - ${resourceData.uri || resource.uri}]`
insertPromptIntoTextArea(resourceInfo)
}
}
requestAnimationFrame(async () => {
try {
const response = await window.api.mcp.getResource({
server,
uri: resource.uri
})
if (response?.contents && Array.isArray(response.contents)) {
response.contents.forEach((content: ResourceData) => processResourceContent(content))
} else {
processResourceContent(response as ResourceData)
}
} catch (error: Error | any) {
window.modal.error({
title: t('common.error'),
content: error.message || t('settings.mcp.resources.genericError')
})
}
})
}, 10)
},
[activedMcpServers, t, insertPromptIntoTextArea]
[enabledMCPs, t, insertPromptIntoTextArea]
)
// 优化 resourcesList 的状态更新
const [resourcesList, setResourcesList] = useState<QuickPanelListItem[]>([])
useEffect(() => {
let isMounted = true
const fetchResources = async () => {
const resources: MCPResource[] = []
for (const server of activedMcpServers) {
for (const server of enabledMCPs) {
const serverResources = await window.api.mcp.listResources(server)
resources.push(...serverResources)
}
if (isMounted) {
setResourcesList(
resources.map((resource) => ({
label: resource.name,
description: resource.description,
icon: <SquareTerminal />,
action: () => handleResourceSelect(resource)
}))
)
}
setResourcesList(
resources.map((resource) => ({
label: resource.name,
description: resource.description,
icon: <SquareTerminal />,
action: () => handleResourceSelect(resource)
}))
)
}
fetchResources()
return () => {
isMounted = false
}
}, [activedMcpServers, handleResourceSelect])
}, [handleResourceSelect, enabledMCPs])
const openResourcesList = useCallback(async () => {
const resources = resourcesList
@@ -445,5 +418,4 @@ const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, Toolbar
)
}
// 使用 React.memo 包装组件
export default React.memo(MCPToolsButton)
export default MCPToolsButton

View File

@@ -8,14 +8,16 @@ import type { Message } from '@renderer/types'
import { parseJSON } from '@renderer/utils'
import { escapeBrackets, removeSvgEmptyLines, withGeminiGrounding } from '@renderer/utils/formats'
import { findCitationInChildren } from '@renderer/utils/markdown'
import { sanitizeSchema } from '@renderer/utils/markdown'
import { isEmpty } from 'lodash'
import { type FC, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import ReactMarkdown, { type Components } from 'react-markdown'
import rehypeKatex from 'rehype-katex'
// @ts-ignore rehype-mathjax is not typed
// @ts-ignore next-line
import rehypeMathjax from 'rehype-mathjax'
import rehypeRaw from 'rehype-raw'
import rehypeSanitize from 'rehype-sanitize'
import remarkCjkFriendly from 'remark-cjk-friendly'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
@@ -24,26 +26,16 @@ import CodeBlock from './CodeBlock'
import ImagePreview from './ImagePreview'
import Link from './Link'
const ALLOWED_ELEMENTS =
/<(style|p|div|span|b|i|strong|em|ul|ol|li|table|tr|td|th|thead|tbody|h[1-6]|blockquote|pre|code|br|hr|svg|path|circle|rect|line|polyline|polygon|text|g|defs|title|desc|tspan|sub|sup)/i
const DISALLOWED_ELEMENTS = ['iframe']
interface Props {
message: Message
}
const remarkPlugins = [remarkMath, remarkGfm, remarkCjkFriendly]
const Markdown: FC<Props> = ({ message }) => {
const { t } = useTranslation()
const { renderInputMessageAsMarkdown, mathEngine } = useSettings()
const remarkPlugins = useMemo(() => {
const plugins = [remarkGfm, remarkCjkFriendly]
if (mathEngine !== 'none') {
plugins.push(remarkMath)
}
return plugins
}, [mathEngine])
const messageContent = useMemo(() => {
const empty = isEmpty(message.content)
const paused = message.status === 'paused'
@@ -52,17 +44,8 @@ const Markdown: FC<Props> = ({ message }) => {
}, [message, t])
const rehypePlugins = useMemo(() => {
const plugins: any[] = []
if (ALLOWED_ELEMENTS.test(messageContent)) {
plugins.push(rehypeRaw)
}
if (mathEngine === 'KaTeX') {
plugins.push(rehypeKatex as any)
} else if (mathEngine === 'MathJax') {
plugins.push(rehypeMathjax as any)
}
return plugins
}, [mathEngine, messageContent])
return [rehypeRaw, [rehypeSanitize, sanitizeSchema], mathEngine === 'KaTeX' ? rehypeKatex : rehypeMathjax]
}, [mathEngine])
const components = useMemo(() => {
const baseComponents = {
@@ -88,7 +71,6 @@ const Markdown: FC<Props> = ({ message }) => {
remarkPlugins={remarkPlugins}
className="markdown"
components={components}
disallowedElements={DISALLOWED_ELEMENTS}
remarkRehypeOptions={{
footnoteLabel: t('common.footnotes'),
footnoteLabelTagName: 'h4',

View File

@@ -1,8 +1,6 @@
import { useTheme } from '@renderer/context/ThemeProvider'
import { EventEmitter } from '@renderer/services/EventService'
import { ThemeMode } from '@renderer/types'
import { debounce, isEmpty } from 'lodash'
import React, { useCallback, useEffect, useRef } from 'react'
import React, { useEffect, useRef } from 'react'
import MermaidPopup from './MermaidPopup'
@@ -14,44 +12,20 @@ const Mermaid: React.FC<Props> = ({ chart }) => {
const { theme } = useTheme()
const mermaidRef = useRef<HTMLDivElement>(null)
const renderMermaidBase = useCallback(async () => {
if (!mermaidRef.current || !window.mermaid || isEmpty(chart)) return
try {
useEffect(() => {
if (mermaidRef.current && window.mermaid) {
mermaidRef.current.innerHTML = chart
mermaidRef.current.removeAttribute('data-processed')
await window.mermaid.initialize({
startOnLoad: true,
theme: theme === ThemeMode.dark ? 'dark' : 'default'
})
await window.mermaid.run({ nodes: [mermaidRef.current] })
} catch (error) {
console.error('Failed to render mermaid chart:', error)
if (window.mermaid.initialize) {
window.mermaid.initialize({
startOnLoad: true,
theme: theme === ThemeMode.dark ? 'dark' : 'default'
})
}
window.mermaid.contentLoaded()
}
}, [chart, theme])
const renderMermaid = useCallback(debounce(renderMermaidBase, 1000), [renderMermaidBase])
useEffect(() => {
renderMermaid()
// Make sure to cancel any pending debounced calls when unmounting
return () => renderMermaid.cancel()
}, [renderMermaid])
useEffect(() => {
setTimeout(renderMermaidBase, 0)
}, [])
useEffect(() => {
const removeListener = EventEmitter.on('mermaid-loaded', renderMermaid)
return () => {
removeListener()
renderMermaid.cancel()
}
}, [renderMermaid])
const onPreview = () => {
MermaidPopup.show({ chart })
}

Some files were not shown because too many files have changed in this diff Show More