Compare commits

...

260 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
5bfa13112a feat(locales): add locale cleanup functionality to after-pack script
- Introduced a new `remove-locales.js` script to handle the removal of unnecessary locale files based on the platform.
- Integrated the locale cleanup process into the `after-pack.js` script to ensure locales are managed during packaging.
2025-04-19 19:22:36 +08:00
kangfenmao
7f7db748a7 chore(version): 1.2.5 2025-04-19 18:58:53 +08:00
kangfenmao
55aac1cb9b lint: fix code format 2025-04-19 18:58:26 +08:00
kangfenmao
4663794ba6 feat(FeatureMenus, Footer): replace Ant Design icons with Lucide icons and enhance layout
- Updated icons in FeatureMenus from Ant Design to Lucide for improved visual consistency.
- Refactored Footer component to use Lucide icons and adjusted layout for better alignment and spacing.
- Enhanced styling of Tag components for a more cohesive design.
2025-04-19 18:54:17 +08:00
kangfenmao
e4de5331e0 Revert "feat: add chat message translate copy button (#4620)"
This reverts commit 8b462935b4.
2025-04-19 18:09:44 +08:00
kangfenmao
c7ed15684a feat(Messages): enhance citations display with improved styling and translation support
- Added a title for the citations list with translation using `useTranslation`.
- Introduced an `Info` icon next to the citations title.
- Updated the `CitationsContainer` styling for better visual appeal.
- Refactored citation rendering logic in `MessageContent` to streamline citation handling.
2025-04-19 17:44:44 +08:00
kangfenmao
1bb27ee3f9 feat: update Z.ai app configuration with additional styling and increment store version to 97 2025-04-19 17:28:23 +08:00
ousugo
579d7d1e5d refactor(ModelList): extract NameSpan component and adjust styling for better layout 2025-04-19 13:58:21 +08:00
one
36aa13c4f1 refactor: merge rehype plugins 2025-04-19 13:55:33 +08:00
one
c5161b9da4 refactor: use rehype-sanitize for html tags 2025-04-19 13:55:33 +08:00
karl
32c96daf1f feat: mcp configuration extraction logic optimization (#4918)
Co-authored-by: 寇佳龙 <koujialong@bonc.com.cn>
2025-04-19 10:45:22 +08:00
寇佳龙
30309c29ff fix: mcp search field redundancy 2025-04-19 10:39:44 +08:00
自由的世界人
8b462935b4 feat: add chat message translate copy button (#4620)
* feat: add chat message translate copy button

* Update MessageMenubar.tsx

* fix: copy button display
2025-04-19 10:36:57 +08:00
Simple4
d907344ca7 fix(obsidian): update title update logic 2025-04-19 10:24:45 +08:00
缘生
9b21c334cc feat: implement maximum backups feature in WebDAV settings (#5060)
* feat: implement maximum backups feature in WebDAV settings

- Add maximum backups feature to WebDAV settings

Signed-off-by: ysicing <i@ysicing.me>

* refactor: refactor backup file management for device-specific handling

- Change the order of hostname and device type in the backup file name
- Add filtering for backup files to manage device-specific backups
- Update logic to delete the oldest backup files based on the specific device count

Signed-off-by: ysicing <i@ysicing.me>

---------

Signed-off-by: ysicing <i@ysicing.me>
2025-04-19 10:22:03 +08:00
Asurada
3e1e814004 fix (UI): Resolve the issue of overly long file names and path names not being displayed correctly (#5054)
* feat(Messages): enhance file upload display with styled component for better UI

* feat(Inputbar): add file name truncation for better display in attachment preview

* feat(DataSettings): replace Typography.Text with PathText for improved path display and add text truncation styling

* feat(DataSettings): refactor path display with PathRow component for better layout and styling
2025-04-19 02:00:11 +08:00
Hantong Chen
1c5adc1329 feat(websearch): HTTP basic auth support for Searxng (#5009)
* feat(websearch): HTTP basic auth support for Searxng

* fix(websearch): schema migration

* refactor(i18n): update translations for basic authentication

* fix(websearch): 修正 `HTTP 认证` 相关翻译

* feat(websearch): 为 `HTTP 认证` 添加注释

---------

Co-authored-by: suyao <sy20010504@gmail.com>
2025-04-19 01:58:03 +08:00
SuYao
3360905275 feat: gemini reasoning budget support (#5052)
* feat(models): add Gemini 2.5 reasoning model identification and integrate reasoning effort logic in GeminiProvider

* feat(AiProvider): enhance usage tracking by adding thoughts_tokens and updating usage types
2025-04-19 01:27:20 +08:00
chenxi
0a28df132d fix(deepseek-reasoner) doesn't support successive user or assistant messages (#5051)
fix(deepseek-reasoner) does not support successive user or assistant messages
2025-04-19 01:21:27 +08:00
fullex
fd3d9f17b8 fix: should not download when autoupdate is false (#5029) 2025-04-18 16:28:20 +08:00
ousugo
73f8148a94 feat(models): update gemini model identification logic to be more general 2025-04-18 09:58:44 +08:00
fullex
456f0657a6 fix: Nutstore auto-sync when app starts (#5005)
feat: integrate Nutstore auto-sync functionality in initialization process
2025-04-17 21:57:24 +08:00
缘生
53f74725ed feat: add hostname retrieval for backup webdav (#5004)
fix #4705

Signed-off-by: ysicing <i@ysicing.me>
2025-04-17 21:21:39 +08:00
SuYao
4fa04a801a feat(UI): Support custcom css in mini window (#4255)
* feat(UI): enable custom CSS functionality with miniWindow

* feat(UI): implement custom CSS handling in IPC and update related components
2025-04-17 20:54:34 +08:00
Chen Tao
c5580f5b71 fix: knowledge citations (#4988) 2025-04-17 15:43:30 +08:00
one
f8f808c9f4 refactor: improve error boundary messsage (#4987) 2025-04-17 15:39:40 +08:00
ousugo
dbbd539207 fix(AddAgentPopup): update form handling and simplify prompt input layout 2025-04-17 15:30:42 +08:00
Asurada
703eae5777 fix(models): simplify OpenAI o-series model identification logic (#4985)
* fix(models): simplify OpenAI o-series model identification logic

* Update OpenAIProvider.ts

---------

Co-authored-by: Pleasurecruise <3196812536@qq.com>
2025-04-17 15:19:14 +08:00
Chen Tao
9438c8e6ff feat: LLM可以根据需求自行选择使用知识库或者网络搜索 (#4806) 2025-04-17 13:11:43 +08:00
beyondkmp
75f986087a chore(electron-builder): Simplify file renaming logic and remove space (#4919)
* chore(electron-builder): Disable universal installer option in NSIS configuration

* refactor(after-build): Change file handling to delete files with spaces and rename files in YAML data

- Updated the function to delete files containing spaces instead of renaming them.
- Enhanced YAML processing to rename files and their blockmaps, ensuring proper handling of setup and portable versions.
- Adjusted the final YAML output to reflect the new file names.

* refactor(after-build): Simplify file renaming logic and remove space handling script

- Updated the after-build script to rename artifact files by replacing spaces with hyphens.
- Removed the replace-spaces.js script as its functionality is now integrated into the after-build process.
- Adjusted the build process in package.json to reflect the changes in file handling.

* refactor(electron-builder): Update artifact build script reference and remove obsolete after-build script

- Changed the artifactBuildCompleted script reference in electron-builder.yml to point to the new script.
- Deleted the outdated after-build.js script, which is no longer needed for file handling.

* delete js-yml
2025-04-17 10:05:48 +08:00
Asurada
7ac8f480bb feat(models): add support for o3 and o4-mini models in vision and logo configurations (#4963) 2025-04-17 02:13:53 +08:00
SuYao
676ac21804 fix(GeminiProvider): update content configuration based on model type (#4960)
* fix(GeminiProvider): update content configuration based on model type

* chore(ApiService): comment out debug log for message output
2025-04-17 00:00:35 +08:00
SuYao
24ddd69cd5 refactor(Gemini): migrate generative-ai sdk to genai sdk (#4939)
* refactor(GeminiService): migrate to new Google GenAI SDK and update file handling methods

- Updated import statements to use the new Google GenAI SDK.
- Refactored file upload, retrieval, and deletion methods to align with the new SDK's API.
- Adjusted type definitions and response handling for improved type safety and clarity.
- Enhanced file listing and processing logic to utilize async iteration for better performance.

* refactor(GeminiProvider): update message handling and integrate abort signal support

- Refactored message content handling to align with updated type definitions, ensuring consistent use of Content type.
- Enhanced abort signal management for chat requests, allowing for better control over ongoing operations.
- Improved message processing logic to streamline user message history handling and response generation.
- Adjusted type definitions for message contents to enhance type safety and clarity.

* refactor(electron.vite.config): replace direct import of Vite React plugin with dynamic import

* fix(Gemini): clean up unused methods and improve property access

* fix(typecheck): update color properties to use CSS variables

* feat: 修改画图逻辑

* fix: import viteReact

---------

Co-authored-by: eeee0717 <chentao020717Work@outlook.com>
2025-04-16 23:13:22 +08:00
LiuVaayne
35c50b54a8 Feat/mcp oauth (#4837)
* feat: implement OAuth client provider and lockfile management

* feat: implement OAuth callback server and refactor authentication flow

* fix(McpService): restrict command handling to 'npx' for improved clarity

* refactor: make callbackPort optional in OAuthProviderOptions and clean up MCPService

* refactor: restructure OAuth handling by creating separate callback and provider classes, and remove unused utility functions
2025-04-16 22:07:32 +08:00
beyondkmp
ac0fe75078 chore: Update electron-builder configuration to remove architecture specifications for targets and adjust build scripts for Windows, macOS, and Linux to include both x64 and arm64 architectures. 2025-04-16 10:49:27 +08:00
nutstore-dev
f0c25f8108 refractor: nutstore sdk changed to JS version (#4913)
Co-authored-by: shlroland <shlroland1995@gmail.com>
2025-04-16 10:47:08 +08:00
W
c10e5a9ca4 fix: 快捷助手发起询问后没有清理掉输入框内的内容 (#4907) 2025-04-16 10:42:34 +08:00
Asurada
444abc9b88 fix(OpenAIProvider): Filter empty system prompts (#4896) 2025-04-16 02:15:06 +08:00
Cle2ment
2d130a8526 fix(ReadMe): the redirection error of the Maple Neon theme (#4893)
* 修复Maple Neon Theme的链接重定向错误.

* 修复中文和日语主题的排版错误,改为和英文README统一的列表.
2025-04-16 00:03:11 +08:00
liqihao
5061ee5c4d Fix(MCPService): 修复 getSystemPath 因硬编码 Shell 路径导致的兼容性问题 (#4853)
Fix(MCPService): Prioritize process.env.SHELL for PATH retrieval

Co-authored-by: liqihao <liqiha0@outlook.com>
2025-04-15 23:23:28 +08:00
心如止水自在如风
9e913f531c fix: MCP服务器添加server-filesystem时填写参数后启用时报错 (#4872)
fix: MCP服务器添加server-filesystem时填写地址后启用时报错

Co-authored-by: annan01 <annan01@qianxin.com>
2025-04-15 23:19:22 +08:00
Cle2ment
98130de8ac chore(README): 为各语言的README都添加了Maple-Neon主题. (#4891) 2025-04-15 23:14:14 +08:00
SuYao
b339b7b6d4 fix(OpenAIProvider): remove unnecessary o-series model stream restriction (#4889)
* fix(OpenAIProvider): remove unnecessary o-series model stream restriction

* lint(OpenAIProvider): remove redundant code
2025-04-15 22:53:34 +08:00
Hao He
eb8ee5ec02 feat(MessageContent): Add Collapsible Citations Display (#4285)
* feat(MessageContent): 添加引用内容折叠功能,优化用户界面交互

* feat(Citations): add hideTitle prop to control title visibility in CitationsList

* feat(Messages): add message update functionality and manage UI state for citations and web search

* fix: web search citation

---------

Co-authored-by: 自由的世界人 <3196812536@qq.com>
2025-04-15 22:47:20 +08:00
牡丹凤凰
a34e10cb0d Update README.ja.md 2025-04-15 19:45:40 +08:00
牡丹凤凰
fcae5b097b Update README.zh.md 2025-04-15 19:44:23 +08:00
牡丹凤凰
3cb339e480 Update README.md 2025-04-15 19:42:16 +08:00
SuYao
35224f5213 feat(model): add ModelCard schema and related types for input/output capabilities (#4812)
* feat(model): add ModelCard schema and related types for input/output capabilities

* refactor(model): make limits and price properties optional in ModelSchema

* feat(model): add textGeneration capability to ModelSchema
2025-04-15 19:40:13 +08:00
beyondkmp
b8b37fcd11 feat: integrate AxiosProxy for HTTP requests in rerankers and Copilot… (#4858)
* feat: integrate AoxisProxy for HTTP requests in rerankers and CopilotService

- Replaced direct axios calls with aoxisProxy in JinaReranker, SiliconFlowReranker, and VoyageReranker to utilize proxy settings.
- Introduced AoxisProxy service to manage axios instances with proxy configurations.
- Updated CopilotService to use aoxisProxy for API requests, ensuring consistent proxy handling across services.

* refactor(AxiosProxy): improve proxy handling and initialization logic

* fix tyop

* fix tyop
2025-04-15 18:42:31 +08:00
ousugo
615fda0547 fix(styles): add support for lucide icons in global styles 2025-04-15 17:44:50 +08:00
karl
0585d28312 feat/search mcp auto config (#4780)
feat: mcp search carry basic configuration

Co-authored-by: 寇佳龙 <koujialong@bonc.com.cn>
2025-04-15 16:05:17 +08:00
Teo
9ad40b9219 feat(QuickPanel): enhance pinyin filtering and improve input handling in QuickPanel 2025-04-15 15:58:44 +08:00
Asurada
18e99dee67 chore(issue-template): improve clarity and requirements in bug report checklist (#4847) 2025-04-15 13:29:16 +08:00
Asurada
43ef1d6815 feat(models): add gpt-4.1 model to visionAllowedModels (#4843) 2025-04-15 12:14:51 +08:00
Asurada
141904e61a feat(models): update GLM model list and add new GLM-Z1 reasoning models (#4836) 2025-04-15 11:07:17 +08:00
Chen Tao
247501c26c fix: websearch ui (#4840)
fix(ui)
2025-04-15 11:06:28 +08:00
自由的世界人
748252febc Update issue-management.yml (#4830)
* Update issue-management.yml

* Update issue-management.yml
2025-04-15 09:59:37 +08:00
自由的世界人
8ac4d07d6b feat: create issue-management.yml (#4822) 2025-04-15 04:03:01 +08:00
Asurada
5fbff8c1fe feat(Grok): add isGrokModel function and update systemMessage handling for Grok models (#4823) 2025-04-15 03:15:07 +08:00
Asurada
f0f44d5768 feat(Miniapp): add Z.ai mini app with logo and migration support (#4820) 2025-04-15 01:43:56 +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
beyondkmp
8c20bd6d8f update electron-builder to 26.0.13 2025-04-14 21:52:58 +08:00
kangfenmao
d1c2bbed1b refactor(ipc): remove Windows ARM update check from IPC handler and AboutSettings component 2025-04-14 20:04:48 +08:00
kangfenmao
f372ebe485 ci(after-build): move renameFilesWithSpaces call to the end of the process 2025-04-14 18:42:18 +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
kangfenmao
833873b95e chore(version): 1.2.4 2025-04-14 17:30:41 +08:00
LiuVaayne
1d211ee9f7 feat: mcp custom headers (#4800)
* Add support for custom HTTP headers in MCP servers

Allow users to configure custom HTTP headers for SSE and streamable HTTP
MCP server connections. This enables authentication and other API
requirements.

* Add custom headers i18n strings
2025-04-14 17:17:13 +08:00
LiuVaayne
c7ef2c5791 Feat/mcp tool response support image (#4787)
* Add typed MCPCallToolResponse interface and format converters

The commit introduces a structured response type for MCP tool calls with
proper handling for different content types (text, image, audio). It adds
provider-specific converter functions to transform tool responses into
the appropriate message format for OpenAI, Anthropic, and Gemini.

* Support vision models in tool call responses

Add isVisionModel parameter to tool response formatters to conditionally
handle content based on model capabilities. Non-vision models now receive
JSON stringified content, while vision models get proper multimedia parts.
2025-04-14 17:16:42 +08:00
kangfenmao
6e66721688 feat: add after-build script for renaming files and updating latest.yml
- Introduced a new script to rename files with spaces in the 'dist' directory.
- Updated 'latest.yml' to remove the first file entry and adjust paths accordingly.
- Enhanced build process for Windows to include the new script execution.
- Added js-yaml dependency for YAML file manipulation.
2025-04-14 17:14:45 +08:00
kangfenmao
ffc8a33ccf fix: emoji icon empty 2025-04-14 17:10:55 +08:00
Teo
81538a5446 feat: update icons in Inputbar and related components for consistency 2025-04-14 16:06:17 +08:00
fullex
e6b325dd88 fix: quickAssistant transalte not work by key select 2025-04-14 10:31:52 +08:00
kangfenmao
0e8c053cee feat: enhance styling and icon consistency across components 2025-04-14 09:34:13 +08:00
kangfenmao
24e46efa0c feat(migrate, websearch): enable enhanceMode in websearch and update migration logic 2025-04-14 09:34:13 +08:00
1600822305
4b5601734a 翻译 2025-04-14 00:16:56 +08:00
fullex
64200b00a9 fix: mac fullscreen changed when switch back through clicking dock icon 2025-04-13 23:34:55 +08:00
fullex
ab4fb7d1d6 fix: numpad enter not work 2025-04-13 23:34:20 +08:00
Reamd7
352731827c fix(MCPService): 增加获取系统 PATH 的功能, 修复 process.env.PATH 无法获取系统PATH的问题 (#4766) 2025-04-13 22:45:02 +08:00
LiuVaayne
e13a43d82a feat: Enhance web search with XML-based query extraction (#4770)
Add support for webpage summarization, direct URL references, and
better query processing using a structured XML format. Move web content
fetching to dedicated utility functions with improved error handling
and format options.
2025-04-13 22:42:14 +08:00
kangfenmao
e51de5b492 fix(Sidebar): rename Sparkle icon to Sparkles for consistency 2025-04-13 22:05:32 +08:00
africa1207
a54360cc69 feat: 优化webdav备份文件恢复管理功能 (#4699)
* feat: 优化webdav备份文件恢复管理功能

* fix: 恢复和删除操作更改为文字而非图标

* feat: 统一坚果云与webdav备份恢复功能
2025-04-13 21:52:16 +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
kangfenmao
88cbb27557 style(Sidebar, Messages, ModelSettings): update icon styles and clean up unused imports
- Added 'icon' class to various icons in Sidebar for consistent styling.
- Removed unused loading state from Messages component.
- Cleaned up iconStyle variable in ModelSettings as it was no longer needed.
2025-04-13 21:29:48 +08:00
LiuVaayne
e22d076d67 feat(MCP): add resource management features and localization support (#4746)
* feat(MCP): add resource management features and localization support

* feat(MCP): enhance resource handling with improved error messages and response structure

* fix(MCPToolsButton): add missing useEffect import for resource handling

---------

Co-authored-by: 亢奋猫 <kangfenmao@qq.com>
2025-04-13 21:08:57 +08:00
Teo
de7f806bbc hotfix: 优化一些issue反馈 (#4758)
feat(Inputbar, Settings): add backspace delete model functionality and localization updates

- Implemented a new setting to enable backspace key functionality for deleting models/attachments in the Inputbar.
- Added corresponding localization strings for English, Japanese, Russian, Chinese (Simplified and Traditional) in the i18n files.
- Updated the QuickPanelBody styling to inherit border-radius.
- Migrated the new setting to the state management for persistence.

Co-authored-by: 亢奋猫 <kangfenmao@qq.com>
2025-04-13 21:07:00 +08:00
kangfenmao
5f7d8652bc feat: new icon style 2025-04-13 21:03:19 +08:00
bjl101501
39c614e4db docs(README): Add 仿Claude样式主题 (#4751)
* Update README.md

* Add 仿Claude样式主题 in README.zh.md

* Add 仿Claude样式主题 in README.ja.md
2025-04-13 20:40:13 +08:00
George·Dong
412e8b03fc fix: Update dashscoop provider configuration and enhance model editing functionality (#4748)
* fix(provider config): update dashscoop new links

* feat(EditModelsPopup): add grouping function for bailian

* fix(isWebSearchModel): Correctly handle the priority of manually setting model support for web search
2025-04-13 20:38:25 +08:00
kangfenmao
486ccc1a15 fix(EditModelsPopup, ModelList): adjust avatar size and streamline model description rendering
- Reduced avatar size in ModelList for better alignment.
- Simplified rendering logic for model descriptions in EditModelsPopup to enhance readability.
2025-04-13 13:34:48 +08:00
kangfenmao
c6ab7b9326 style(MCPSettings): adjust layout and spacing in NpxSearch and MainContainer
- Updated MainContainer to use flex display for better layout.
- Increased margin in NpxSearch component for improved spacing.
- Adjusted ResultList to use two columns instead of three for better content presentation.
2025-04-13 11:00:07 +08:00
kangfenmao
41981acd77 feat(Settings): implement assistant icon type selection and localization updates 2025-04-13 10:45:47 +08:00
Teo
97ef7016d3 feat(AssistantItem): add emoji support and improve icon display logic 2025-04-13 10:01:03 +08:00
Teo
e4514bd04c refactor(AgentPage): Refactor AgentPage UI (#4737)
* refactor(AgentPage): Refactor AgentPage UI

* style(AgentCard): update HeaderInfoEmoji styling for improved layout and visual consistency

* fix(AgentCard): conditionally render HeaderInfoEmoji to prevent rendering of undefined

* feat(AgentsPage): add handleAddAgent function to streamline agent addition process

* style(AgentsPage): remove unnecessary whitespace in title rendering
2025-04-13 09:58:46 +08:00
王叔叔
f39bb9869b Update LICENSE (#4744) 2025-04-13 08:00:41 +08:00
purefkh
fc884d72af fix(UI): Correct citation tooltip style in light theme (#4738) 2025-04-12 21:48:14 +08:00
kangfenmao
883bdd6283 chore(version): 1.2.3 2025-04-12 20:47:08 +08:00
kangfenmao
fa19f41385 feat(Ipc): add architecture information and update check logic for Windows arm64 2025-04-12 20:42:20 +08:00
1600822305
a728e9866a Merge branch 'main' into 1600822305-patch-2 2025-04-12 20:25:19 +08:00
kangfenmao
8b95a131ec fix(SettingsTab): refine reasoning effort handling for Grok models
fix: #4735
2025-04-12 20:23:31 +08:00
kangfenmao
72e18fbcc1 feat(MCPSettings): enhance MCP server management and localization updates
- Added a new SVG icon for npm in the MCP settings.
- Introduced a custom hook `useMCPServer` for retrieving a specific MCP server by ID.
- Updated localization files to include new error messages for tool and prompt loading in English, Japanese, Russian, and Chinese.
- Refactored MCP settings components for improved navigation and state management, including the use of React Router for routing.
- Enhanced the Npx search functionality and UI for better user experience.
2025-04-12 19:47:36 +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
kangfenmao
b62c59eb52 style(SelectModelPopup): update background color animation for improved visual consistency 2025-04-12 17:02:17 +08:00
kangfenmao
ffe7702c1c style(QuickPanel): update font sizes and line height for improved readability 2025-04-12 16:41:39 +08:00
kangfenmao
1ed6320caf refactor(license.html): update structure and styling for improved readability and consistency 2025-04-12 16:41:26 +08:00
kangfenmao
315271ac35 Revert "fix(ChatNavigation): improve navigation button collapse functionality"
This reverts commit fb5ddaf9d5.
2025-04-12 16:12:34 +08:00
kangfenmao
0bd24f652d feat(NewContextButton): add styled container for responsive design
- Introduced a styled container to the NewContextButton component to hide it on smaller screens (max-width: 800px).
- Ensured the tooltip and button functionality remain intact while enhancing the component's layout.
2025-04-12 16:11:00 +08:00
kangfenmao
0e7c4e4bdd refactor(Inputbar, Messages): simplify clear topic functionality and improve message display logic
- Removed unused QuestionCircleOutlined icon and Popconfirm from Inputbar, replacing it with a direct button click for clearing topics.
- Refactored message display logic in Messages component to enhance clarity and maintainability, while preserving existing functionality.
2025-04-12 16:07:40 +08:00
kangfenmao
d4bf8da225 feat(CustomCollapse): enhance component with customizable styles and improve usage in EditModelsPopup 2025-04-12 15:57:50 +08:00
LiuVaayne
8eb6632620 Feat/improve UI mcp settings (#4717)
* feat(MCPSettings): implement server selection and navigation with back button

* chore(ui)

* chore(UI): npx search padding

* feat(NpxSearch): add server selection and navigation; update styles

---------

Co-authored-by: eeee0717 <chentao020717Work@outlook.com>
2025-04-12 15:31:52 +08:00
王叔叔
10225512f4 docs: Update LICENSE (#4723) 2025-04-12 15:31:33 +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
Hao He
76058bd749 feat(MessageTools): add error handling and status indicator for tool responses (#4712)
* feat(MessageTools): add error handling and status indicator for tool responses

* feat(i18n): add error message for tool invocation in English, Japanese, and Russian locales
2025-04-12 10:33:14 +08:00
Herio
a692ae7e9d fix(Messages): 调整ScrollContainer和Container的样式以减少底部空间 2025-04-12 10:28:06 +08:00
LiuVaayne
a70ca190ba Feat/mcp support MCP prompt (#4675)
* Add MCP prompt listing and retrieval functionality

* Add generic caching mechanism for MCP service methods

Refactor caching strategy by implementing a higher-order withCache function
to centralize cache logic and reduce code duplication. Separate implementation
details from caching concerns in listTools, listPrompts and getPrompt methods.

# Conflicts:
#	src/main/services/MCPService.ts

* Add MCP prompts listing feature

- Add IPC handlers for listing and getting prompts
- Create UI component to display available prompts in settings tab
- Improve error handling in MCP service methods

* fix(McpService): add error handling for tool and prompt listing methods

* feat(MCPSettings): enhance prompts and tools sections with improved UI and reset functionality

* feat(i18n): add tabs and prompts sections to localization files

* feat(MCPToolsButton): add MCP prompt list functionality to Inputbar

* feat(McpSettings, NpxSearch): improve user feedback with success messages on server addition

* feat(MCPService, MCPToolsButton): enhance prompt handling with caching and improved selection logic

* feat(MCPToolsButton): enhance prompt handling with argument support and error management

---------

Co-authored-by: Teo <cheesen.xu@gmail.com>
2025-04-12 10:27:48 +08:00
robot-AI
7c39116351 重构了memory.ts,增加了文件写入锁,解决了并行写入导致记忆文件错误的问题; (#4671)
优化了memory.json文件的加载过程,只加载一次,其它涉及图谱的操作均在内存中完成,提高效率;
注意新引入了async-mutex软件包,需要yarn install安装。
2025-04-11 22:03:57 +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
kangfenmao
04333535dd chore(version): 1.2.2 2025-04-11 14:43:02 +08:00
kangfenmao
a1dba93d27 feat(websearch): initialize subscribeSources in migrateConfig and update WebSearchState interface 2025-04-11 14:42:35 +08:00
Chen Tao
0842b7e84d fix(llm): rename settingsSlice to llmSlice for clarity (#4688) 2025-04-11 11:32:30 +08:00
kangfenmao
24d6d146c0 fix(scripts): update download URLs and default versions for bun and uv binaries 2025-04-11 11:25:37 +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
kangfenmao
978c3ea3cf feat(i18n): update subscription terminology in multiple languages for consistency 2025-04-10 22:12:27 +08:00
ousugo
a9eb235c43 refactor(SettingsTab): update reasoning effort change handler to use useCallback for performance optimization 2025-04-10 21:47:14 +08:00
ousugo
e0a47de8f7 feat(CodeBlock): add tooltips for collapse and copy buttons 2025-04-10 21:47:14 +08:00
ousugo
78a4696327 feat(models): add grok-3 support to FUNCTION_CALLING_MODELS 2025-04-10 21:46:48 +08:00
Asurada
57fa0aad38 feat(xAI): Add support for Grok-3-mini and update reasoning effort logic (#4657)
* feat(models): add grok-3-mini support and update reasoning effort logic in SettingsTab and OpenAIProvider

* feat(settings): update reasoning effort logic for Grok models and enhance localization in multiple languages

* fix(models): correct spelling of reasoning in model support functions

* fix(settings): correct spelling of reasoning_effort in OpenAIProvider
2025-04-10 18:43:20 +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
Chen Tao
2e0251aed7 feat: support ublacklist subscribe (#2974)
* feat: support ublacklist subscribe

* Merge branch 'main' into feat-ublacklist

* chore

* chore
2025-04-10 17:25:38 +08:00
ousugo
afd1381d7f refactor(CodeBlock): simplify header layout and adjust CollapseIcon position 2025-04-10 17:22:20 +08:00
LiuVaayne
c3b5cbee8f Clean up MCPService connections on app quit (#4647)
* Clean up MCPService connections on app quit

* Improve application shutdown error handling
2025-04-10 17:19:02 +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
kangfenmao
e1f255048e feat(models): add Qiniu models to SYSTEM_MODELS and update migration logic to initialize provider models
- Introduced new models for the Qiniu provider in SYSTEM_MODELS.
- Updated migration logic to populate Qiniu provider models if they are empty during state initialization.
2025-04-10 13:42:03 +08:00
kangfenmao
8a579be4c1 refactor(after-pack): rename function to keepArchNodeFiles and update logic for retaining architecture-specific node modules
close PR#4522
2025-04-10 13:15:41 +08:00
kangfenmao
efcffbaa30 feat(websearch): enhance web search provider settings and localization
- Updated web search provider settings to include API key and free status indicators.
- Improved localization for English, Japanese, Russian, Chinese, and Taiwanese languages to reflect new API key and free status fields.
- Refactored web search provider management to prevent duplicates and streamline provider addition during state migration.
- Adjusted UI components to conditionally render based on provider type, enhancing user experience.
2025-04-10 13:07:55 +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
LiuVaayne
f9c6bddae5 feat(search): support using google as default search provider (#4569)
* feat(websearch): implement search window functionality and enhance search service

* feat(DefaultProvider): integrate @mozilla/readability for improved content parsing

* Add LocalSearchProvider for web page scraping

AI: Change `provider` from private to protected in BaseWebSearchProvider and implement LocalSearchProvider for web searching with browser-based content extraction.

* Add web search provider management features

Implement addWebSearchProvider function to prevent duplicates,
automatically load default providers on initialization, fix
LocalSearchProvider implementation, and update local provider
identification logic.

* Improve web search with specialized search engine parsers

Add dedicated parsers for Google, Bing, and Baidu search results,
replacing the generic URL extraction approach. Enhance page loading
with proper wait mechanisms and window cleanup. Remove DuckDuckGo
provider as it's no longer supported.

* Simplify DefaultProvider to unimplemented placeholder

* Remove default search engine from initial state

* Improve web search providers config and display

Add configuration for local search providers, remove empty apiKey fields,
and enhance the UI by sorting providers alphabetically and showing
whether they require an API key.

* Add stderr logging for MCP servers

* Make search window initially hidden
2025-04-10 12:29:09 +08:00
司马琦昂
5e086a1686 fix: O3 config text-embedding-3-small duplicate 2025-04-10 10:20:34 +08:00
fullex
0db4c8b475 fix: [mac] issues related to fullscreen mode (#4618) 2025-04-10 09:02:53 +08:00
自由的世界人
d5fcef39d3 feat: add model provider logo upload (#4408)
* feat: add model provider logo upload

* Update index.tsx

* fix: upload image delete
2025-04-09 23:52:42 +08:00
1600822305
d6302bbc25 翻译 2025-04-09 20:56:27 +08:00
kangfenmao
5c44f71684 refactor(ModelList): replace FileItem with ListItem and HStack for improved layout and styling 2025-04-09 20:42:36 +08:00
1600822305
a95a2e01e0 去除边框 2025-04-09 20:17:46 +08:00
fullex
3462be2a2a fix:[mac] window level to show py input 2025-04-09 20:12:33 +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
Teo
a0be911dc9 feat: Optimize QuickPanel (#4604)
* feat(QuickPanel): enhance close action options and improve input handling

- Added 'enter_empty' as a new close action option for QuickPanel.
- Refactored input handling to include a delay before clearing search text after panel closure.
- Updated keyboard event handling to prevent default actions for specific keys.
- Improved styling for selected and focused states in QuickPanel components.
- Enhanced AttachmentPreview to utilize a separate FileNameRender component for better readability and functionality.

* feat(AttachmentPreview): enhance file icon rendering and styling

* feat(CustomTag): add closable functionality and improve styling

- Enhanced CustomTag component to support closable tags with an onClose callback.
- Updated styling for better visual integration and added hover effects for the close icon.
- Refactored usage of CustomTag in AttachmentPreview, KnowledgeBaseInput, and MentionModelsInput components for consistency.

* feat(SelectModelPopup, QuickPanel): update tag component and enhance search functionality

* feat(Inputbar, SettingsTab): add enable quick panel triggers setting and update translations

* feat(QuickPanel): integrate color library for dynamic styling and update package dependencies
2025-04-09 17:00:34 +08:00
magicdmer
f7f7d2bde8 fix: 解决聊天页面图片复制失败的问题和点击编辑回复的时候,不显示图片url的问题 (#4496)
* fix: 解决聊天页面图片复制失败的问题和点击编辑回复的时候,不显示图片url的问题

* fix: 解决chat模式,gemini-2.0-flash-exp-image-generation返回base64图片,无法复制的问题

* fix(MessageImage): Update the image copying feature to process base64 and URL formatted images based on their type

---------

Co-authored-by: magicdmer <magicdmer@163.com>
Co-authored-by: eeee0717 <chentao020717Work@outlook.com>
2025-04-09 16:23:25 +08:00
fullex
10efa444bf fix: missing ExportMenuOptions in persist leads to useSelector re-render a lot (#4593)
* fix: missing ExportMenuOptions in persist leads to useSelector re-render

* chore: cleanup
2025-04-09 13:54:15 +08:00
LiuVaayne
24e28b86cf feat(mcp): support MCP by prompt (#4476)
* feat: implement tool usage handling and system prompt building for AI providers

* refactor: streamline tool usage handling and remove unused code in OpenAIProvider and formats

* refactor: simplify tool usage handling in Anthropic and Gemini providers, and update prompt instructions

* refactor: remove unused function calling model checks and simplify MCP tools handling in Inputbar

* hidden tool use in message

* revert  import

* Add idx parameter to parseAndCallTools for unique tool IDs
2025-04-09 11:22:14 +08:00
Hao He
fa66d048d7 feat(AssistantsTab): add sorting functionality by Pinyin and update translations (#4507) 2025-04-09 09:23:11 +08:00
Vaayne
fe7a392116 fix(NpxSearch): update SearchResult type to use MCPServer type definition 2025-04-09 09:21:35 +08:00
Vaayne
c883fd85d8 feat(MCP): add StreamableHTTPClientTransport and update server type handling 2025-04-09 09:21:35 +08:00
suyao
aa73025568 feat(websearch): improve web search enablement logic 2025-04-09 09:19:42 +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
ZhuangYumin
9689f00214 fix: fix main-window fake show up in Wayland KDE 2025-04-09 00:12:33 +08:00
kangfenmao
3674cc4afe chore(version): 1.2.1 2025-04-08 23:19:52 +08:00
kangfenmao
1f21b99820 feat(ModelTagsWithLabel): enhance tag component styling and update layout
- Improved icon size handling in the ModelTagsWithLabel component for better visual consistency.
- Adjusted the layout of the tags to prevent wrapping and added horizontal scrolling for better usability.
- Updated the EditModelsPopup to increase its width for improved content display.
- Removed unnecessary CustomTag usage in ModelList for cleaner code.
2025-04-08 21:32:17 +08:00
kangfenmao
a3e10dd116 chore(version): 1.2.0 2025-04-08 20:29:54 +08:00
SuYao
ab1a5f18c9 feat(websearch): add overwrite functionality for search service (#4530)
* feat(websearch): add overwrite functionality for search service

- Introduced new settings to allow users to override the default search service.
- Updated localization files for English, Japanese, Russian, Simplified Chinese, and Traditional Chinese to include new overwrite options and tooltips.
- Modified relevant components and services to support the new overwrite feature in the web search settings.

* feat(websearch): enhance web search model integration

* chore(websearch): unnecessary return
2025-04-08 20:07:00 +08:00
kangfenmao
2a0d6eb08a feat: update dangbei miniapp integration and version bump
- Added `bodered` property to the dangbei miniapp configuration.
- Refactored migration logic to utilize a new `addMiniApp` function for cleaner code.
- Incremented store version from 91 to 92 for migration compatibility.
2025-04-08 19:59:32 +08:00
DemonJun
f78663f815 feat: add dangbei miniapp (#4552)
* feat: add dangbei miniapp

* compressed logo file

---------

Co-authored-by: demonjun <demonjun@foxmail.com>
2025-04-08 19:46:26 +08:00
kangfenmao
8bcb31071e feat(CustomCollapse, KnowledgeContent): enhance collapsible behavior and UI updates
- Added `activeKey` prop to `CustomCollapse` for better control over active panels.
- Updated styles in `CustomCollapse` for improved visual consistency.
- Refactored `KnowledgeContent` to include expand/collapse functionality for file and directory sections, enhancing user experience.
- Added translations for "collapse" in multiple languages.
- Minor style adjustments across various components for better UI consistency.
2025-04-08 19:38:15 +08:00
Teo
3aaa1848f0 style(ProviderSettings): Refactor ProviderSettings UI (#4475)
* chore(version): 1.1.19

* style(ProviderSettings): Refactor ProviderSettings UI

* style(CustomTag, ModelTagsWithLabel): enhance layout and styling for better UI consistency

* refactor(CustomTag, ModelTagsWithLabel, MentionModelsButton): update props handling and improve component usage

* feat(CustomTag, ModelTagsWithLabel): add tooltip support and improve label visibility based on container size

* fix(ModelTagsWithLabel): adjust maxWidth for non-Chinese languages to improve layout

* style(ModelList): add text overflow handling for list item names

* feat(ModelList): enhance group label with item count using CustomTag

* feat(FileItem): add style prop for customizable background color in FileItem component

* style(index.scss): update border color variables for improved UI consistency

* style(EditModelsPopup): update background color for model items to enhance visual distinction

* style(HealthCheckPopup): update button size for improved usability

* feat(CustomCollapse): add collapsible prop to customize collapse behavior

* chore: remove hover models color

---------

Co-authored-by: kangfenmao <kangfenmao@qq.com>
Co-authored-by: eeee0717 <chentao020717Work@outlook.com>
2025-04-08 19:37:11 +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
Vaayne
037027f1f4 fix(McpService): improve client connection handling with error logging 2025-04-08 17:28:09 +08:00
suyao
97c1d67cbf fix(formats): add optional chaining for grounding support properties to prevent errors 2025-04-08 17:27:06 +08:00
suyao
d38c4c7368 fix(MessageContent): handle optional chaining for grounding metadata and citations 2025-04-08 17:27:06 +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
Hamm
b1bd5d0531 refactor(reranker): 重构重排序功能以提高可维护性 (#4539)
* refactor(reranker): 重构重排序功能以提高可维护性

- 将 BaseReranker 类中的公共逻辑提取到受保护的方法中
- 优化了 JinaReranker、SiliconFlowReranker 和 VoyageReranker 的实现
- 新增 getRerankUrl 和 getRerankResult 方法以提高代码复用性
- 简化了重排序结果的处理逻辑

* refactor(reranker): 将 formatErrorMessage 方法的访问权限改为受保护

- 将 formatErrorMessage 方法的访问权限从公共 (public) 改为受保护 (protected)
- 这一更改限制了方法的访问范围,仅允许子类访问该方法
- 有助于提高代码的封装性和安全性
2025-04-08 16:53:31 +08:00
1600822305
b48af7e27b Add files via upload
TTS
2025-04-08 15:43:12 +08:00
one
1fcee6c829 fix: wrap inline code 2025-04-08 09:26:37 +08:00
MyPrototypeWhat
cbcebdc87a fix(NpxSearch): update search result mapping to use record name as ke… (#4491)
fix(NpxSearch): update search result mapping to use record name as key and handle optional search results

Co-authored-by: lizhixuan <zhixuan.li@banosuperapp.com>
2025-04-07 19:45:40 +08:00
kangfenmao
96df9f6979 chore(version): 1.1.19 2025-04-07 16:04:48 +08:00
kangfenmao
4ef9d52694 Revert "refactor(Navbar): replace FormOutlined with SearchOutlined and update tooltip functionality"
This reverts commit d8baf378ea.
2025-04-07 14:34:12 +08:00
kangfenmao
41b9f8dbd5 feat(mcp): add automatic MCP server registration for auto-install tool
- Implemented functionality to automatically register MCP servers when the '@cherry/mcp-auto-install' tool is called.
- Utilized nanoid for unique server ID generation and dispatched the new server to the Redux store.
2025-04-07 14:33:43 +08:00
kangfenmao
8191791036 refactor(mcp): move inMemory servers to npx search scope
- Updated error handling in FileSystemServer to throw errors instead of exiting the process.
- Enhanced MCPService to use a more flexible server name check and updated the path for MCP_REGISTRY_PATH.
- Refined Inputbar to conditionally set enabled MCPs only if they are not empty.
- Cleaned up MCPToolsButton and MCPSettings by removing unused imports and effects.
- Updated NpxSearch to include a new npm scope and improved search result handling.
- Enhanced builtin MCP server descriptions for better clarity.
2025-04-07 14:07:01 +08:00
fullex
99b37f2782 perf: remove unused codes related to minapp (#4444) 2025-04-07 09:51:10 +08:00
fullex
da49c3ddd3 fix: remove sidebar minapp animation to stop gpu high load 2025-04-07 09:37:10 +08:00
one
6891068ca1 perf: prevent unnecessary reflow 2025-04-07 09:36:46 +08:00
kangfenmao
b361001f39 fix(WindowService): conditionally hide dock icon for macOS when closing to tray 2025-04-06 21:32:56 +08:00
kangfenmao
3823912b3e feat(i18n): add user and system labels to multiple language files 2025-04-06 21:27:37 +08:00
one
b5ad77e70c fix: LRU cache import 2025-04-06 21:11:37 +08:00
eeee0717
3491eec86b fix(websearch): improve error handling and response validation 2025-04-06 21:03:38 +08:00
kangfenmao
581ad5fbda feat: add qiniu ai provider 2025-04-06 18:50:35 +08:00
Teo
90424808ab refactor(Inputbar): streamline Backspace handling and update knowledge base management 2025-04-06 18:49:50 +08:00
kangfenmao
c884b11f01 feat(MCPSettings): enhance server management with segmented control and improved layout 2025-04-06 16:59:09 +08:00
kangfenmao
a530ce652e fix(useAssistant): ensure safe access to assistant ID in setModel callback 2025-04-06 14:40:32 +08:00
Shelly
9c052dee5c fix: 🐛 LRUCache export name error (#4420)
BREAKING CHANGE: 🧨 SyntaxError: The requested module
'/@fs/Users/bary/code/tools/cherry-studio/node_modules/.vite/deps/lru-cache.js?v=9139ab94'
does not provide an export named 'LRUCache' (at
CodeCacheService.ts:2:10)
2025-04-06 12:41:30 +08:00
LiuVaayne
1085c11240 bugfix(MCP): ensure memory path exists on initialization and remove unused… (#4418)
* bugfix: ensure memory path exists on initialization and remove unused everything mcp server

* refactor(factory): remove unused EverythingServer import and case

* fix(CodeCacheService): update import statement for LRUCache to default import

Error: src/renderer/src/services/CodeCacheService.ts(2,10): error TS2595: 'LRUCache' can only be imported by using a default import.
2025-04-06 12:40:51 +08:00
kangfenmao
ae6097a29e chore(dependencies): update libsql and add sindresorhus/is package
- Updated libsql patch reference in package.json.
- Added sindresorhus/is package to yarn.lock with version 7.0.1.
- Removed duplicate sindresorhus/is entry from yarn.lock.
2025-04-06 10:51:41 +08:00
kangfenmao
d74f05f27e refactor(Inputbar, Markdown): optimize citation handling and improve performance
- Refactored AttachmentButton to use useMemo for extensions calculation.
- Simplified citation extraction in Markdown by moving logic to a new utility function.
- Updated TopicsTab dependencies for better performance and reactivity.
2025-04-06 10:49:52 +08:00
Sophon
8501ab82c6 build: Add support for Windows ARM64 platform (#3431)
Co-authored-by: 亢奋猫 <kangfenmao@qq.com>
2025-04-06 09:35:08 +08:00
SuYao
f2ca56a088 feat(UI, OpenAI): support OpenAI-4o-web-search add support for web search citations (#3524)
* feat(UI, OpenAI): support  OpenAI 4o web search add support for web search citations

- refactor: Introduced a new CitationsList component to display citations in MessageContent.
- feat: Enhanced message handling to support web search results and annotations from OpenAI.
- refactor: Removed the deprecated MessageSearchResults component for cleaner code structure.
- refactor: Added utility functions for link conversion and URL extraction from Markdown.

* chore: remove debug logging from ProxyManager

* revert(OpenAIProvider): streamline reasoning check for stream output handling

* chore(OpenAIProvider): correct placement of webSearch in response object

* fix(patches): update OpenAI package version and remove patch references

- Integrated dayjs for dynamic date formatting in prompts.ts.

* feat(Citation, Favicon): enhance OpenAI web search support and citation handling

- Improved FallbackFavicon component to cache failed favicon URLs.
- Support all web search citation preview
- Added support for Hunyuan search model in OpenAIProvider and ApiService.

* refactor(provider/AI): move additional search parameters to AI Provider
2025-04-06 09:11:59 +08:00
kangfenmao
e02c967f5b fix(Markdown): Conditionally apply MarkdownShadowDOMRenderer for style components based on message content 2025-04-06 09:11:23 +08:00
PilgrimLyieu
7284679907 feat: Support bottom anchor in message anchor line 2025-04-06 08:50:05 +08:00
George·Dong
56e9a7371a refactor(export): 添加导出菜单选项设置、思维链导出功能 (#4168)
* refactor(settings): Add export menu setting & optimize data settings page

* feat: add dynamic export menu options from Redux state in MessageMenubar and TopicsTab

* feat(export): Add export to markdown with reasoning method

* feat(export): optimize reasoning style

* feat(export): Add export to markdown with reasoning to export menu

* feat(i18n): Update i18n for new export options

---------

Co-authored-by: 亢奋猫 <kangfenmao@qq.com>
2025-04-06 08:43:54 +08:00
one
95639df35c perf(CodeBlock): improve long codeblock loading experience (#4167)
* perf(CodeBlock): improve long codeblock loading experience

* refactor: use requestIdleCallback rather than observer

* refactor: simplify setting expanded and unwrapped

* refactor: simplify logic

* refactor: revert to observer

* fix: turn mermaid listener to passive to avoid scrolling performance downgrade

* feat: add lru cache for syntax highlighting

* refactor: adjust cache options

* feat: add highlighter cache

* fix: highlighter should be loaded before highlighting

* refactor: reduce cache time

* refactor: adjust cache size and hash

* refactor: decrease cache size

* fix: restore the behaviour of ShowExpandButton

* fix: check streaming status

* fix: empty code

* refactor: improve streaming check

* fix: optimizeDeps excludes

* refactor: adjust cache policy

* feat: add a setting for code caching

* feat: add more settings for code cache

* fix: initialize service

* refactor: prevent accident cache reset, update settings

* refactor: update code cache service

* fix: revert unecessary changes

* refactor: adjust cache settings

* fix: update migrate version

* chore: update to shiki v3

* fix: import path

* refactor: remove highlighter cache, improve fallbacks

* fix: revert path changes

* style: fix lint errors

* style: improve readability

* style: improve readability

* chore: update migrate version

* chore: update packages
2025-04-06 08:38:02 +08:00
PeterWang-dev
641dfc60b0 feat(icons): explicitly add icons in multiple resolutions to correct appimage icon path 2025-04-06 08:28:10 +08:00
kangfenmao
46c7df6f5b refactor(GraphRAG): Remove GraphRAG related files and references from the project 2025-04-06 08:11:10 +08:00
one
b828d1f54f perf(QuickPanel): improve search responsiveness (#4406) 2025-04-06 01:06:54 +08:00
kangfenmao
d9abfc5443 feat(database): Add version 6 with new 'files' store and update existing stores 2025-04-05 20:59:36 +08:00
kangfenmao
7364646caa feat(MCPSettings): Reset form change state on server ID change and disable server type selection for inMemory servers 2025-04-05 20:58:36 +08:00
kangfenmao
5fa7465174 refactor(Inputbar): Update file name handling in AttachmentPreview, adjust padding in Inputbar, and enhance textarea expansion logic 2025-04-05 20:33:44 +08:00
Teo
bc02727633 feat(QuickPanel): Optimize QuickPanel (#4404)
feat(QuickPanel): Add footer resizing and improve item action handling
2025-04-05 20:19:43 +08:00
kangfenmao
c76f274562 feat: google analytics 2025-04-05 16:07:04 +08:00
Teo
f9be0e0d26 feat(QuickPanel): Add new feature QuickPanel, unify input box operation. (#4356)
* feat(QuickPanel): Add new feature QuickPanel, unify input box operation.

* refactor(Inputbar): Remove unused quickPanelSymbol reference and update navigation action in KnowledgeBaseButton

* fix(Inputbar): Prevent translation action when input text is empty and reorder MentionModelsInput component

* refactor(Inputbar): Add resizeTextArea prop to QuickPhrasesButton for better text area management

* feat(i18n): Add translation strings for input actions and quick phrases in multiple languages

* feat(Inputbar): Enhance AttachmentButton to support ref forwarding and quick panel opening

* feat(i18n, Inputbar): Add upload file translation strings and enhance file count display in multiple languages

* style(QuickPanel): Update background color for QuickPanelBody and add dark theme support

* fix(Inputbar): Update upload label for vision model support

* feat(QuickPanel): Add outside click handling and update close action type

* feat(QuickPanel): Improve scrolling behavior with key press handling and add PageUp/PageDown support

* feat(i18n): Add translation strings for menu description in multiple languages

* refactor(QuickPhrasesButton): simplify phrase mapping by removing index-based disabling

* fix(QuickPanel): correct regex pattern for search functionality

* refactor(QuickPanel): remove searchText state and related logic for cleaner context management

* refactor(QuickPanel): enhance search text handling and input management

* refactor(Inputbar): update file name handling in AttachmentPreview and Inputbar components
2025-04-05 16:05:28 +08:00
LiuVaayne
ea059d5517 feat(mcp): add in-memory MCP server support and configuration management (#4359) 2025-04-05 14:17:56 +08:00
kangfenmao
9c6de71fbb lint: fix eslint error 2025-04-05 10:52:45 +08:00
Hamm
3290ac4b1b refactor(Constants): 优化一些常量和枚举值 (#3773)
* refactor(main): 使用枚举管理 IPC 通道

- 新增 IpcChannel 枚举,用于统一管理所有的 IPC 通道
- 修改相关代码,使用 IpcChannel 枚举替代硬编码的字符串通道名称
- 此改动有助于提高代码的可维护性和可读性,避免因通道名称变更导致的错误

* refactor(ipc): 将字符串通道名称替换为 IpcChannel 枚举

- 在多个文件中将硬编码的字符串通道名称替换为 IpcChannel 枚举值
- 更新了相关文件的导入,增加了对 IpcChannel 的引用
- 通过使用枚举来管理 IPC 通道名称,提高了代码的可维护性和可读性

* refactor(ipc): 调整 IPC 通道枚举和预加载脚本

- 移除了 IpcChannel 枚举中的未使用注释
- 更新了预加载脚本中 IpcChannel 的导入路径

* refactor(ipc): 更新 IpcChannel导入路径

- 将 IpcChannel 的导入路径从 @main/enum/IpcChannel 修改为 @shared/IpcChannel
- 此修改涉及多个文件,包括 AppUpdater、BackupManager、EditMcpJsonPopup 等
- 同时移除了 tsconfig.web.json 中对 src/main/**/* 的引用

* refactor(ipc): 添加 ReduxStoreReady 事件并更新事件监听

- 在 IpcChannel 枚举中添加 ReduxStoreReady 事件
- 更新 ReduxService 中的事件监听,使用新的枚举值

* refactor(main): 重构 ReduxService 中的状态变化事件处理

- 将状态变化事件名称定义为常量 STATUS_CHANGE_EVENT
- 更新事件监听和触发使用新的常量
- 优化了代码结构,提高了可维护性

* refactor(i18n): 优化国际化配置和语言选择逻辑

- 在多个文件中引入 defaultLanguage 常量,统一默认语言设置
- 调整 i18n 初始化和语言变更逻辑,使用新配置
- 更新相关组件和 Hook 中的语言选择逻辑

* refactor(ConfigManager): 重构配置管理器

- 添加 ConfigKeys 枚举,用于统一配置项的键名
- 引入 defaultLanguage,作为默认语言设置
- 重构 get 和 set 方法,使用 ConfigKeys 枚举作为键名
- 优化类型定义和方法签名,提高代码可读性和可维护性

* refactor(ConfigManager): 重命名配置键 ZoomFactor

将配置键 zoomFactor 重命名为 ZoomFactor,以符合命名规范。
更新了相关方法和属性以反映这一变更。

* refactor(shared): 重构常量定义并优化文件大小格式化逻辑

- 在 constant.ts 中添加 KB、MB、GB 常量定义
- 将 defaultLanguage 移至 constant.ts
- 更新 ConfigManager、useAppInit、i18n、GeneralSettings 等文件中的导入路径
- 优化 formatFileSize 函数,使用新定义的常量

* refactor(FileSize): 使用 GB/MB/KB 等常量处理文件大小计算

* refactor(ipc): 将字符串通道名称替换为 IpcChannel 枚举

- 在多个文件中将硬编码的字符串通道名称替换为 IpcChannel 枚举值
- 更新了相关文件的导入,增加了对 IpcChannel 的引用
- 通过使用枚举来管理 IPC 通道名称,提高了代码的可维护性和可读性

* refactor(ipc): 更新 IpcChannel导入路径

- 将 IpcChannel 的导入路径从 @main/enum/IpcChannel 修改为 @shared/IpcChannel
- 此修改涉及多个文件,包括 AppUpdater、BackupManager、EditMcpJsonPopup 等
- 同时移除了 tsconfig.web.json 中对 src/main/**/* 的引用

* refactor(i18n): 优化国际化配置和语言选择逻辑

- 在多个文件中引入 defaultLanguage 常量,统一默认语言设置
- 调整 i18n 初始化和语言变更逻辑,使用新配置
- 更新相关组件和 Hook 中的语言选择逻辑

* refactor(shared): 重构常量定义并优化文件大小格式化逻辑

- 在 constant.ts 中添加 KB、MB、GB 常量定义
- 将 defaultLanguage 移至 constant.ts
- 更新 ConfigManager、useAppInit、i18n、GeneralSettings 等文件中的导入路径
- 优化 formatFileSize 函数,使用新定义的常量

* refactor: 移除重复的导入语句

- 在 HomeWindow.tsx 和 useAppInit.ts 文件中移除了重复的 defaultLanguage导入语句
- 这个改动简化了代码结构,提高了代码的可读性和维护性
2025-04-04 19:07:23 +08:00
lizhixuan
ef8250ab72 refactor(MCPSettings): replace MainContent callback with useMemo for performance optimization 2025-04-04 18:59:54 +08:00
351 changed files with 74698 additions and 7355 deletions

View File

@@ -18,7 +18,9 @@ body:
options:
- label: 我理解 Issue 是用于反馈和解决问题的,而非吐槽评论区,将尽可能提供更多信息帮助问题解决。
required: true
- label: 已经查看了置顶 Issue 并搜索了现有的 [开放Issue](https://github.com/CherryHQ/cherry-studio/issues)和[已关闭Issue](https://github.com/CherryHQ/cherry-studio/issues?q=is%3Aissue%20state%3Aclosed%20),没有找到类似的问题
- label: 的问题不是 [常见问题](https://github.com/CherryHQ/cherry-studio/issues/3881) 中的内容
required: true
- label: 我已经查看了 **置顶 Issue** 并搜索了现有的 [开放Issue](https://github.com/CherryHQ/cherry-studio/issues)和[已关闭Issue](https://github.com/CherryHQ/cherry-studio/issues?q=is%3Aissue%20state%3Aclosed%20),没有找到类似的问题。
required: true
- label: 我填写了简短且清晰明确的标题,以便开发者在翻阅 Issue 列表时能快速确定大致问题。而不是“一个建议”、“卡住了”等。
required: true
@@ -48,8 +50,8 @@ body:
id: description
attributes:
label: 错误描述
description: 描述问题时请尽可能详细
placeholder: 告诉我们发生了什么...
description: 描述问题时请尽可能详细。请尽可能提供截图或屏幕录制,以帮助我们更好地理解问题。
placeholder: 告诉我们发生了什么...(记得附上截图/录屏,如果适用)
validations:
required: true
@@ -57,12 +59,14 @@ body:
id: reproduction
attributes:
label: 重现步骤
description: 提供详细的重现步骤,以便于我们可以准确地重现问题
description: 提供详细的重现步骤,以便于我们的开发人员可以准确地重现问题。请尽可能为每个步骤提供截图或屏幕录制。
placeholder: |
1. 转到 '...'
2. 点击 '....'
3. 向下滚动到 '....'
4. 看到错误
记得尽可能为每个步骤附上截图/录屏!
validations:
required: true

View File

@@ -6,8 +6,8 @@ body:
- type: markdown
attributes:
value: |
Thank you for taking the time to fill out this bug report!
Before submitting this issue, please make sure that you have understood the [FAQ](https://docs.cherry-ai.com/question-contact/questions) and [Knowledge Science](https://docs.cherry-ai.com/question-contact/knowledge)
Thank you for taking the time to fill out this bug report!
Before submitting this issue, please make sure that you have understood the [FAQ](https://docs.cherry-ai.com/question-contact/questions) and [Knowledge Science](https://docs.cherry-ai.com/question-contact/knowledge)
- type: checkboxes
id: checklist
@@ -18,7 +18,9 @@ body:
options:
- label: I understand that issues are for feedback and problem solving, not for complaining in the comment section, and will provide as much information as possible to help solve the problem.
required: true
- label: I've looked at pinned issues and searched for existing [Open Issues](https://github.com/CherryHQ/cherry-studio/issues), [Closed Issues](https://github.com/CherryHQ/cherry-studio/issues?q=is%3Aissue%20state%3Aclosed), and [Discussions](https://github.com/CherryHQ/cherry-studio/discussions), no similar issue or discussion was found.
- label: My issue is not listed in the [FAQ](https://github.com/CherryHQ/cherry-studio/issues/3881).
required: true
- label: I've looked at **pinned issues** and searched for existing [Open Issues](https://github.com/CherryHQ/cherry-studio/issues), [Closed Issues](https://github.com/CherryHQ/cherry-studio/issues?q=is%3Aissue%20state%3Aclosed), and [Discussions](https://github.com/CherryHQ/cherry-studio/discussions), no similar issue or discussion was found.
required: true
- label: I've filled in short, clear headings so that developers can quickly identify a rough idea of what to expect when flipping through the list of issues. And not "a suggestion", "stuck", etc.
required: true

39
.github/workflows/issue-management.yml vendored Normal file
View File

@@ -0,0 +1,39 @@
name: "Stale Issue Management"
on:
schedule:
- cron: "0 0 * * *"
workflow_dispatch:
env:
daysBeforeStale: 30 # Number of days of inactivity before marking as stale
daysBeforeClose: 30 # Number of days to wait after marking as stale before closing
jobs:
stale:
if: github.repository_owner == 'CherryHQ'
runs-on: ubuntu-latest
permissions:
actions: write # Workaround for https://github.com/actions/stale/issues/1090
issues: write
# Completely disable stalling for PRs
pull-requests: none
contents: none
steps:
- name: Close inactive issues
uses: actions/stale@v9
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-stale: ${{ env.daysBeforeStale }}
days-before-close: ${{ env.daysBeforeClose }}
stale-issue-label: "inactive"
stale-issue-message: |
This issue has been inactive for a prolonged period and will be closed automatically in ${{ env.daysBeforeClose }} days.
该问题已长时间处于闲置状态,${{ env.daysBeforeClose }} 天后将自动关闭。
exempt-issue-labels: "pending, Dev Team, enhancement"
days-before-pr-stale: -1 # Completely disable stalling for PRs
days-before-pr-close: -1 # Completely disable closing for PRs
# Temporary to reduce the huge issues number
operations-per-run: 100
debug-only: false

View File

@@ -76,7 +76,10 @@ jobs:
- name: Build Windows
if: matrix.os == 'windows-latest'
run: yarn build:win
run: |
yarn build:npm windows
yarn build:win:x64
yarn build:win:arm64
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}

View File

@@ -88,14 +88,13 @@ jobs:
- name: Build Windows
if: matrix.os == 'windows-latest'
run: yarn build:win
run: |
yarn build:npm windows
yarn build:win
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
- name: Replace spaces in filenames
run: node scripts/replace-spaces.js
- name: Release
uses: ncipollo/release-action@v1
with:

1
.gitignore vendored
View File

@@ -35,7 +35,6 @@ Thumbs.db
node_modules
dist
out
build/icons
stats.html
# ENV

10
.vscode/settings.json vendored
View File

@@ -31,5 +31,13 @@
"[markdown]": {
"files.trimTrailingWhitespace": false
},
"i18n-ally.localesPaths": ["src/renderer/src/i18n"]
"i18n-ally.localesPaths": ["src/renderer/src/i18n/locales"],
"i18n-ally.enabledFrameworks": ["react-i18next", "i18next"],
"i18n-ally.keystyle": "nested", // 翻译路径格式
"i18n-ally.sortKeys": true, // 排序
"i18n-ally.namespace": true, // 开启命名空间
"i18n-ally.enabledParsers": ["ts", "js", "json"], // 解析语言
"i18n-ally.sourceLanguage": "en-us", // 翻译源语言
"i18n-ally.displayLanguage": "zh-cn",
"i18n-ally.fullReloadOnChanged": true // 界面显示语言
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,17 @@
diff --git a/index.js b/index.js
index 4e8423491ab51a9eb9fee22182e4ea0fcc3d3d3b..2846c5d4354c130d478dc99565b3ecd6d85b7d2e 100644
--- a/index.js
+++ b/index.js
@@ -19,7 +19,11 @@ function requireNative() {
break;
}
}
- return require(`@libsql/${target}`);
+ if (target === "win32-arm64-msvc") {
+ return require(`@strongtz/win32-arm64-msvc`);
+ } else {
+ return require(`@libsql/${target}`);
+ }
}
const {

View File

@@ -1,8 +1,8 @@
diff --git a/core.js b/core.js
index e75a18281ce8f051990c5a50bc1076afdddf91a3..e62f796791a155f23d054e74a429516c14d6e11b 100644
index ebb071d31cd5a14792b62814df072c5971e83300..31e1062d4a7f2422ffec79cf96a35dbb69fe89cb 100644
--- a/core.js
+++ b/core.js
@@ -156,7 +156,7 @@ class APIClient {
@@ -157,7 +157,7 @@ class APIClient {
Accept: 'application/json',
'Content-Type': 'application/json',
'User-Agent': this.getUserAgent(),
@@ -12,10 +12,10 @@ index e75a18281ce8f051990c5a50bc1076afdddf91a3..e62f796791a155f23d054e74a429516c
};
}
diff --git a/core.mjs b/core.mjs
index fcef58eb502664c41a77483a00db8adaf29b2817..18c5d6ed4be86b3640931277bdc27700006764d7 100644
index 9c1a0264dcd73a85de1cf81df4efab9ce9ee2ab7..33f9f1f237f2eb2667a05dae1a7e3dc916f6bfff 100644
--- a/core.mjs
+++ b/core.mjs
@@ -149,7 +149,7 @@ export class APIClient {
@@ -150,7 +150,7 @@ export class APIClient {
Accept: 'application/json',
'Content-Type': 'application/json',
'User-Agent': this.getUserAgent(),

File diff suppressed because one or more lines are too long

111
LICENSE
View File

@@ -1,62 +1,87 @@
**许可协议**
**许可协议 (Licensing)**
软件采用 Apache License 2.0 许可。除 Apache License 2.0 规定的条款外,您在使用 Cherry Studio 时还应遵守以下附加条款:
项目采用**区分用户的双重许可 (User-Segmented Dual Licensing)** 模式。
**一. 商用许可**
**核心原则:**
在以下任何一种情况下,您需要联系我们并获得明确的书面商业授权后,方可继续使用 Cherry Studio 材料:
* **个人用户 和 10人及以下企业/组织:** 默认适用 **GNU Affero 通用公共许可证 v3.0 (AGPLv3)**。
* **超过10人的企业/组织:** **必须** 获取 **商业许可证 (Commercial License)**。
1. **修改与衍生** 您对 Cherry Studio 材料进行修改或基于其进行衍生开发包括但不限于修改应用名称、Logo、代码、功能、界面数据等
2. **企业服务** 在您的企业内部,或为企业客户提供基于 CherryStudio 的服务,且该服务支持 10 人及以上累计用户使用
3. **硬件捆绑销售** 您将 Cherry Studio 预装或集成到硬件设备或产品中进行捆绑销售。
4. **政府或教育机构大规模采购** 您的使用场景属于政府或教育机构的大规模采购项目,特别是涉及安全、数据隐私等敏感需求时。
5. **面向公众的公有云服务**:基于 Cherry Studio提供面向公众的公有云服务。
**二. 贡献者协议**
作为 Cherry Studio 的贡献者,您应当同意以下条款:
1. **许可调整**:生产者有权根据需要对开源协议进行调整,使其更加严格或宽松。
2. **商业用途**:您贡献的代码可能会被用于商业用途,包括但不限于云业务运营。
**三. 其他条款**
1. 本协议条款的解释权归 Cherry Studio 开发者所有。
2. 本协议可能根据实际情况进行更新,更新时将通过本软件通知用户。
如有任何问题或需申请商业授权,请联系 Cherry Studio 开发团队。
除上述特定条件外,其他所有权利和限制均遵循 Apache License 2.0。有关 Apache License 2.0 的详细信息,请访问 http://www.apache.org/licenses/LICENSE-2.0。
定义“10人及以下”
指在您的组织(包括公司、非营利组织、政府机构、教育机构等任何实体)中,能够访问、使用或以任何方式直接或间接受益于本软件(Cherry Studio功能的个人总数不超过10人。这包括但不限于开发者、测试人员、运营人员、最终用户、通过集成系统间接使用者等
---
**1. 开源许可证 (Open Source License): AGPLv3 - 适用于个人及10人及以下组织**
**License Agreement**
* 如果您是个人用户或者您的组织满足上述“10人及以下”的定义您可以在 **AGPLv3** 的条款下自由使用、修改和分发 Cherry Studio。AGPLv3 的完整文本可以访问 [https://www.gnu.org/licenses/agpl-3.0.html](https://www.gnu.org/licenses/agpl-3.0.html) 获取。
* **核心义务:** AGPLv3 的一个关键要求是,如果您修改了 Cherry Studio 并通过网络提供服务,或者分发了修改后的版本,您必须以 AGPLv3 许可证向接收者提供相应的**完整源代码**。即使您符合“10人及以下”的标准如果您希望避免此源代码公开义务您也需要考虑获取商业许可证见下文
* 使用前请务必仔细阅读并理解 AGPLv3 的所有条款。
This software is licensed under the Apache License 2.0. In addition to the terms stipulated by the Apache License 2.0, you must comply with the following supplementary terms when using Cherry Studio:
**2. 商业许可证 (Commercial License) - 适用于超过10人的组织或希望规避 AGPLv3 义务的用户**
**I. Commercial Licensing**
* **强制要求:** 如果您的组织**不**满足上述“10人及以下”的定义即有11人或更多人可以访问、使用或受益于本软件您**必须**联系我们获取并签署一份商业许可证才能使用 Cherry Studio。
* **自愿选择:** 即使您的组织满足“10人及以下”的条件但如果您的使用场景**无法满足 AGPLv3 的条款要求**(特别是关于**源代码公开**的义务),或者您需要 AGPLv3 **未提供**的特定商业条款(如保证、赔偿、无 Copyleft 限制等),您也**必须**联系我们获取并签署一份商业许可证。
* **需要商业许可证的常见情况包括(但不限于):**
* 您的组织规模超过10人。
* (无论组织规模)您希望分发修改过的 Cherry Studio 版本,但**不希望**根据 AGPLv3 公开您修改部分的源代码。
* (无论组织规模)您希望基于修改过的 Cherry Studio 提供网络服务SaaS但**不希望**根据 AGPLv3 向服务使用者提供修改后的源代码。
* (无论组织规模)您的公司政策、客户合同或项目要求不允许使用 AGPLv3 许可的软件,或要求闭源分发及保密。
* 商业许可证将为您提供豁免 AGPLv3 义务(如源代码公开)的权利,并可能包含额外的商业保障条款。
* **获取商业许可:** 请通过邮箱 **bd@cherry-ai.com** 联系 Cherry Studio 开发团队洽谈商业授权事宜。
You must contact us and obtain explicit written commercial authorization to continue using Cherry Studio materials under any of the following circumstances:
**3. 贡献 (Contributions)**
1. **Modifications and Derivatives:** You modify Cherry Studio materials or perform derivative development based on them (including but not limited to changing the applications name, logo, code, functionality, user interface, data, etc.).
2. **Enterprise Services:** You use Cherry Studio internally within your enterprise, or you provide Cherry Studio-based services for enterprise customers, and such services support cumulative usage by 10 or more users.
3. **Hardware Bundling and Sales:** You pre-install or integrate Cherry Studio into hardware devices or products for bundled sale.
4. **Large-scale Procurement by Government or Educational Institutions:** Your usage scenario involves large-scale procurement projects by government or educational institutions, especially in cases involving sensitive requirements such as security and data privacy.
5. **Public Cloud Services:** You provide public cloud-based product services utilizing Cherry Studio.
* 我们欢迎社区对 Cherry Studio 的贡献。所有向本项目提交的贡献都将被视为在 **AGPLv3** 许可证下提供。
* 通过向本项目提交贡献(例如通过 Pull Request即表示您同意您的代码以 AGPLv3 许可证授权给本项目及所有后续使用者(无论这些使用者最终遵循 AGPLv3 还是商业许可)。
* 您也理解并同意,您的贡献可能会被包含在根据商业许可证分发的 Cherry Studio 版本中。
**II. Contributor Agreement**
**4. 其他条款 (Other Terms)**
As a contributor to Cherry Studio, you must agree to the following terms:
* 关于商业许可证的具体条款和条件,以双方签署的正式商业许可协议为准。
* 项目维护者保留根据需要更新本许可政策(包括用户规模定义和阈值)的权利。相关更新将通过项目官方渠道(如代码仓库、官方网站)进行通知。
1. **License Adjustments:** The producer reserves the right to adjust the open-source license as necessary, making it more strict or permissive.
2. **Commercial Usage:** Your contributed code may be used commercially, including but not limited to cloud business operations.
---
**III. Other Terms**
**Licensing**
1. Cherry Studio developers reserve the right of final interpretation of these agreement terms.
2. This agreement may be updated according to practical circumstances, and users will be notified of updates through this software.
This project employs a **User-Segmented Dual Licensing** model.
If you have any questions or need to apply for commercial authorization, please contact the Cherry Studio development team.
**Core Principle:**
Other than these specific conditions, all remaining rights and restrictions follow the Apache License 2.0. For more detailed information regarding Apache License 2.0, please visit http://www.apache.org/licenses/LICENSE-2.0.
* **Individual Users and Organizations with 10 or Fewer Individuals:** Governed by default under the **GNU Affero General Public License v3.0 (AGPLv3)**.
* **Organizations with More Than 10 Individuals:** **Must** obtain a **Commercial License**.
Definition: "10 or Fewer Individuals"
Refers to any organization (including companies, non-profits, government agencies, educational institutions, etc.) where the total number of individuals who can access, use, or in any way directly or indirectly benefit from the functionality of this software (Cherry Studio) does not exceed 10. This includes, but is not limited to, developers, testers, operations staff, end-users, and indirect users via integrated systems.
---
**1. Open Source License: AGPLv3 - For Individuals and Organizations of 10 or Fewer**
* If you are an individual user, or if your organization meets the "10 or Fewer Individuals" definition above, you are free to use, modify, and distribute Cherry Studio under the terms of the **AGPLv3**. The full text of the AGPLv3 can be found in the LICENSE file at [https://www.gnu.org/licenses/agpl-3.0.html](https://www.gnu.org/licenses/agpl-3.0.html).
* **Core Obligation:** A key requirement of the AGPLv3 is that if you modify Cherry Studio and make it available over a network, or distribute the modified version, you must provide the **complete corresponding source code** under the AGPLv3 license to the recipients. Even if you qualify under the "10 or Fewer Individuals" rule, if you wish to avoid this source code disclosure obligation, you will need to obtain a Commercial License (see below).
* Please read and understand the full terms of the AGPLv3 carefully before use.
**2. Commercial License - For Organizations with More Than 10 Individuals, or Users Needing to Avoid AGPLv3 Obligations**
* **Mandatory Requirement:** If your organization does **not** meet the "10 or Fewer Individuals" definition above (i.e., 11 or more individuals can access, use, or benefit from the software), you **must** contact us to obtain and execute a Commercial License to use Cherry Studio.
* **Voluntary Option:** Even if your organization meets the "10 or Fewer Individuals" condition, if your intended use case **cannot comply with the terms of the AGPLv3** (particularly the obligations regarding **source code disclosure**), or if you require specific commercial terms **not offered** by the AGPLv3 (such as warranties, indemnities, or freedom from copyleft restrictions), you also **must** contact us to obtain and execute a Commercial License.
* **Common scenarios requiring a Commercial License include (but are not limited to):**
* Your organization has more than 10 individuals who can access, use, or benefit from the software.
* (Regardless of organization size) You wish to distribute a modified version of Cherry Studio but **do not want** to disclose the source code of your modifications under AGPLv3.
* (Regardless of organization size) You wish to provide a network service (SaaS) based on a modified version of Cherry Studio but **do not want** to provide the modified source code to users of the service under AGPLv3.
* (Regardless of organization size) Your corporate policies, client contracts, or project requirements prohibit the use of AGPLv3-licensed software or mandate closed-source distribution and confidentiality.
* The Commercial License grants you rights exempting you from AGPLv3 obligations (like source code disclosure) and may include additional commercial assurances.
* **Obtaining a Commercial License:** Please contact the Cherry Studio development team via email at **bd@cherry-ai.com** to discuss commercial licensing options.
**3. Contributions**
* We welcome community contributions to Cherry Studio. All contributions submitted to this project are considered to be offered under the **AGPLv3** license.
* By submitting a contribution to this project (e.g., via a Pull Request), you agree to license your code under the AGPLv3 to the project and all its downstream users (regardless of whether those users ultimately operate under AGPLv3 or a Commercial License).
* You also understand and agree that your contribution may be included in distributions of Cherry Studio offered under our commercial license.
**4. Other Terms**
* The specific terms and conditions of the Commercial License are governed by the formal commercial license agreement signed by both parties.
* The project maintainers reserve the right to update this licensing policy (including the definition and threshold for user count) as needed. Updates will be communicated through official project channels (e.g., code repository, official website).

View File

@@ -13,7 +13,7 @@
Cherry Studio is a desktop client that supports for multiple LLM providers, available on Windows, Mac and Linux.
👏 Join [Telegram Group](https://t.me/CherryStudioAI)[Discord](https://discord.gg/wez8HtpxqQ) | [QQ Group(472019156)](https://qm.qq.com/q/CbZiBWwCXu)
👏 Join [Telegram Group](https://t.me/CherryStudioAI)[Discord](https://discord.gg/wez8HtpxqQ) | [QQ Group(575014769)](https://qm.qq.com/q/lo0D4qVZKi)
❤️ Like Cherry Studio? Give it a star 🌟 or [Sponsor](docs/sponsor.md) to support the development!
@@ -84,9 +84,11 @@ https://docs.cherry-ai.com
# 🌈 Theme
- Theme Gallery: https://cherrycss.com
- Theme Gallery: https://cherrycss.com
- Aero Theme: https://github.com/hakadao/CherryStudio-Aero
- PaperMaterial Theme: https://github.com/rainoffallingstar/CherryStudio-PaperMaterial
- Claude dynamic-style: https://github.com/bjl101501/CherryStudio-Claudestyle-dynamic
- Maple Neon Theme: https://github.com/BoningtonChen/CherryStudio_themes
Welcome PR for more themes

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

BIN
build/icons/1024x1024.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

BIN
build/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

BIN
build/icons/16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 621 B

BIN
build/icons/24x24.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

BIN
build/icons/256x256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
build/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
build/icons/48x48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

BIN
build/icons/512x512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

BIN
build/icons/64x64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@@ -1,8 +1,8 @@
# provider: generic
# url: http://127.0.0.1:8080
# updaterCacheDirName: cherry-studio-updater
provider: github
repo: cherry-studio
owner: kangfenmao
# provider: generic
# url: https://cherrystudio.ocool.online
# provider: github
# repo: cherry-studio
# owner: kangfenmao
provider: generic
url: https://releases.cherry-ai.com

View File

@@ -14,7 +14,7 @@
Cherry Studio は、複数の LLM プロバイダーをサポートするデスクトップクライアントで、Windows、Mac、Linux で利用可能です。
👏 [Telegram](https://t.me/CherryStudioAI)[Discord](https://discord.gg/wez8HtpxqQ) | [QQグループ(472019156)](https://qm.qq.com/q/CbZiBWwCXu)
👏 [Telegram](https://t.me/CherryStudioAI)[Discord](https://discord.gg/wez8HtpxqQ) | [QQグループ(575014769)](https://qm.qq.com/q/lo0D4qVZKi)
❤️ Cherry Studio をお気に入りにしましたか?小さな星をつけてください 🌟 または [スポンサー](sponsor.md) をして開発をサポートしてください!❤️
@@ -85,8 +85,11 @@ https://docs.cherry-ai.com
# 🌈 テーマ
テーマギャラリー: https://cherrycss.com
Aero テーマ: https://github.com/hakadao/CherryStudio-Aero
- テーマギャラリー: https://cherrycss.com
- Aero テーマ: https://github.com/hakadao/CherryStudio-Aero
- PaperMaterial テーマ: https://github.com/rainoffallingstar/CherryStudio-PaperMaterial
- Claude テーマ: https://github.com/bjl101501/CherryStudio-Claudestyle-dynamic
- メープルネオンテーマ: https://github.com/BoningtonChen/CherryStudio_themes
より多くのテーマのPRを歓迎します

View File

@@ -14,7 +14,7 @@
Cherry Studio 是一款支持多个大语言模型LLM服务商的桌面客户端兼容 Windows、Mac 和 Linux 系统。
👏 欢迎加入 [Telegram 群组](https://t.me/CherryStudioAI)[Discord](https://discord.gg/wez8HtpxqQ) | [QQ群(472019156)](https://qm.qq.com/q/CbZiBWwCXu)
👏 欢迎加入 [Telegram 群组](https://t.me/CherryStudioAI)[Discord](https://discord.gg/wez8HtpxqQ) | [QQ群(575014769)](https://qm.qq.com/q/lo0D4qVZKi)
❤️ 喜欢 Cherry Studio? 点亮小星星 🌟 或 [赞助开发者](sponsor.md)! ❤️
@@ -85,8 +85,11 @@ https://docs.cherry-ai.com
# 🌈 主题
主题库https://cherrycss.com
Aero 主题https://github.com/hakadao/CherryStudio-Aero
- 主题库https://cherrycss.com
- Aero 主题https://github.com/hakadao/CherryStudio-Aero
- PaperMaterial 主题: https://github.com/rainoffallingstar/CherryStudio-PaperMaterial
- 仿Claude 主题: https://github.com/bjl101501/CherryStudio-Claudestyle-dynamic
- 霓虹枫叶字体主题: https://github.com/BoningtonChen/CherryStudio_themes
欢迎 PR 更多主题

View File

@@ -1,8 +1,16 @@
appId: com.kangfenmao.CherryStudio
productName: Cherry Studio
electronLanguages:
- zh-CN
- zh-TW
- en-GB
- en-US
- 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}'
@@ -27,23 +35,35 @@ 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/**
- '**/*.{node,dll,metal,exp,lib}'
- '**/*.{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}-portable.${ext}
artifactName: ${productName}-${version}-${arch}-setup.${ext}
target:
- target: nsis
- target: portable
nsis:
artifactName: ${productName}-${version}-setup.${ext}
artifactName: ${productName}-${version}-${arch}-setup.${ext}
shortcutName: ${productName}
uninstallDisplayName: ${productName}
createDesktopShortcut: always
allowToChangeInstallationDirectory: true
oneClick: false
include: build/nsis-installer.nsh
buildUniversalInstaller: false
portable:
artifactName: ${productName}-${version}-${arch}-portable.${ext}
mac:
entitlementsInherit: build/entitlements.mac.plist
notarize: false
@@ -55,36 +75,23 @@ mac:
- NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
target:
- target: dmg
arch:
- arm64
- x64
- target: zip
arch:
- arm64
- x64
linux:
artifactName: ${productName}-${version}-${arch}.${ext}
target:
- target: AppImage
arch:
- arm64
- x64
maintainer: electronjs.org
category: Utility
publish:
# provider: generic
# url: https://cherrystudio.ocool.online
provider: github
repo: cherry-studio
owner: CherryHQ
provider: generic
url: https://releases.cherry-ai.com
electronDownload:
mirror: https://npmmirror.com/mirrors/electron/
afterPack: scripts/after-pack.js
afterSign: scripts/notarize.js
artifactBuildCompleted: scripts/artifact-build-completed.js
releaseInfo:
releaseNotes: |
新增助手级别的 MCP 能力支持
改进 MCP 包的安装与管理
导出单条消息时,增加生成消息标题的功能
快捷助手窗口新增钉住和调整大小的功能
小程序现在支持显示、复制当前实际 URL
全新图标风格
新的智能体界面
WebDAV 增加文件管理功能

View File

@@ -1,4 +1,4 @@
import react from '@vitejs/plugin-react'
import viteReact from '@vitejs/plugin-react'
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
import { resolve } from 'path'
import { visualizer } from 'rollup-plugin-visualizer'
@@ -6,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: [
@@ -42,11 +42,16 @@ export default defineConfig({
}
},
preload: {
plugins: [externalizeDepsPlugin()]
plugins: [externalizeDepsPlugin()],
resolve: {
alias: {
'@shared': resolve('packages/shared')
}
}
},
renderer: {
plugins: [
react({
viteReact({
babel: {
plugins: [
[
@@ -70,7 +75,18 @@ export default defineConfig({
}
},
optimizeDeps: {
exclude: ['chunk-PZ64DZKH.js', 'chunk-JMKENWIY.js', 'chunk-UXYB6GHG.js', 'chunk-ALDIEZMG.js', 'chunk-4X6ZJEXY.js']
exclude: []
},
build: {
rollupOptions: {
input: {
index: resolve('src/renderer/index.html')
}
},
// 复制ASR服务器文件
assetsInlineLimit: 0,
// 确保复制assets目录下的所有文件
copyPublicDir: true
}
}
})

425
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>

View File

@@ -1,6 +1,6 @@
{
"name": "CherryStudio",
"version": "1.1.18",
"version": "1.2.5",
"private": true,
"description": "A powerful AI assistant for producer.",
"main": "./out/main/index.js",
@@ -23,12 +23,13 @@
"build": "npm run typecheck && electron-vite build",
"build:check": "yarn test && yarn typecheck && yarn check:i18n",
"build:unpack": "dotenv npm run build && electron-builder --dir",
"build:win": "dotenv npm run build && electron-builder --win",
"build:win": "dotenv npm run build && electron-builder --win --x64 --arm64",
"build:win:x64": "dotenv npm run build && electron-builder --win --x64",
"build:mac": "dotenv electron-vite build && electron-builder --mac",
"build:win:arm64": "dotenv npm run build && electron-builder --win --arm64",
"build:mac": "dotenv electron-vite build && electron-builder --mac --arm64 --x64",
"build:mac:arm64": "dotenv electron-vite build && electron-builder --mac --arm64",
"build:mac:x64": "dotenv electron-vite build && electron-builder --mac --x64",
"build:linux": "dotenv electron-vite build && electron-builder --linux",
"build:linux": "dotenv electron-vite build && electron-builder --linux --x64 --arm64",
"build:linux:arm64": "dotenv electron-vite build && electron-builder --linux --arm64",
"build:linux:x64": "dotenv electron-vite build && electron-builder --linux --x64",
"build:npm": "node scripts/build-npm.js",
@@ -63,34 +64,47 @@
"@cherrystudio/embedjs-openai": "^0.1.28",
"@electron-toolkit/utils": "^3.0.0",
"@electron/notarize": "^2.5.0",
"@google/generative-ai": "^0.24.0",
"@langchain/community": "^0.3.36",
"@mozilla/readability": "^0.6.0",
"@notionhq/client": "^2.2.15",
"@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",
"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",
"fast-xml-parser": "^5.0.9",
"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-edge-tts": "^1.2.8",
"officeparser": "^4.1.1",
"proxy-agent": "^6.5.0",
"tar": "^7.4.3",
"turndown": "^7.2.0",
"turndown-plugin-gfm": "^1.0.2",
"undici": "^7.4.0",
"webdav": "^5.8.0",
"ws": "^8.18.1",
"zipread": "^1.3.3"
},
"devDependencies": {
"@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",
@@ -100,17 +114,19 @@
"@emotion/is-prop-valid": "^1.3.1",
"@eslint-react/eslint-plugin": "^1.36.1",
"@eslint/js": "^9.22.0",
"@google/genai": "^0.4.0",
"@google/genai": "patch:@google/genai@npm%3A0.8.0#~/.yarn/patches/@google-genai-npm-0.8.0-450d0d9a7d.patch",
"@hello-pangea/dnd": "^16.6.0",
"@kangfenmao/keyv-storage": "^0.1.0",
"@modelcontextprotocol/sdk": "^1.8.0",
"@modelcontextprotocol/sdk": "^1.9.0",
"@notionhq/client": "^2.2.15",
"@reduxjs/toolkit": "^2.2.5",
"@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",
@@ -119,7 +135,9 @@
"@types/react-dom": "^19.0.4",
"@types/react-infinite-scroll-component": "^5.0.0",
"@types/tinycolor2": "^1",
"@vitejs/plugin-react": "^4.2.1",
"@types/ws": "^8",
"@vitejs/plugin-react": "4.3.4",
"analytics": "^0.8.16",
"antd": "^5.22.5",
"applescript": "^1.0.0",
"axios": "^1.7.3",
@@ -130,7 +148,7 @@
"dexie-react-hooks": "^1.1.7",
"dotenv-cli": "^7.4.2",
"electron": "31.7.6",
"electron-builder": "^24.13.3",
"electron-builder": "26.0.13",
"electron-devtools-installer": "^3.2.0",
"electron-icon-builder": "^2.0.1",
"electron-vite": "^2.3.0",
@@ -145,9 +163,11 @@
"i18next": "^23.11.5",
"lint-staged": "^15.5.0",
"lodash": "^4.17.21",
"lru-cache": "^11.1.0",
"lucide-react": "^0.487.0",
"mime": "^4.0.4",
"npx-scope-finder": "^1.2.0",
"openai": "patch:openai@npm%3A4.77.3#~/.yarn/patches/openai-npm-4.77.3-59c6d42e7a.patch",
"openai": "patch:openai@npm%3A4.87.3#~/.yarn/patches/openai-npm-4.87.3-2b30a7685f.patch",
"p-queue": "^8.1.0",
"prettier": "^3.5.3",
"rc-virtual-list": "^3.18.5",
@@ -166,25 +186,29 @@
"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",
"rollup-plugin-visualizer": "^5.12.0",
"sass": "^1.77.2",
"shiki": "^1.22.2",
"shiki": "^3.2.1",
"string-width": "^7.2.0",
"styled-components": "^6.1.11",
"tiny-pinyin": "^1.3.2",
"tinycolor2": "^1.6.0",
"tokenx": "^0.4.1",
"typescript": "^5.6.2",
"uuid": "^10.0.0",
"vite": "^5.0.12"
"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",
"@langchain/openai@npm:^0.3.16": "patch:@langchain/openai@npm%3A0.3.16#~/.yarn/patches/@langchain-openai-npm-0.3.16-e525b59526.patch",
"@langchain/openai@npm:>=0.1.0 <0.4.0": "patch:@langchain/openai@npm%3A0.3.16#~/.yarn/patches/@langchain-openai-npm-0.3.16-e525b59526.patch",
"openai@npm:^4.77.0": "patch:openai@npm%3A4.77.3#~/.yarn/patches/openai-npm-4.77.3-59c6d42e7a.patch",
"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"
},
"packageManager": "yarn@4.6.0",

View File

@@ -0,0 +1,174 @@
export enum IpcChannel {
App_ClearCache = 'app:clear-cache',
App_SetLaunchOnBoot = 'app:set-launch-on-boot',
App_SetLanguage = 'app:set-language',
App_ShowUpdateDialog = 'app:show-update-dialog',
App_CheckForUpdate = 'app:check-for-update',
App_Reload = 'app:reload',
App_Info = 'app:info',
App_Proxy = 'app:proxy',
App_SetLaunchToTray = 'app:set-launch-to-tray',
App_SetTray = 'app:set-tray',
App_SetTrayOnClose = 'app:set-tray-on-close',
App_RestartTray = 'app:restart-tray',
App_SetTheme = 'app:set-theme',
App_SetCustomCss = 'app:set-custom-css',
App_SetAutoUpdate = 'app:set-auto-update',
App_IsBinaryExist = 'app:is-binary-exist',
App_GetBinaryPath = 'app:get-binary-path',
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',
Minapp = 'minapp',
Config_Set = 'config:set',
Config_Get = 'config:get',
MiniWindow_Show = 'miniwindow:show',
MiniWindow_Hide = 'miniwindow:hide',
MiniWindow_Close = 'miniwindow:close',
MiniWindow_Toggle = 'miniwindow:toggle',
MiniWindow_SetPin = 'miniwindow:set-pin',
// Mcp
Mcp_RemoveServer = 'mcp:remove-server',
Mcp_RestartServer = 'mcp:restart-server',
Mcp_StopServer = 'mcp:stop-server',
Mcp_ListTools = 'mcp:list-tools',
Mcp_CallTool = 'mcp:call-tool',
Mcp_ListPrompts = 'mcp:list-prompts',
Mcp_GetPrompt = 'mcp:get-prompt',
Mcp_ListResources = 'mcp:list-resources',
Mcp_GetResource = 'mcp:get-resource',
Mcp_GetInstallInfo = 'mcp:get-install-info',
Mcp_ServersChanged = 'mcp:servers-changed',
Mcp_ServersUpdated = 'mcp:servers-updated',
//copilot
Copilot_GetAuthMessage = 'copilot:get-auth-message',
Copilot_GetCopilotToken = 'copilot:get-copilot-token',
Copilot_SaveCopilotToken = 'copilot:save-copilot-token',
Copilot_GetToken = 'copilot:get-token',
Copilot_Logout = 'copilot:logout',
Copilot_GetUser = 'copilot:get-user',
// obsidian
Obsidian_GetVaults = 'obsidian:get-vaults',
Obsidian_GetFiles = 'obsidian:get-files',
// nutstore
Nutstore_GetSsoUrl = 'nutstore:get-sso-url',
Nutstore_DecryptToken = 'nutstore:decrypt-token',
Nutstore_GetDirectoryContents = 'nutstore:get-directory-contents',
//aes
Aes_Encrypt = 'aes:encrypt',
Aes_Decrypt = 'aes:decrypt',
Gemini_UploadFile = 'gemini:upload-file',
Gemini_Base64File = 'gemini:base64-file',
Gemini_RetrieveFile = 'gemini:retrieve-file',
Gemini_ListFiles = 'gemini:list-files',
Gemini_DeleteFile = 'gemini:delete-file',
Windows_ResetMinimumSize = 'window:reset-minimum-size',
Windows_SetMinimumSize = 'window:set-minimum-size',
SelectionMenu_Action = 'selection-menu:action',
KnowledgeBase_Create = 'knowledge-base:create',
KnowledgeBase_Reset = 'knowledge-base:reset',
KnowledgeBase_Delete = 'knowledge-base:delete',
KnowledgeBase_Add = 'knowledge-base:add',
KnowledgeBase_Remove = 'knowledge-base:remove',
KnowledgeBase_Search = 'knowledge-base:search',
KnowledgeBase_Rerank = 'knowledge-base:rerank',
//file
File_Open = 'file:open',
File_OpenPath = 'file:openPath',
File_Save = 'file:save',
File_Select = 'file:select',
File_Upload = 'file:upload',
File_Clear = 'file:clear',
File_Read = 'file:read',
File_Delete = 'file:delete',
File_Get = 'file:get',
File_SelectFolder = 'file:selectFolder',
File_Create = 'file:create',
File_Write = 'file:write',
File_SaveImage = 'file:saveImage',
File_Base64Image = 'file:base64Image',
File_Download = 'file:download',
File_Copy = 'file:copy',
File_BinaryFile = 'file:binaryFile',
Fs_Read = 'fs:read',
Export_Word = 'export:word',
Shortcuts_Update = 'shortcuts:update',
// backup
Backup_Backup = 'backup:backup',
Backup_Restore = 'backup:restore',
Backup_BackupToWebdav = 'backup:backupToWebdav',
Backup_RestoreFromWebdav = 'backup:restoreFromWebdav',
Backup_ListWebdavFiles = 'backup:listWebdavFiles',
Backup_CheckConnection = 'backup:checkConnection',
Backup_CreateDirectory = 'backup:createDirectory',
Backup_DeleteWebdavFile = 'backup:deleteWebdavFile',
// zip
Zip_Compress = 'zip:compress',
Zip_Decompress = 'zip:decompress',
// system
System_GetDeviceType = 'system:getDeviceType',
System_GetHostname = 'system:getHostname',
// events
SelectionAction = 'selection-action',
BackupProgress = 'backup-progress',
ThemeChange = 'theme:change',
UpdateDownloadedCancelled = 'update-downloaded-cancelled',
RestoreProgress = 'restore-progress',
UpdateError = 'update-error',
UpdateAvailable = 'update-available',
UpdateNotAvailable = 'update-not-available',
DownloadProgress = 'download-progress',
UpdateDownloaded = 'update-downloaded',
DownloadUpdate = 'download-update',
DirectoryProcessingPercent = 'directory-processing-percent',
FullscreenStatusChanged = 'fullscreen-status-changed',
HideMiniWindow = 'hide-mini-window',
ShowMiniWindow = 'show-mini-window',
MiniWindowReload = 'miniwindow-reload',
ReduxStateChange = 'redux-state-change',
ReduxStoreReady = 'redux-store-ready',
// Search Window
SearchWindow_Open = 'search-window:open',
SearchWindow_Close = 'search-window:close',
SearchWindow_OpenUrl = 'search-window:open-url'
}

View File

@@ -157,3 +157,8 @@ export const ZOOM_SHORTCUTS = [
system: true
}
]
export const KB = 1024
export const MB = 1024 * KB
export const GB = 1024 * MB
export const defaultLanguage = 'en-US'

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,115 +1,106 @@
<!doctype html>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CherryStudio 许可协议-ZH/EN</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet" />
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>许可协议 | License Agreement</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-100 p-8">
<div class="container mx-auto bg-white p-6 rounded shadow-lg">
<h1 class="text-3xl font-bold mb-6 text-center">Cherry Studio 许可协议</h1>
<div class="mb-8">
<h2 class="text-2xl font-semibold mb-4">许可协议</h2>
<p class="mb-4">
本软件采用 <strong>Apache License 2.0</strong> 许可。除 Apache License 2.0 规定的条款外,您在使用 Cherry
Studio 时还应遵守以下附加条款:
</p>
<h3 class="text-xl font-semibold mb-2">一. 商用许可</h3>
<ol class="list-decimal list-inside mb-4">
<li><strong>免费商用</strong>:用户在不修改代码的情况下,可以免费用于商业目的。</li>
<li>
<strong>商业授权</strong>如果您满足以下任意条件之一,需取得商业授权:
<ol class="list-decimal list-inside ml-4">
<li>对本软件进行二次修改、开发包括但不限于修改应用名称、logo、代码以及功能</li>
<li>为企业客户提供多租户服务,且该服务支持 10 人或以上的使用</li>
<li>预装或集成到硬件设备或产品中进行捆绑销售。</li>
<li>政府或教育机构的大规模采购项目,特别是涉及安全、数据隐私等敏感需求时。</li>
</ol>
</li>
</ol>
<h3 class="text-xl font-semibold mb-2">二. 贡献者协议</h3>
<ol class="list-decimal list-inside mb-4">
<li><strong>许可调整</strong>:生产者有权根据需要对开源协议进行调整,使其更加严格或宽松。</li>
<li><strong>商业用途</strong>:您贡献的代码可能会被用于商业用途,包括但不限于云业务运营。</li>
</ol>
<h3 class="text-xl font-semibold mb-2">三. 其他条款</h3>
<ol class="list-decimal list-inside mb-4">
<li>本协议条款的解释权归 Cherry Studio 开发者所有。</li>
<li>本协议可能根据实际情况进行更新,更新时将通过本软件通知用户。</li>
</ol>
<p class="mb-4">如有任何问题或需申请商业授权,请联系 Cherry Studio 开发团队</p>
<p>
除上述特定条件外,其他所有权利和限制均遵循 Apache License 2.0。有关 Apache License 2.0 的详细信息,请访问
<a href="http://www.apache.org/licenses/LICENSE-2.0"
class="text-blue-500 underline">http://www.apache.org/licenses/LICENSE-2.0</a>
</p>
<body class="bg-gray-50">
<div class="max-w-4xl mx-auto px-4 py-8">
<!-- 中文版本 -->
<div class="mb-12">
<h1 class="text-3xl font-bold mb-8 text-gray-900">许可协议</h1>
<p class="mb-6 text-gray-700">采用 Apache License 2.0 修改版许可,并附加以下条件:</p>
<section class="mb-8">
<h2 class="text-xl font-semibold mb-4 text-gray-900">一. 商用许可</h2>
<p class="mb-4 text-gray-700">在以下任何一种情况下,您需要联系我们并获得明确的书面商业授权后,方可继续使用 Cherry Studio 材料:</p>
<ol class="list-decimal pl-6 space-y-2 text-gray-700">
<li><strong>修改与衍生</strong> 您对 Cherry Studio 材料进行修改或基于其进行衍生开发包括但不限于修改应用名称、Logo、代码、功能、界面数据等</li>
<li><strong>企业服务</strong> 在您的企业内部,或为企业客户提供基于 Cherry Studio 的服务,且该服务支持 10 人及以上累计用户使用。</li>
<li><strong>硬件捆绑销售</strong> 您将 Cherry Studio 预装或集成到硬件设备或产品中进行捆绑销售。</li>
<li><strong>政府或教育机构大规模采购</strong> 您的使用场景属于政府或教育机构的大规模采购项目,特别是涉及安全、数据隐私等敏感需求时</li>
<li><strong>面向公众的公有云服务</strong>:基于 Cherry Studio提供面向公众的公有云服务</li>
</ol>
</section>
<section class="mb-8">
<h2 class="text-xl font-semibold mb-4 text-gray-900">二. 贡献者协议</h2>
<p class="mb-4 text-gray-700">作为 Cherry Studio 的贡献者,您应当同意以下条款:</p>
<ol class="list-decimal pl-6 space-y-2 text-gray-700">
<li><strong>许可调整</strong>:生产者有权根据需要对开源协议进行调整,使其更加严格或宽松。</li>
<li><strong>商业用途</strong>:您贡献的代码可能会被用于商业用途,包括但不限于云业务运营。</li>
</ol>
</section>
<section class="mb-8">
<h2 class="text-xl font-semibold mb-4 text-gray-900">三. 其他条款</h2>
<ol class="list-decimal pl-6 space-y-2 text-gray-700">
<li>本协议条款的解释权归 Cherry Studio 开发者所有</li>
<li>本协议可能根据实际情况进行更新,更新时将通过本软件通知用户。</li>
</ol>
</section>
</div>
<h1 class="text-3xl font-bold mb-6 text-center">Cherry Studio License</h1>
<div class="mb-8">
<h2 class="text-2xl font-semibold mb-4">License Agreement</h2>
<p class="mb-4">
This software is licensed under the <strong>Apache License 2.0</strong>. In addition to the terms of the
Apache License 2.0, the following additional terms apply to the use of Cherry Studio:
</p>
<h3 class="text-xl font-semibold mb-2">I. Commercial Use License</h3>
<ol class="list-decimal list-inside mb-4">
<li>
<strong>Free Commercial Use</strong>: Users can use the software for commercial purposes without
modifying
the code.
</li>
<li>
<strong>Commercial License Required</strong>: A commercial license is required if any of the
following
conditions are met:
<ol class="list-decimal list-inside ml-4">
<li>
You modify, develop, or alter the software, including but not limited to changes to the
application
name, logo, code, or functionality.
</li>
<li>You provide multi-tenant services to enterprise customers with 10 or more users.</li>
<li>
You pre-install or integrate the software into hardware devices or products and bundle it
for sale.
</li>
<li>
You are engaging in large-scale procurement for government or educational institutions,
especially
involving security, data privacy, or other sensitive requirements.
</li>
</ol>
</li>
</ol>
<h3 class="text-xl font-semibold mb-2">II. Contributor Agreement</h3>
<ol class="list-decimal list-inside mb-4">
<li>
<strong>License Adjustment</strong>: The producer reserves the right to adjust the open-source
license as
needed, making it stricter or more lenient.
</li>
<li>
<strong>Commercial Use</strong>: Any code you contribute may be used for commercial purposes,
including but
not limited to cloud business operations.
</li>
</ol>
<h3 class="text-xl font-semibold mb-2">III. Other Terms</h3>
<ol class="list-decimal list-inside mb-4">
<li>The interpretation of these terms is subject to the discretion of Cherry Studio developers.</li>
<li>These terms may be updated, and users will be notified through the software when changes occur.</li>
</ol>
<p class="mb-4">
For any questions or to request a commercial license, please contact the Cherry Studio development team.
</p>
<p>
Apart from the specific conditions mentioned above, all other rights and restrictions follow the Apache
License 2.0. Detailed information about the Apache License 2.0 can be found at
<hr class="my-12 border-gray-300">
<!-- English Version -->
<div>
<h1 class="text-3xl font-bold mb-8 text-gray-900">License Agreement</h1>
<p class="mb-6 text-gray-700">This software is licensed under a modified version of the Apache License 2.0, with
the following additional conditions.</p>
<section class="mb-8">
<h2 class="text-xl font-semibold mb-4 text-gray-900">I. Commercial Licensing</h2>
<p class="mb-4 text-gray-700">You must contact us and obtain explicit written commercial authorization to
continue using Cherry Studio materials under any of the following circumstances:</p>
<ol class="list-decimal pl-6 space-y-2 text-gray-700">
<li><strong>Modifications and Derivatives:</strong> You modify Cherry Studio materials or perform derivative
development based on them (including but not limited to changing the application's name, logo, code,
functionality, user interface, data, etc.).</li>
<li><strong>Enterprise Services:</strong> You use Cherry Studio internally within your enterprise, or you
provide Cherry Studio-based services for enterprise customers, and such services support cumulative usage by
10 or more users.</li>
<li><strong>Hardware Bundling and Sales:</strong> You pre-install or integrate Cherry Studio into hardware
devices or products for bundled sale.</li>
<li><strong>Large-scale Procurement by Government or Educational Institutions:</strong> Your usage scenario
involves large-scale procurement projects by government or educational institutions, especially in cases
involving sensitive requirements such as security and data privacy.</li>
<li><strong>Public Cloud Services:</strong> You provide public cloud-based product services utilizing Cherry
Studio.</li>
</ol>
</section>
<section class="mb-8">
<h2 class="text-xl font-semibold mb-4 text-gray-900">II. Contributor Agreement</h2>
<p class="mb-4 text-gray-700">As a contributor to Cherry Studio, you must agree to the following terms:</p>
<ol class="list-decimal pl-6 space-y-2 text-gray-700">
<li><strong>License Adjustments:</strong> The producer reserves the right to adjust the open-source license as
necessary, making it more strict or permissive.</li>
<li><strong>Commercial Usage:</strong> Your contributed code may be used commercially, including but not
limited to cloud business operations.</li>
</ol>
</section>
<section class="mb-8">
<h2 class="text-xl font-semibold mb-4 text-gray-900">III. Other Terms</h2>
<ol class="list-decimal pl-6 space-y-2 text-gray-700">
<li>Cherry Studio developers reserve the right of final interpretation of these agreement terms.</li>
<li>This agreement may be updated according to practical circumstances, and users will be notified of updates
through this software.</li>
</ol>
</section>
<p class="mt-8 text-gray-700">
Other than these specific conditions, all remaining rights and restrictions follow the Apache License 2.0. For
more detailed information regarding Apache License 2.0, please visit
<a href="http://www.apache.org/licenses/LICENSE-2.0"
class="text-blue-500 underline">http://www.apache.org/licenses/LICENSE-2.0</a>
class="text-blue-600 hover:underline">http://www.apache.org/licenses/LICENSE-2.0</a>.
</p>
</div>
</div>

View File

@@ -1,7 +1,6 @@
<!doctype html>
<html lang="en">
<head>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Github Releases Timeline</title>
@@ -9,194 +8,201 @@
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<script src="https://cdn.jsdelivr.net/npm/markdown-it@13.0.1/dist/markdown-it.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/typography@0.5.10/dist/typography.min.css"></script>
</head>
</head>
<body id="app">
<body id="app">
<div :class="isDark ? 'dark-bg' : 'bg'" class="min-h-screen">
<div class="max-w-3xl mx-auto py-12 px-4">
<h1 class="text-3xl font-bold mb-8" :class="isDark ? 'text-white' : 'text-gray-900'">Release Timeline</h1>
<div class="max-w-3xl mx-auto py-12 px-4">
<h1 class="text-3xl font-bold mb-8" :class="isDark ? 'text-white' : 'text-gray-900'">Release Timeline</h1>
<!-- Loading状态 -->
<div v-if="loading" class="text-center py-8">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-4"
:class="isDark ? 'border-gray-700 border-t-blue-500' : 'border-gray-300 border-t-blue-500'"></div>
</div>
<!-- Error 状态 -->
<div v-else-if="error" class="text-red-500 text-center py-8">{{ error }}</div>
<!-- Release 列表 -->
<div v-else class="space-y-8">
<div v-for="release in releases" :key="release.id" class="relative pl-8"
:class="isDark ? 'border-l-2 border-gray-700' : 'border-l-2 border-gray-200'">
<div class="absolute -left-2 top-0 w-4 h-4 rounded-full bg-green-500"></div>
<div class="rounded-lg shadow-sm p-6 transition-shadow"
:class="isDark ? 'bg-black hover:shadow-md hover:shadow-black' : 'bg-white hover:shadow-md'">
<div class="flex items-start justify-between mb-4">
<div>
<h2 class="text-xl font-semibold" :class="isDark ? 'text-white' : 'text-gray-900'">
{{ release.name || release.tag_name }}
</h2>
<p class="text-sm mt-1" :class="isDark ? 'text-gray-400' : 'text-gray-500'">
{{ formatDate(release.published_at) }}
</p>
</div>
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium"
:class="isDark ? 'bg-green-900 text-green-200' : 'bg-green-100 text-green-800'">
{{ release.tag_name }}
</span>
</div>
<div class="prose" :class="isDark ? 'text-gray-300 dark-prose' : 'text-gray-600'"
v-html="renderMarkdown(release.body)"></div>
</div>
</div>
</div>
<!-- Loading状态 -->
<div v-if="loading" class="text-center py-8">
<div
class="inline-block animate-spin rounded-full h-8 w-8 border-4"
:class="isDark ? 'border-gray-700 border-t-blue-500' : 'border-gray-300 border-t-blue-500'"></div>
</div>
<!-- Error 状态 -->
<div v-else-if="error" class="text-red-500 text-center py-8">{{ error }}</div>
<!-- Release 列表 -->
<div v-else class="space-y-8">
<div
v-for="release in releases"
:key="release.id"
class="relative pl-8"
:class="isDark ? 'border-l-2 border-gray-700' : 'border-l-2 border-gray-200'">
<div class="absolute -left-2 top-0 w-4 h-4 rounded-full bg-green-500"></div>
<div
class="rounded-lg shadow-sm p-6 transition-shadow"
:class="isDark ? 'bg-black hover:shadow-md hover:shadow-black' : 'bg-white hover:shadow-md'">
<div class="flex items-start justify-between mb-4">
<div>
<h2 class="text-xl font-semibold" :class="isDark ? 'text-white' : 'text-gray-900'">
{{ release.name || release.tag_name }}
</h2>
<p class="text-sm mt-1" :class="isDark ? 'text-gray-400' : 'text-gray-500'">
{{ formatDate(release.published_at) }}
</p>
</div>
<span
class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium"
:class="isDark ? 'bg-green-900 text-green-200' : 'bg-green-100 text-green-800'">
{{ release.tag_name }}
</span>
</div>
<div
class="prose"
:class="isDark ? 'text-gray-300 dark-prose' : 'text-gray-600'"
v-html="renderMarkdown(release.body)"></div>
</div>
</div>
</div>
</div>
</div>
<script>
const md = window.markdownit({
breaks: true,
linkify: true
})
const md = window.markdownit({
breaks: true,
linkify: true
})
const { createApp } = Vue
const { createApp } = Vue
createApp({
data() {
return {
releases: [],
loading: true,
error: null,
isDark: false
}
},
methods: {
async fetchReleases() {
try {
this.loading = true
this.error = null
const response = await fetch('https://api.github.com/repos/kangfenmao/cherry-studio/releases')
if (!response.ok) {
throw new Error('Failed to fetch releases')
}
this.releases = await response.json()
} catch (err) {
this.error = 'Error loading releases: ' + err.message
} finally {
this.loading = false
}
},
formatDate(dateString) {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
},
renderMarkdown(content) {
if (!content) return ''
return md.render(content)
},
initTheme() {
// 从 URL 参数获取主题设置
const url = new URL(window.location.href)
const theme = url.searchParams.get('theme')
this.isDark = theme === 'dark'
}
},
mounted() {
this.initTheme()
this.fetchReleases()
createApp({
data() {
return {
releases: [],
loading: true,
error: null,
isDark: false
}
},
methods: {
async fetchReleases() {
try {
this.loading = true
this.error = null
const response = await fetch('https://api.github.com/repos/kangfenmao/cherry-studio/releases')
if (!response.ok) {
throw new Error('Failed to fetch releases')
}
this.releases = await response.json()
} catch (err) {
this.error = 'Error loading releases: ' + err.message
} finally {
this.loading = false
}
}).mount('#app')
},
formatDate(dateString) {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
},
renderMarkdown(content) {
if (!content) return ''
return md.render(content)
},
initTheme() {
// 从 URL 参数获取主题设置
const url = new URL(window.location.href)
const theme = url.searchParams.get('theme')
this.isDark = theme === 'dark'
}
},
mounted() {
this.initTheme()
this.fetchReleases()
}
}).mount('#app')
</script>
<style>
/* 基础的 Markdown 样式 */
.prose {
line-height: 1.6;
}
/* 基础的 Markdown 样式 */
.prose {
line-height: 1.6;
}
.prose h1 {
font-size: 1.5em;
margin: 1em 0;
}
.prose h1 {
font-size: 1.5em;
margin: 1em 0;
}
.prose h2 {
font-size: 1.3em;
margin: 0.8em 0;
}
.prose h2 {
font-size: 1.3em;
margin: 0.8em 0;
}
.prose h3 {
font-size: 1.1em;
margin: 0.6em 0;
}
.prose h3 {
font-size: 1.1em;
margin: 0.6em 0;
}
.prose ul {
list-style-type: disc;
margin-left: 1.5em;
margin-bottom: 1em;
}
.prose ul {
list-style-type: disc;
margin-left: 1.5em;
margin-bottom: 1em;
}
.prose ol {
list-style-type: decimal;
margin-left: 1.5em;
margin-bottom: 1em;
}
.prose ol {
list-style-type: decimal;
margin-left: 1.5em;
margin-bottom: 1em;
}
.prose code {
padding: 0.2em 0.4em;
border-radius: 0.2em;
font-size: 0.9em;
}
.prose code {
padding: 0.2em 0.4em;
border-radius: 0.2em;
font-size: 0.9em;
}
.dark .prose code {
background-color: #1f2937;
}
.dark .prose code {
background-color: #1f2937;
}
.prose code {
background-color: #f3f4f6;
}
.prose code {
background-color: #f3f4f6;
}
.prose pre code {
display: block;
padding: 1em;
overflow-x: auto;
}
.prose pre code {
display: block;
padding: 1em;
overflow-x: auto;
}
.prose a {
color: #3b82f6;
text-decoration: underline;
}
.prose a {
color: #3b82f6;
text-decoration: underline;
}
.dark .prose a {
color: #60a5fa;
}
.dark .prose a {
color: #60a5fa;
}
.prose blockquote {
border-left: 4px solid #e5e7eb;
padding-left: 1em;
margin: 1em 0;
}
.prose blockquote {
border-left: 4px solid #e5e7eb;
padding-left: 1em;
margin: 1em 0;
}
.dark .prose blockquote {
border-left-color: #374151;
color: #9ca3af;
}
.dark .prose blockquote {
border-left-color: #374151;
color: #9ca3af;
}
.dark .prose {
color: #e5e7eb;
}
.dark .prose {
color: #e5e7eb;
}
.dark-bg {
background-color: #151515;
}
.dark-bg {
background-color: #151515;
}
.bg {
background-color: #f2f2f2;
}
.bg {
background-color: #f2f2f2;
}
</style>
</body>
</html>
</body>
</html>

View File

@@ -1,68 +0,0 @@
<head>
<style>
body {
margin: 0;
}
</style>
<script src="https://unpkg.com/3d-force-graph"></script>
</head>
<body>
<div id="3d-graph"></div>
<script src="./js/bridge.js"></script>
<script type="module">
import { getQueryParam } from './js/utils.js'
const apiUrl = getQueryParam('apiUrl')
const modelId = getQueryParam('modelId')
const jsonUrl = `${apiUrl}/v1/global_graph/${modelId}`
const infoCard = document.createElement('div')
infoCard.style.position = 'fixed'
infoCard.style.backgroundColor = 'rgba(255, 255, 255, 0.9)'
infoCard.style.padding = '8px'
infoCard.style.borderRadius = '4px'
infoCard.style.boxShadow = '0 2px 5px rgba(0,0,0,0.2)'
infoCard.style.fontSize = '12px'
infoCard.style.maxWidth = '200px'
infoCard.style.display = 'none'
infoCard.style.zIndex = '1000'
document.body.appendChild(infoCard)
document.addEventListener('mousemove', (event) => {
infoCard.style.left = `${event.clientX + 10}px`
infoCard.style.top = `${event.clientY + 10}px`
})
const elem = document.getElementById('3d-graph')
const Graph = ForceGraph3D()(elem)
.jsonUrl(jsonUrl)
.nodeAutoColorBy((node) => node.properties.type || 'default')
.nodeVal((node) => node.properties.degree)
.linkWidth((link) => link.properties.weight)
.onNodeHover((node) => {
if (node) {
infoCard.innerHTML = `
<div style="font-weight: bold; margin-bottom: 4px; color: #333;">
${node.properties.title}
</div>
<div style="color: #666;">
${node.properties.description}
</div>`
infoCard.style.display = 'block'
} else {
infoCard.style.display = 'none'
}
})
.onNodeClick((node) => {
const url = `${apiUrl}/v1/references/${modelId}/entities/${node.properties.human_readable_id}`
window.api.minApp({
url,
windowOptions: {
title: node.properties.title,
width: 500,
height: 800
}
})
})
</script>
</body>

View File

@@ -6,8 +6,8 @@ const AdmZip = require('adm-zip')
const { downloadWithRedirects } = require('./download')
// Base URL for downloading bun binaries
const BUN_RELEASE_BASE_URL = 'https://github.com/oven-sh/bun/releases/download'
const DEFAULT_BUN_VERSION = '1.2.5' // Default fallback version
const BUN_RELEASE_BASE_URL = 'https://gitcode.com/CherryHQ/bun/releases/download'
const DEFAULT_BUN_VERSION = '1.2.9' // Default fallback version
// Mapping of platform+arch to binary package name
const BUN_PACKAGES = {

View File

@@ -7,8 +7,8 @@ const AdmZip = require('adm-zip')
const { downloadWithRedirects } = require('./download')
// Base URL for downloading uv binaries
const UV_RELEASE_BASE_URL = 'https://github.com/astral-sh/uv/releases/download'
const DEFAULT_UV_VERSION = '0.6.6'
const UV_RELEASE_BASE_URL = 'https://gitcode.com/CherryHQ/uv/releases/download'
const DEFAULT_UV_VERSION = '0.6.14'
// Mapping of platform+arch to binary package name
const UV_PACKAGES = {

View File

@@ -18,28 +18,48 @@ exports.default = async function (context) {
'node_modules'
)
removeDifferentArchNodeFiles(node_modules_path, '@libsql', arch === Arch.arm64 ? ['darwin-arm64'] : ['darwin-x64'])
keepPackageNodeFiles(node_modules_path, '@libsql', arch === Arch.arm64 ? ['darwin-arm64'] : ['darwin-x64'])
}
if (platform === 'linux') {
const node_modules_path = path.join(context.appOutDir, 'resources', 'app.asar.unpacked', 'node_modules')
const _arch = arch === Arch.arm64 ? ['linux-arm64-gnu', 'linux-arm64-musl'] : ['linux-x64-gnu', 'linux-x64-musl']
removeDifferentArchNodeFiles(node_modules_path, '@libsql', _arch)
keepPackageNodeFiles(node_modules_path, '@libsql', _arch)
}
if (platform === 'windows') {
const node_modules_path = path.join(context.appOutDir, 'resources', 'app.asar.unpacked', 'node_modules')
removeDifferentArchNodeFiles(node_modules_path, '@libsql', ['win32-x64-msvc'])
if (arch === Arch.arm64) {
keepPackageNodeFiles(node_modules_path, '@strongtz', ['win32-arm64-msvc'])
keepPackageNodeFiles(node_modules_path, '@libsql', ['win32-arm64-msvc'])
}
if (arch === Arch.x64) {
keepPackageNodeFiles(node_modules_path, '@strongtz', ['win32-x64-msvc'])
keepPackageNodeFiles(node_modules_path, '@libsql', ['win32-x64-msvc'])
}
}
}
function removeDifferentArchNodeFiles(nodeModulesPath, packageName, arch) {
/**
* 使用指定架构的 node_modules 文件
* @param {*} nodeModulesPath
* @param {*} packageName
* @param {*} arch
* @returns
*/
function keepPackageNodeFiles(nodeModulesPath, packageName, arch) {
const modulePath = path.join(nodeModulesPath, packageName)
if (!fs.existsSync(modulePath)) {
console.log(`[After Pack] Directory does not exist: ${modulePath}`)
return
}
const dirs = fs.readdirSync(modulePath)
dirs
.filter((dir) => !arch.includes(dir))
.forEach((dir) => {
fs.rmSync(path.join(modulePath, dir), { recursive: true, force: true })
console.log(`Removed dir: ${dir}`, arch)
console.log(`[After Pack] Removed dir: ${dir}`, arch)
})
}

View File

@@ -0,0 +1,23 @@
const fs = require('fs')
exports.default = function (buildResult) {
try {
console.log('[artifact build completed] rename artifact file...')
if (!buildResult.file.includes(' ')) {
return
}
let oldFilePath = buildResult.file
if (oldFilePath.includes('-portable') && !oldFilePath.includes('-x64') && !oldFilePath.includes('-arm64')) {
console.log('[artifact build completed] delete portable file:', oldFilePath)
fs.unlinkSync(oldFilePath)
return
}
const newfilePath = oldFilePath.replace(/ /g, '-')
fs.renameSync(oldFilePath, newfilePath)
buildResult.file = newfilePath
console.log(`[artifact build completed] rename file ${oldFilePath} to ${newfilePath} `)
} catch (error) {
console.error('Error renaming file:', error)
}
}

View File

@@ -33,6 +33,10 @@ async function downloadNpm(platform) {
'@libsql/win32-x64-msvc',
'https://registry.npmjs.org/@libsql/win32-x64-msvc/-/win32-x64-msvc-0.4.7.tgz'
)
downloadNpmPackage(
'@strongtz/win32-arm64-msvc',
'https://registry.npmjs.org/@strongtz/win32-arm64-msvc/-/win32-arm64-msvc-0.4.7.tgz'
)
}
}

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)

View File

@@ -1,58 +0,0 @@
// replaceSpaces.js
const fs = require('fs')
const path = require('path')
const directory = 'dist'
// 处理文件名中的空格
function replaceFileNames() {
fs.readdir(directory, (err, files) => {
if (err) throw err
files.forEach((file) => {
const oldPath = path.join(directory, file)
const newPath = path.join(directory, file.replace(/ /g, '-'))
fs.stat(oldPath, (err, stats) => {
if (err) throw err
if (stats.isFile() && oldPath !== newPath) {
fs.rename(oldPath, newPath, (err) => {
if (err) throw err
console.log(`Renamed: ${oldPath} -> ${newPath}`)
})
}
})
})
})
}
function replaceYmlContent() {
fs.readdir(directory, (err, files) => {
if (err) throw err
files.forEach((file) => {
if (path.extname(file).toLowerCase() === '.yml') {
const filePath = path.join(directory, file)
fs.readFile(filePath, 'utf8', (err, data) => {
if (err) throw err
// 替换内容
const newContent = data.replace(/Cherry Studio-/g, 'Cherry-Studio-')
// 写回文件
fs.writeFile(filePath, newContent, 'utf8', (err) => {
if (err) throw err
console.log(`Updated content in: ${filePath}`)
})
})
}
})
})
}
// 执行两个操作
replaceFileNames()
replaceYmlContent()

View File

@@ -22,7 +22,8 @@ function downloadNpmPackage(packageName, url) {
console.log(`Extracting ${filename}...`)
execSync(`tar -xvf ${filename}`)
execSync(`rm -rf ${filename}`)
execSync(`mv package ${targetDir}`)
execSync(`mkdir -p ${targetDir}`)
execSync(`mv package/* ${targetDir}/`)
} catch (error) {
console.error(`Error processing ${packageName}: ${error.message}`)
if (fs.existsSync(filename)) {

View File

@@ -1,10 +1,13 @@
import { electronApp, optimizer } from '@electron-toolkit/utils'
import { replaceDevtoolsFont } from '@main/utils/windowUtil'
import { IpcChannel } from '@shared/IpcChannel'
import { app, ipcMain } from 'electron'
import installExtension, { REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS } from 'electron-devtools-installer'
import Logger from 'electron-log'
import { registerIpc } from './ipc'
import { configManager } from './services/ConfigManager'
import mcpService from './services/MCPService'
import { CHERRY_STUDIO_PROTOCOL, handleProtocolUrl, registerProtocolClient } from './services/ProtocolClient'
import { registerShortcuts } from './services/ShortcutService'
import { TrayService } from './services/TrayService'
@@ -45,6 +48,9 @@ if (!app.requestSingleInstanceLock()) {
registerIpc(mainWindow, app)
// 注意: MsTTS IPC处理程序已在ipc.ts中注册
// 不需要再次调用registerMsTTSIpcHandlers()
replaceDevtoolsFont(mainWindow)
if (process.env.NODE_ENV === 'development') {
@@ -52,9 +58,13 @@ if (!app.requestSingleInstanceLock()) {
.then((name) => console.log(`Added Extension: ${name}`))
.catch((err) => console.log('An error occurred: ', err))
}
ipcMain.handle('system:getDeviceType', () => {
ipcMain.handle(IpcChannel.System_GetDeviceType, () => {
return process.platform === 'darwin' ? 'mac' : process.platform === 'win32' ? 'windows' : 'linux'
})
ipcMain.handle(IpcChannel.System_GetHostname, () => {
return require('os').hostname()
})
})
registerProtocolClient(app)
@@ -91,6 +101,15 @@ if (!app.requestSingleInstanceLock()) {
app.isQuitting = true
})
app.on('will-quit', async () => {
// event.preventDefault()
try {
await mcpService.cleanup()
} catch (error) {
Logger.error('Error cleaning up MCP service:', error)
}
})
// 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.
}

View File

@@ -0,0 +1,14 @@
interface CreateOAuthUrlArgs {
app: string;
}
declare function createOAuthUrl({ app }: CreateOAuthUrlArgs): Promise<string>;
declare function _dont_use_in_prod_createOAuthUrl({ app, }: CreateOAuthUrlArgs): Promise<string>;
interface DecryptSecretArgs {
app: string;
s: string;
}
declare function decryptSecret({ app, s }: DecryptSecretArgs): Promise<string>;
declare function _dont_use_in_prod_decryptSecret({ app, s, }: DecryptSecretArgs): Promise<string>;
export { type CreateOAuthUrlArgs, type DecryptSecretArgs, _dont_use_in_prod_createOAuthUrl, _dont_use_in_prod_decryptSecret, createOAuthUrl, decryptSecret };

View File

@@ -1,8 +0,0 @@
declare function decrypt(app: string, s: string): string;
interface Secret {
app: string;
}
declare function createOAuthUrl(secret: Secret): string;
export { type Secret, createOAuthUrl, decrypt };

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,13 +1,16 @@
import fs from 'node:fs'
import { arch } from 'node:os'
import { isMac, isWin } from '@main/constant'
import { getBinaryPath, isBinaryExists, runInstallScript } from '@main/utils/process'
import { IpcChannel } from '@shared/IpcChannel'
import { Shortcut, ThemeMode } from '@types'
import { BrowserWindow, ipcMain, session, shell } from 'electron'
import log from 'electron-log'
import { titleBarOverlayDark, titleBarOverlayLight } from './config'
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'
@@ -17,15 +20,17 @@ 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'
import { searchService } from './services/SearchService'
import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService'
import { TrayService } from './services/TrayService'
import { windowService } from './services/WindowService'
import { getResourcePath } from './utils'
import { decrypt, encrypt } from './utils/aes'
import { getFilesDir } from './utils/file'
import { getConfigDir, getFilesDir } from './utils/file'
import { compress, decompress } from './utils/zip'
const fileManager = new FileStorage()
@@ -36,17 +41,19 @@ const obsidianVaultService = new ObsidianVaultService()
export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
const appUpdater = new AppUpdater(mainWindow)
ipcMain.handle('app:info', () => ({
ipcMain.handle(IpcChannel.App_Info, () => ({
version: app.getVersion(),
isPackaged: app.isPackaged,
appPath: app.getAppPath(),
filesPath: getFilesDir(),
configPath: getConfigDir(),
appDataPath: app.getPath('userData'),
resourcesPath: getResourcePath(),
logsPath: log.transports.file.getFile().path
logsPath: log.transports.file.getFile().path,
arch: arch()
}))
ipcMain.handle('app:proxy', async (_, proxy: string) => {
ipcMain.handle(IpcChannel.App_Proxy, async (_, proxy: string) => {
let proxyConfig: ProxyConfig
if (proxy === 'system') {
@@ -60,19 +67,19 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
await proxyManager.configureProxy(proxyConfig)
})
ipcMain.handle('app:reload', () => mainWindow.reload())
ipcMain.handle('open:website', (_, url: string) => shell.openExternal(url))
ipcMain.handle(IpcChannel.App_Reload, () => mainWindow.reload())
ipcMain.handle(IpcChannel.Open_Website, (_, url: string) => shell.openExternal(url))
// Update
ipcMain.handle('app:show-update-dialog', () => appUpdater.showUpdateDialog(mainWindow))
ipcMain.handle(IpcChannel.App_ShowUpdateDialog, () => appUpdater.showUpdateDialog(mainWindow))
// language
ipcMain.handle('app:set-language', (_, language) => {
ipcMain.handle(IpcChannel.App_SetLanguage, (_, language) => {
configManager.setLanguage(language)
})
// launch on boot
ipcMain.handle('app:set-launch-on-boot', (_, openAtLogin: boolean) => {
ipcMain.handle(IpcChannel.App_SetLaunchOnBoot, (_, openAtLogin: boolean) => {
// Set login item settings for windows and mac
// linux is not supported because it requires more file operations
if (isWin || isMac) {
@@ -81,32 +88,37 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
})
// launch to tray
ipcMain.handle('app:set-launch-to-tray', (_, isActive: boolean) => {
ipcMain.handle(IpcChannel.App_SetLaunchToTray, (_, isActive: boolean) => {
configManager.setLaunchToTray(isActive)
})
// tray
ipcMain.handle('app:set-tray', (_, isActive: boolean) => {
ipcMain.handle(IpcChannel.App_SetTray, (_, isActive: boolean) => {
configManager.setTray(isActive)
})
// to tray on close
ipcMain.handle('app:set-tray-on-close', (_, isActive: boolean) => {
ipcMain.handle(IpcChannel.App_SetTrayOnClose, (_, isActive: boolean) => {
configManager.setTrayOnClose(isActive)
})
ipcMain.handle('app:restart-tray', () => TrayService.getInstance().restartTray())
// auto update
ipcMain.handle(IpcChannel.App_SetAutoUpdate, (_, isActive: boolean) => {
configManager.setAutoUpdate(isActive)
})
ipcMain.handle('config:set', (_, key: string, value: any) => {
ipcMain.handle(IpcChannel.App_RestartTray, () => TrayService.getInstance().restartTray())
ipcMain.handle(IpcChannel.Config_Set, (_, key: string, value: any) => {
configManager.set(key, value)
})
ipcMain.handle('config:get', (_, key: string) => {
ipcMain.handle(IpcChannel.Config_Get, (_, key: string) => {
return configManager.get(key)
})
// theme
ipcMain.handle('app:set-theme', (event, theme: ThemeMode) => {
ipcMain.handle(IpcChannel.App_SetTheme, (event, theme: ThemeMode) => {
if (theme === configManager.getTheme()) return
configManager.setTheme(theme)
@@ -117,7 +129,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
// 向其他窗口广播主题变化
windows.forEach((win) => {
if (win.webContents.id !== senderWindowId) {
win.webContents.send('theme:change', theme)
win.webContents.send(IpcChannel.ThemeChange, theme)
}
})
@@ -125,8 +137,24 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
mainWindow.setTitleBarOverlay(theme === 'dark' ? titleBarOverlayDark : titleBarOverlayLight)
})
// custom css
ipcMain.handle(IpcChannel.App_SetCustomCss, (event, css: string) => {
if (css === configManager.getCustomCss()) return
configManager.setCustomCss(css)
// Broadcast to all windows including the mini window
const senderWindowId = event.sender.id
const windows = BrowserWindow.getAllWindows()
// 向其他窗口广播主题变化
windows.forEach((win) => {
if (win.webContents.id !== senderWindowId) {
win.webContents.send('custom-css:update', css)
}
})
})
// clear cache
ipcMain.handle('app:clear-cache', async () => {
ipcMain.handle(IpcChannel.App_ClearCache, async () => {
const sessions = [session.defaultSession, session.fromPartition('persist:webview')]
try {
@@ -139,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)
@@ -148,8 +176,9 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
})
// check for update
ipcMain.handle('app:check-for-update', async () => {
ipcMain.handle(IpcChannel.App_CheckForUpdate, async () => {
const update = await appUpdater.autoUpdater.checkForUpdates()
return {
currentVersion: appUpdater.autoUpdater.currentVersion,
updateInfo: update?.updateInfo
@@ -157,62 +186,51 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
})
// zip
ipcMain.handle('zip:compress', (_, text: string) => compress(text))
ipcMain.handle('zip:decompress', (_, text: Buffer) => decompress(text))
ipcMain.handle(IpcChannel.Zip_Compress, (_, text: string) => compress(text))
ipcMain.handle(IpcChannel.Zip_Decompress, (_, text: Buffer) => decompress(text))
// backup
ipcMain.handle('backup:backup', backupManager.backup)
ipcMain.handle('backup:restore', backupManager.restore)
ipcMain.handle('backup:backupToWebdav', backupManager.backupToWebdav)
ipcMain.handle('backup:restoreFromWebdav', backupManager.restoreFromWebdav)
ipcMain.handle('backup:listWebdavFiles', backupManager.listWebdavFiles)
ipcMain.handle('backup:checkConnection', backupManager.checkConnection)
ipcMain.handle('backup:createDirectory', backupManager.createDirectory)
ipcMain.handle(IpcChannel.Backup_Backup, backupManager.backup)
ipcMain.handle(IpcChannel.Backup_Restore, backupManager.restore)
ipcMain.handle(IpcChannel.Backup_BackupToWebdav, backupManager.backupToWebdav)
ipcMain.handle(IpcChannel.Backup_RestoreFromWebdav, backupManager.restoreFromWebdav)
ipcMain.handle(IpcChannel.Backup_ListWebdavFiles, backupManager.listWebdavFiles)
ipcMain.handle(IpcChannel.Backup_CheckConnection, backupManager.checkConnection)
ipcMain.handle(IpcChannel.Backup_CreateDirectory, backupManager.createDirectory)
ipcMain.handle(IpcChannel.Backup_DeleteWebdavFile, backupManager.deleteWebdavFile)
// file
ipcMain.handle('file:open', fileManager.open)
ipcMain.handle('file:openPath', fileManager.openPath)
ipcMain.handle('file:save', fileManager.save)
ipcMain.handle('file:select', fileManager.selectFile)
ipcMain.handle('file:upload', fileManager.uploadFile)
ipcMain.handle('file:clear', fileManager.clear)
ipcMain.handle('file:read', fileManager.readFile)
ipcMain.handle('file:delete', fileManager.deleteFile)
ipcMain.handle('file:get', fileManager.getFile)
ipcMain.handle('file:selectFolder', fileManager.selectFolder)
ipcMain.handle('file:create', fileManager.createTempFile)
ipcMain.handle('file:write', fileManager.writeFile)
ipcMain.handle('file:saveImage', fileManager.saveImage)
ipcMain.handle('file:base64Image', fileManager.base64Image)
ipcMain.handle('file:download', fileManager.downloadFile)
ipcMain.handle('file:copy', fileManager.copyFile)
ipcMain.handle('file:binaryFile', fileManager.binaryFile)
ipcMain.handle(IpcChannel.File_Open, fileManager.open)
ipcMain.handle(IpcChannel.File_OpenPath, fileManager.openPath)
ipcMain.handle(IpcChannel.File_Save, fileManager.save)
ipcMain.handle(IpcChannel.File_Select, fileManager.selectFile)
ipcMain.handle(IpcChannel.File_Upload, fileManager.uploadFile)
ipcMain.handle(IpcChannel.File_Clear, fileManager.clear)
ipcMain.handle(IpcChannel.File_Read, fileManager.readFile)
ipcMain.handle(IpcChannel.File_Delete, fileManager.deleteFile)
ipcMain.handle(IpcChannel.File_Get, fileManager.getFile)
ipcMain.handle(IpcChannel.File_SelectFolder, fileManager.selectFolder)
ipcMain.handle(IpcChannel.File_Create, fileManager.createTempFile)
ipcMain.handle(IpcChannel.File_Write, fileManager.writeFile)
ipcMain.handle(IpcChannel.File_SaveImage, fileManager.saveImage)
ipcMain.handle(IpcChannel.File_Base64Image, fileManager.base64Image)
ipcMain.handle(IpcChannel.File_Download, fileManager.downloadFile)
ipcMain.handle(IpcChannel.File_Copy, fileManager.copyFile)
ipcMain.handle(IpcChannel.File_BinaryFile, fileManager.binaryFile)
// fs
ipcMain.handle('fs:read', FileService.readFile)
// minapp
ipcMain.handle('minapp', (_, args) => {
windowService.createMinappWindow({
url: args.url,
parent: mainWindow,
windowOptions: {
...mainWindow.getBounds(),
...args.windowOptions
}
})
})
ipcMain.handle(IpcChannel.Fs_Read, FileService.readFile)
// export
ipcMain.handle('export:word', exportService.exportToWord)
ipcMain.handle(IpcChannel.Export_Word, exportService.exportToWord)
// open path
ipcMain.handle('open:path', async (_, path: string) => {
ipcMain.handle(IpcChannel.Open_Path, async (_, path: string) => {
await shell.openPath(path)
})
// shortcuts
ipcMain.handle('shortcuts:update', (_, shortcuts: Shortcut[]) => {
ipcMain.handle(IpcChannel.Shortcuts_Update, (_, shortcuts: Shortcut[]) => {
configManager.setShortcuts(shortcuts)
// Refresh shortcuts registration
if (mainWindow) {
@@ -222,20 +240,20 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
})
// knowledge base
ipcMain.handle('knowledge-base:create', KnowledgeService.create)
ipcMain.handle('knowledge-base:reset', KnowledgeService.reset)
ipcMain.handle('knowledge-base:delete', KnowledgeService.delete)
ipcMain.handle('knowledge-base:add', KnowledgeService.add)
ipcMain.handle('knowledge-base:remove', KnowledgeService.remove)
ipcMain.handle('knowledge-base:search', KnowledgeService.search)
ipcMain.handle('knowledge-base:rerank', KnowledgeService.rerank)
ipcMain.handle(IpcChannel.KnowledgeBase_Create, KnowledgeService.create)
ipcMain.handle(IpcChannel.KnowledgeBase_Reset, KnowledgeService.reset)
ipcMain.handle(IpcChannel.KnowledgeBase_Delete, KnowledgeService.delete)
ipcMain.handle(IpcChannel.KnowledgeBase_Add, KnowledgeService.add)
ipcMain.handle(IpcChannel.KnowledgeBase_Remove, KnowledgeService.remove)
ipcMain.handle(IpcChannel.KnowledgeBase_Search, KnowledgeService.search)
ipcMain.handle(IpcChannel.KnowledgeBase_Rerank, KnowledgeService.rerank)
// window
ipcMain.handle('window:set-minimum-size', (_, width: number, height: number) => {
ipcMain.handle(IpcChannel.Windows_SetMinimumSize, (_, width: number, height: number) => {
mainWindow?.setMinimumSize(width, height)
})
ipcMain.handle('window:reset-minimum-size', () => {
ipcMain.handle(IpcChannel.Windows_ResetMinimumSize, () => {
mainWindow?.setMinimumSize(1080, 600)
const [width, height] = mainWindow?.getSize() ?? [1080, 600]
if (width < 1080) {
@@ -244,59 +262,81 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
})
// gemini
ipcMain.handle('gemini:upload-file', GeminiService.uploadFile)
ipcMain.handle('gemini:base64-file', GeminiService.base64File)
ipcMain.handle('gemini:retrieve-file', GeminiService.retrieveFile)
ipcMain.handle('gemini:list-files', GeminiService.listFiles)
ipcMain.handle('gemini:delete-file', GeminiService.deleteFile)
ipcMain.handle(IpcChannel.Gemini_UploadFile, GeminiService.uploadFile)
ipcMain.handle(IpcChannel.Gemini_Base64File, GeminiService.base64File)
ipcMain.handle(IpcChannel.Gemini_RetrieveFile, GeminiService.retrieveFile)
ipcMain.handle(IpcChannel.Gemini_ListFiles, GeminiService.listFiles)
ipcMain.handle(IpcChannel.Gemini_DeleteFile, GeminiService.deleteFile)
// mini window
ipcMain.handle('miniwindow:show', () => windowService.showMiniWindow())
ipcMain.handle('miniwindow:hide', () => windowService.hideMiniWindow())
ipcMain.handle('miniwindow:close', () => windowService.closeMiniWindow())
ipcMain.handle('miniwindow:toggle', () => windowService.toggleMiniWindow())
ipcMain.handle('miniwindow:set-pin', (_, isPinned) => windowService.setPinMiniWindow(isPinned))
ipcMain.handle(IpcChannel.MiniWindow_Show, () => windowService.showMiniWindow())
ipcMain.handle(IpcChannel.MiniWindow_Hide, () => windowService.hideMiniWindow())
ipcMain.handle(IpcChannel.MiniWindow_Close, () => windowService.closeMiniWindow())
ipcMain.handle(IpcChannel.MiniWindow_Toggle, () => windowService.toggleMiniWindow())
ipcMain.handle(IpcChannel.MiniWindow_SetPin, (_, isPinned) => windowService.setPinMiniWindow(isPinned))
// aes
ipcMain.handle('aes:encrypt', (_, text: string, secretKey: string, iv: string) => encrypt(text, secretKey, iv))
ipcMain.handle('aes:decrypt', (_, encryptedData: string, iv: string, secretKey: string) =>
ipcMain.handle(IpcChannel.Aes_Encrypt, (_, text: string, secretKey: string, iv: string) =>
encrypt(text, secretKey, iv)
)
ipcMain.handle(IpcChannel.Aes_Decrypt, (_, encryptedData: string, iv: string, secretKey: string) =>
decrypt(encryptedData, iv, secretKey)
)
// Register MCP handlers
ipcMain.handle('mcp:remove-server', mcpService.removeServer)
ipcMain.handle('mcp:restart-server', mcpService.restartServer)
ipcMain.handle('mcp:stop-server', mcpService.stopServer)
ipcMain.handle('mcp:list-tools', mcpService.listTools)
ipcMain.handle('mcp:call-tool', mcpService.callTool)
ipcMain.handle('mcp:get-install-info', mcpService.getInstallInfo)
ipcMain.handle(IpcChannel.Mcp_RemoveServer, mcpService.removeServer)
ipcMain.handle(IpcChannel.Mcp_RestartServer, mcpService.restartServer)
ipcMain.handle(IpcChannel.Mcp_StopServer, mcpService.stopServer)
ipcMain.handle(IpcChannel.Mcp_ListTools, mcpService.listTools)
ipcMain.handle(IpcChannel.Mcp_CallTool, mcpService.callTool)
ipcMain.handle(IpcChannel.Mcp_ListPrompts, mcpService.listPrompts)
ipcMain.handle(IpcChannel.Mcp_GetPrompt, mcpService.getPrompt)
ipcMain.handle(IpcChannel.Mcp_ListResources, mcpService.listResources)
ipcMain.handle(IpcChannel.Mcp_GetResource, mcpService.getResource)
ipcMain.handle(IpcChannel.Mcp_GetInstallInfo, mcpService.getInstallInfo)
ipcMain.handle('app:is-binary-exist', (_, name: string) => isBinaryExists(name))
ipcMain.handle('app:get-binary-path', (_, name: string) => getBinaryPath(name))
ipcMain.handle('app:install-uv-binary', () => runInstallScript('install-uv.js'))
ipcMain.handle('app:install-bun-binary', () => runInstallScript('install-bun.js'))
ipcMain.handle(IpcChannel.App_IsBinaryExist, (_, name: string) => isBinaryExists(name))
ipcMain.handle(IpcChannel.App_GetBinaryPath, (_, name: string) => getBinaryPath(name))
ipcMain.handle(IpcChannel.App_InstallUvBinary, () => runInstallScript('install-uv.js'))
ipcMain.handle(IpcChannel.App_InstallBunBinary, () => runInstallScript('install-bun.js'))
//copilot
ipcMain.handle('copilot:get-auth-message', CopilotService.getAuthMessage)
ipcMain.handle('copilot:get-copilot-token', CopilotService.getCopilotToken)
ipcMain.handle('copilot:save-copilot-token', CopilotService.saveCopilotToken)
ipcMain.handle('copilot:get-token', CopilotService.getToken)
ipcMain.handle('copilot:logout', CopilotService.logout)
ipcMain.handle('copilot:get-user', CopilotService.getUser)
ipcMain.handle(IpcChannel.Copilot_GetAuthMessage, CopilotService.getAuthMessage)
ipcMain.handle(IpcChannel.Copilot_GetCopilotToken, CopilotService.getCopilotToken)
ipcMain.handle(IpcChannel.Copilot_SaveCopilotToken, CopilotService.saveCopilotToken)
ipcMain.handle(IpcChannel.Copilot_GetToken, CopilotService.getToken)
ipcMain.handle(IpcChannel.Copilot_Logout, CopilotService.logout)
ipcMain.handle(IpcChannel.Copilot_GetUser, CopilotService.getUser)
// Obsidian service
ipcMain.handle('obsidian:get-vaults', () => {
ipcMain.handle(IpcChannel.Obsidian_GetVaults, () => {
return obsidianVaultService.getVaults()
})
ipcMain.handle('obsidian:get-files', (_event, vaultName) => {
ipcMain.handle(IpcChannel.Obsidian_GetFiles, (_event, vaultName) => {
return obsidianVaultService.getFilesByVaultName(vaultName)
})
// nutstore
ipcMain.handle('nutstore:get-sso-url', NutstoreService.getNutstoreSSOUrl)
ipcMain.handle('nutstore:decrypt-token', (_, token: string) => NutstoreService.decryptToken(token))
ipcMain.handle('nutstore:get-directory-contents', (_, token: string, path: string) =>
ipcMain.handle(IpcChannel.Nutstore_GetSsoUrl, NutstoreService.getNutstoreSSOUrl)
ipcMain.handle(IpcChannel.Nutstore_DecryptToken, (_, token: string) => NutstoreService.decryptToken(token))
ipcMain.handle(IpcChannel.Nutstore_GetDirectoryContents, (_, token: string, path: string) =>
NutstoreService.getDirectoryContents(token, path)
)
// search window
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)
)
// 注册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,374 @@
// Brave Search MCP Server
// port https://github.com/modelcontextprotocol/servers/blob/main/src/brave-search/index.ts
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { CallToolRequestSchema, ListToolsRequestSchema, Tool } from '@modelcontextprotocol/sdk/types.js'
const WEB_SEARCH_TOOL: Tool = {
name: 'brave_web_search',
description:
'Performs a web search using the Brave Search API, ideal for general queries, news, articles, and online content. ' +
'Use this for broad information gathering, recent events, or when you need diverse web sources. ' +
'Supports pagination, content filtering, and freshness controls. ' +
'Maximum 20 results per request, with offset for pagination. ',
inputSchema: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'Search query (max 400 chars, 50 words)'
},
count: {
type: 'number',
description: 'Number of results (1-20, default 10)',
default: 10
},
offset: {
type: 'number',
description: 'Pagination offset (max 9, default 0)',
default: 0
}
},
required: ['query']
}
}
const LOCAL_SEARCH_TOOL: Tool = {
name: 'brave_local_search',
description:
"Searches for local businesses and places using Brave's Local Search API. " +
'Best for queries related to physical locations, businesses, restaurants, services, etc. ' +
'Returns detailed information including:\n' +
'- Business names and addresses\n' +
'- Ratings and review counts\n' +
'- Phone numbers and opening hours\n' +
"Use this when the query implies 'near me' or mentions specific locations. " +
'Automatically falls back to web search if no local results are found.',
inputSchema: {
type: 'object',
properties: {
query: {
type: 'string',
description: "Local search query (e.g. 'pizza near Central Park')"
},
count: {
type: 'number',
description: 'Number of results (1-20, default 5)',
default: 5
}
},
required: ['query']
}
}
const RATE_LIMIT = {
perSecond: 1,
perMonth: 15000
}
const requestCount = {
second: 0,
month: 0,
lastReset: Date.now()
}
function checkRateLimit() {
const now = Date.now()
if (now - requestCount.lastReset > 1000) {
requestCount.second = 0
requestCount.lastReset = now
}
if (requestCount.second >= RATE_LIMIT.perSecond || requestCount.month >= RATE_LIMIT.perMonth) {
throw new Error('Rate limit exceeded')
}
requestCount.second++
requestCount.month++
}
interface BraveWeb {
web?: {
results?: Array<{
title: string
description: string
url: string
language?: string
published?: string
rank?: number
}>
}
locations?: {
results?: Array<{
id: string // Required by API
title?: string
}>
}
}
interface BraveLocation {
id: string
name: string
address: {
streetAddress?: string
addressLocality?: string
addressRegion?: string
postalCode?: string
}
coordinates?: {
latitude: number
longitude: number
}
phone?: string
rating?: {
ratingValue?: number
ratingCount?: number
}
openingHours?: string[]
priceRange?: string
}
interface BravePoiResponse {
results: BraveLocation[]
}
interface BraveDescription {
descriptions: { [id: string]: string }
}
function isBraveWebSearchArgs(args: unknown): args is { query: string; count?: number } {
return (
typeof args === 'object' &&
args !== null &&
'query' in args &&
typeof (args as { query: string }).query === 'string'
)
}
function isBraveLocalSearchArgs(args: unknown): args is { query: string; count?: number } {
return (
typeof args === 'object' &&
args !== null &&
'query' in args &&
typeof (args as { query: string }).query === 'string'
)
}
async function performWebSearch(apiKey: string, query: string, count: number = 10, offset: number = 0) {
checkRateLimit()
const url = new URL('https://api.search.brave.com/res/v1/web/search')
url.searchParams.set('q', query)
url.searchParams.set('count', Math.min(count, 20).toString()) // API limit
url.searchParams.set('offset', offset.toString())
const response = await fetch(url, {
headers: {
Accept: 'application/json',
'Accept-Encoding': 'gzip',
'X-Subscription-Token': apiKey
}
})
if (!response.ok) {
throw new Error(`Brave API error: ${response.status} ${response.statusText}\n${await response.text()}`)
}
const data = (await response.json()) as BraveWeb
// Extract just web results
const results = (data.web?.results || []).map((result) => ({
title: result.title || '',
description: result.description || '',
url: result.url || ''
}))
return results.map((r) => `Title: ${r.title}\nDescription: ${r.description}\nURL: ${r.url}`).join('\n\n')
}
async function performLocalSearch(apiKey: string, query: string, count: number = 5) {
checkRateLimit()
// Initial search to get location IDs
const webUrl = new URL('https://api.search.brave.com/res/v1/web/search')
webUrl.searchParams.set('q', query)
webUrl.searchParams.set('search_lang', 'en')
webUrl.searchParams.set('result_filter', 'locations')
webUrl.searchParams.set('count', Math.min(count, 20).toString())
const webResponse = await fetch(webUrl, {
headers: {
Accept: 'application/json',
'Accept-Encoding': 'gzip',
'X-Subscription-Token': apiKey
}
})
if (!webResponse.ok) {
throw new Error(`Brave API error: ${webResponse.status} ${webResponse.statusText}\n${await webResponse.text()}`)
}
const webData = (await webResponse.json()) as BraveWeb
const locationIds =
webData.locations?.results?.filter((r): r is { id: string; title?: string } => r.id != null).map((r) => r.id) || []
if (locationIds.length === 0) {
return performWebSearch(apiKey, query, count) // Fallback to web search
}
// Get POI details and descriptions in parallel
const [poisData, descriptionsData] = await Promise.all([
getPoisData(apiKey, locationIds),
getDescriptionsData(apiKey, locationIds)
])
return formatLocalResults(poisData, descriptionsData)
}
async function getPoisData(apiKey: string, ids: string[]): Promise<BravePoiResponse> {
checkRateLimit()
const url = new URL('https://api.search.brave.com/res/v1/local/pois')
ids.filter(Boolean).forEach((id) => url.searchParams.append('ids', id))
const response = await fetch(url, {
headers: {
Accept: 'application/json',
'Accept-Encoding': 'gzip',
'X-Subscription-Token': apiKey
}
})
if (!response.ok) {
throw new Error(`Brave API error: ${response.status} ${response.statusText}\n${await response.text()}`)
}
const poisResponse = (await response.json()) as BravePoiResponse
return poisResponse
}
async function getDescriptionsData(apiKey: string, ids: string[]): Promise<BraveDescription> {
checkRateLimit()
const url = new URL('https://api.search.brave.com/res/v1/local/descriptions')
ids.filter(Boolean).forEach((id) => url.searchParams.append('ids', id))
const response = await fetch(url, {
headers: {
Accept: 'application/json',
'Accept-Encoding': 'gzip',
'X-Subscription-Token': apiKey
}
})
if (!response.ok) {
throw new Error(`Brave API error: ${response.status} ${response.statusText}\n${await response.text()}`)
}
const descriptionsData = (await response.json()) as BraveDescription
return descriptionsData
}
function formatLocalResults(poisData: BravePoiResponse, descData: BraveDescription): string {
return (
(poisData.results || [])
.map((poi) => {
const address =
[
poi.address?.streetAddress ?? '',
poi.address?.addressLocality ?? '',
poi.address?.addressRegion ?? '',
poi.address?.postalCode ?? ''
]
.filter((part) => part !== '')
.join(', ') || 'N/A'
return `Name: ${poi.name}
Address: ${address}
Phone: ${poi.phone || 'N/A'}
Rating: ${poi.rating?.ratingValue ?? 'N/A'} (${poi.rating?.ratingCount ?? 0} reviews)
Price Range: ${poi.priceRange || 'N/A'}
Hours: ${(poi.openingHours || []).join(', ') || 'N/A'}
Description: ${descData.descriptions[poi.id] || 'No description available'}
`
})
.join('\n---\n') || 'No local results found'
)
}
class BraveSearchServer {
public server: Server
private apiKey: string
constructor(apiKey: string) {
if (!apiKey) {
throw new Error('BRAVE_API_KEY is required for Brave Search MCP server')
}
this.apiKey = apiKey
this.server = new Server(
{
name: 'brave-search-server',
version: '0.1.0'
},
{
capabilities: {
tools: {}
}
}
)
this.initialize()
}
initialize() {
// Tool handlers
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [WEB_SEARCH_TOOL, LOCAL_SEARCH_TOOL]
}))
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
try {
const { name, arguments: args } = request.params
if (!args) {
throw new Error('No arguments provided')
}
switch (name) {
case 'brave_web_search': {
if (!isBraveWebSearchArgs(args)) {
throw new Error('Invalid arguments for brave_web_search')
}
const { query, count = 10 } = args
const results = await performWebSearch(this.apiKey, query, count)
return {
content: [{ type: 'text', text: results }],
isError: false
}
}
case 'brave_local_search': {
if (!isBraveLocalSearchArgs(args)) {
throw new Error('Invalid arguments for brave_local_search')
}
const { query, count = 5 } = args
const results = await performLocalSearch(this.apiKey, query, count)
return {
content: [{ type: 'text', text: results }],
isError: false
}
}
default:
return {
content: [{ type: 'text', text: `Unknown tool: ${name}` }],
isError: true
}
}
} catch (error) {
return {
content: [
{
type: 'text',
text: `Error: ${error instanceof Error ? error.message : String(error)}`
}
],
isError: true
}
}
})
}
}
export default BraveSearchServer

View File

@@ -0,0 +1,32 @@
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import Logger from 'electron-log'
import BraveSearchServer from './brave-search'
import FetchServer from './fetch'
import FileSystemServer from './filesystem'
import MemoryServer from './memory'
import ThinkingServer from './sequentialthinking'
export function createInMemoryMCPServer(name: string, args: string[] = [], envs: Record<string, string> = {}): Server {
Logger.info(`[MCP] Creating in-memory MCP server: ${name} with args: ${args} and envs: ${JSON.stringify(envs)}`)
switch (name) {
case '@cherry/memory': {
const envPath = envs.MEMORY_FILE_PATH
return new MemoryServer(envPath).server
}
case '@cherry/sequentialthinking': {
return new ThinkingServer().server
}
case '@cherry/brave-search': {
return new BraveSearchServer(envs.BRAVE_API_KEY).server
}
case '@cherry/fetch': {
return new FetchServer().server
}
case '@cherry/filesystem': {
return new FileSystemServer(args).server
}
default:
throw new Error(`Unknown in-memory MCP server: ${name}`)
}
}

View File

@@ -0,0 +1,236 @@
// port https://github.com/zcaceres/fetch-mcp/blob/main/src/index.ts
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
import { JSDOM } from 'jsdom'
import TurndownService from 'turndown'
import { z } from 'zod'
export const RequestPayloadSchema = z.object({
url: z.string().url(),
headers: z.record(z.string()).optional()
})
export type RequestPayload = z.infer<typeof RequestPayloadSchema>
export class Fetcher {
private static async _fetch({ url, headers }: RequestPayload): Promise<Response> {
try {
const response = await fetch(url, {
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
...headers
}
})
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`)
}
return response
} catch (e: unknown) {
if (e instanceof Error) {
throw new Error(`Failed to fetch ${url}: ${e.message}`)
} else {
throw new Error(`Failed to fetch ${url}: Unknown error`)
}
}
}
static async html(requestPayload: RequestPayload) {
try {
const response = await this._fetch(requestPayload)
const html = await response.text()
return { content: [{ type: 'text', text: html }], isError: false }
} catch (error) {
return {
content: [{ type: 'text', text: (error as Error).message }],
isError: true
}
}
}
static async json(requestPayload: RequestPayload) {
try {
const response = await this._fetch(requestPayload)
const json = await response.json()
return {
content: [{ type: 'text', text: JSON.stringify(json) }],
isError: false
}
} catch (error) {
return {
content: [{ type: 'text', text: (error as Error).message }],
isError: true
}
}
}
static async txt(requestPayload: RequestPayload) {
try {
const response = await this._fetch(requestPayload)
const html = await response.text()
const dom = new JSDOM(html)
const document = dom.window.document
const scripts = document.getElementsByTagName('script')
const styles = document.getElementsByTagName('style')
Array.from(scripts).forEach((script: any) => script.remove())
Array.from(styles).forEach((style: any) => style.remove())
const text = document.body.textContent || ''
const normalizedText = text.replace(/\s+/g, ' ').trim()
return {
content: [{ type: 'text', text: normalizedText }],
isError: false
}
} catch (error) {
return {
content: [{ type: 'text', text: (error as Error).message }],
isError: true
}
}
}
static async markdown(requestPayload: RequestPayload) {
try {
const response = await this._fetch(requestPayload)
const html = await response.text()
const turndownService = new TurndownService()
const markdown = turndownService.turndown(html)
return { content: [{ type: 'text', text: markdown }], isError: false }
} catch (error) {
return {
content: [{ type: 'text', text: (error as Error).message }],
isError: true
}
}
}
}
const server = new Server(
{
name: 'zcaceres/fetch',
version: '0.1.0'
},
{
capabilities: {
resources: {},
tools: {}
}
}
)
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: 'fetch_html',
description: 'Fetch a website and return the content as HTML',
inputSchema: {
type: 'object',
properties: {
url: {
type: 'string',
description: 'URL of the website to fetch'
},
headers: {
type: 'object',
description: 'Optional headers to include in the request'
}
},
required: ['url']
}
},
{
name: 'fetch_markdown',
description: 'Fetch a website and return the content as Markdown',
inputSchema: {
type: 'object',
properties: {
url: {
type: 'string',
description: 'URL of the website to fetch'
},
headers: {
type: 'object',
description: 'Optional headers to include in the request'
}
},
required: ['url']
}
},
{
name: 'fetch_txt',
description: 'Fetch a website, return the content as plain text (no HTML)',
inputSchema: {
type: 'object',
properties: {
url: {
type: 'string',
description: 'URL of the website to fetch'
},
headers: {
type: 'object',
description: 'Optional headers to include in the request'
}
},
required: ['url']
}
},
{
name: 'fetch_json',
description: 'Fetch a JSON file from a URL',
inputSchema: {
type: 'object',
properties: {
url: {
type: 'string',
description: 'URL of the JSON to fetch'
},
headers: {
type: 'object',
description: 'Optional headers to include in the request'
}
},
required: ['url']
}
}
]
}
})
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { arguments: args } = request.params
const validatedArgs = RequestPayloadSchema.parse(args)
if (request.params.name === 'fetch_html') {
const fetchResult = await Fetcher.html(validatedArgs)
return fetchResult
}
if (request.params.name === 'fetch_json') {
const fetchResult = await Fetcher.json(validatedArgs)
return fetchResult
}
if (request.params.name === 'fetch_txt') {
const fetchResult = await Fetcher.txt(validatedArgs)
return fetchResult
}
if (request.params.name === 'fetch_markdown') {
const fetchResult = await Fetcher.markdown(validatedArgs)
return fetchResult
}
throw new Error('Tool not found')
})
class FetchServer {
public server: Server
constructor() {
this.server = server
}
}
export default FetchServer

View File

@@ -0,0 +1,655 @@
// port https://github.com/modelcontextprotocol/servers/blob/main/src/filesystem/index.ts
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { CallToolRequestSchema, ListToolsRequestSchema, ToolSchema } from '@modelcontextprotocol/sdk/types.js'
import { createTwoFilesPatch } from 'diff'
import fs from 'fs/promises'
import { minimatch } from 'minimatch'
import os from 'os'
import path from 'path'
import { z } from 'zod'
import { zodToJsonSchema } from 'zod-to-json-schema'
// Normalize all paths consistently
function normalizePath(p: string): string {
return path.normalize(p)
}
function expandHome(filepath: string): string {
if (filepath.startsWith('~/') || filepath === '~') {
return path.join(os.homedir(), filepath.slice(1))
}
return filepath
}
// Security utilities
async function validatePath(allowedDirectories: string[], requestedPath: string): Promise<string> {
const expandedPath = expandHome(requestedPath)
const absolute = path.isAbsolute(expandedPath)
? path.resolve(expandedPath)
: path.resolve(process.cwd(), expandedPath)
const normalizedRequested = normalizePath(absolute)
// Check if path is within allowed directories
const isAllowed = allowedDirectories.some((dir) => normalizedRequested.startsWith(dir))
if (!isAllowed) {
throw new Error(
`Access denied - path outside allowed directories: ${absolute} not in ${allowedDirectories.join(', ')}`
)
}
// Handle symlinks by checking their real path
try {
const realPath = await fs.realpath(absolute)
const normalizedReal = normalizePath(realPath)
const isRealPathAllowed = allowedDirectories.some((dir) => normalizedReal.startsWith(dir))
if (!isRealPathAllowed) {
throw new Error('Access denied - symlink target outside allowed directories')
}
return realPath
} catch (error) {
// For new files that don't exist yet, verify parent directory
const parentDir = path.dirname(absolute)
try {
const realParentPath = await fs.realpath(parentDir)
const normalizedParent = normalizePath(realParentPath)
const isParentAllowed = allowedDirectories.some((dir) => normalizedParent.startsWith(dir))
if (!isParentAllowed) {
throw new Error('Access denied - parent directory outside allowed directories')
}
return absolute
} catch {
throw new Error(`Parent directory does not exist: ${parentDir}`)
}
}
}
// Schema definitions
const ReadFileArgsSchema = z.object({
path: z.string()
})
const ReadMultipleFilesArgsSchema = z.object({
paths: z.array(z.string())
})
const WriteFileArgsSchema = z.object({
path: z.string(),
content: z.string()
})
const EditOperation = z.object({
oldText: z.string().describe('Text to search for - must match exactly'),
newText: z.string().describe('Text to replace with')
})
const EditFileArgsSchema = z.object({
path: z.string(),
edits: z.array(EditOperation),
dryRun: z.boolean().default(false).describe('Preview changes using git-style diff format')
})
const CreateDirectoryArgsSchema = z.object({
path: z.string()
})
const ListDirectoryArgsSchema = z.object({
path: z.string()
})
const DirectoryTreeArgsSchema = z.object({
path: z.string()
})
const MoveFileArgsSchema = z.object({
source: z.string(),
destination: z.string()
})
const SearchFilesArgsSchema = z.object({
path: z.string(),
pattern: z.string(),
excludePatterns: z.array(z.string()).optional().default([])
})
const GetFileInfoArgsSchema = z.object({
path: z.string()
})
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const ToolInputSchema = ToolSchema.shape.inputSchema
type ToolInput = z.infer<typeof ToolInputSchema>
interface FileInfo {
size: number
created: Date
modified: Date
accessed: Date
isDirectory: boolean
isFile: boolean
permissions: string
}
// Tool implementations
async function getFileStats(filePath: string): Promise<FileInfo> {
const stats = await fs.stat(filePath)
return {
size: stats.size,
created: stats.birthtime,
modified: stats.mtime,
accessed: stats.atime,
isDirectory: stats.isDirectory(),
isFile: stats.isFile(),
permissions: stats.mode.toString(8).slice(-3)
}
}
async function searchFiles(
allowedDirectories: string[],
rootPath: string,
pattern: string,
excludePatterns: string[] = []
): Promise<string[]> {
const results: string[] = []
async function search(currentPath: string) {
const entries = await fs.readdir(currentPath, { withFileTypes: true })
for (const entry of entries) {
const fullPath = path.join(currentPath, entry.name)
try {
// Validate each path before processing
await validatePath(allowedDirectories, fullPath)
// Check if path matches any exclude pattern
const relativePath = path.relative(rootPath, fullPath)
const shouldExclude = excludePatterns.some((pattern) => {
const globPattern = pattern.includes('*') ? pattern : `**/${pattern}/**`
return minimatch(relativePath, globPattern, { dot: true })
})
if (shouldExclude) {
continue
}
if (entry.name.toLowerCase().includes(pattern.toLowerCase())) {
results.push(fullPath)
}
if (entry.isDirectory()) {
await search(fullPath)
}
} catch (error) {
// Skip invalid paths during search
continue
}
}
}
await search(rootPath)
return results
}
// file editing and diffing utilities
function normalizeLineEndings(text: string): string {
return text.replace(/\r\n/g, '\n')
}
function createUnifiedDiff(originalContent: string, newContent: string, filepath: string = 'file'): string {
// Ensure consistent line endings for diff
const normalizedOriginal = normalizeLineEndings(originalContent)
const normalizedNew = normalizeLineEndings(newContent)
return createTwoFilesPatch(filepath, filepath, normalizedOriginal, normalizedNew, 'original', 'modified')
}
async function applyFileEdits(
filePath: string,
edits: Array<{ oldText: string; newText: string }>,
dryRun = false
): Promise<string> {
// Read file content and normalize line endings
const content = normalizeLineEndings(await fs.readFile(filePath, 'utf-8'))
// Apply edits sequentially
let modifiedContent = content
for (const edit of edits) {
const normalizedOld = normalizeLineEndings(edit.oldText)
const normalizedNew = normalizeLineEndings(edit.newText)
// If exact match exists, use it
if (modifiedContent.includes(normalizedOld)) {
modifiedContent = modifiedContent.replace(normalizedOld, normalizedNew)
continue
}
// Otherwise, try line-by-line matching with flexibility for whitespace
const oldLines = normalizedOld.split('\n')
const contentLines = modifiedContent.split('\n')
let matchFound = false
for (let i = 0; i <= contentLines.length - oldLines.length; i++) {
const potentialMatch = contentLines.slice(i, i + oldLines.length)
// Compare lines with normalized whitespace
const isMatch = oldLines.every((oldLine, j) => {
const contentLine = potentialMatch[j]
return oldLine.trim() === contentLine.trim()
})
if (isMatch) {
// Preserve original indentation of first line
const originalIndent = contentLines[i].match(/^\s*/)?.[0] || ''
const newLines = normalizedNew.split('\n').map((line, j) => {
if (j === 0) return originalIndent + line.trimStart()
// For subsequent lines, try to preserve relative indentation
const oldIndent = oldLines[j]?.match(/^\s*/)?.[0] || ''
const newIndent = line.match(/^\s*/)?.[0] || ''
if (oldIndent && newIndent) {
const relativeIndent = newIndent.length - oldIndent.length
return originalIndent + ' '.repeat(Math.max(0, relativeIndent)) + line.trimStart()
}
return line
})
contentLines.splice(i, oldLines.length, ...newLines)
modifiedContent = contentLines.join('\n')
matchFound = true
break
}
}
if (!matchFound) {
throw new Error(`Could not find exact match for edit:\n${edit.oldText}`)
}
}
// Create unified diff
const diff = createUnifiedDiff(content, modifiedContent, filePath)
// Format diff with appropriate number of backticks
let numBackticks = 3
while (diff.includes('`'.repeat(numBackticks))) {
numBackticks++
}
const formattedDiff = `${'`'.repeat(numBackticks)}diff\n${diff}${'`'.repeat(numBackticks)}\n\n`
if (!dryRun) {
await fs.writeFile(filePath, modifiedContent, 'utf-8')
}
return formattedDiff
}
class FileSystemServer {
public server: Server
private allowedDirectories: string[]
constructor(allowedDirs: string[]) {
if (!Array.isArray(allowedDirs) || allowedDirs.length === 0) {
throw new Error('No allowed directories provided, please specify at least one directory in args')
}
this.allowedDirectories = allowedDirs.map((dir) => normalizePath(path.resolve(expandHome(dir))))
// Validate that all directories exist and are accessible
this.validateDirs().catch((error) => {
console.error('Error validating allowed directories:', error)
throw new Error(`Error validating allowed directories: ${error}`)
})
this.server = new Server(
{
name: 'secure-filesystem-server',
version: '0.2.0'
},
{
capabilities: {
tools: {}
}
}
)
this.initialize()
}
async validateDirs() {
// Validate that all directories exist and are accessible
await Promise.all(
this.allowedDirectories.map(async (dir) => {
try {
const stats = await fs.stat(expandHome(dir))
if (!stats.isDirectory()) {
console.error(`Error: ${dir} is not a directory`)
throw new Error(`Error: ${dir} is not a directory`)
}
} catch (error: any) {
console.error(`Error accessing directory ${dir}:`, error)
throw new Error(`Error accessing directory ${dir}:`, error)
}
})
)
}
initialize() {
// Tool handlers
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: 'read_file',
description:
'Read the complete contents of a file from the file system. ' +
'Handles various text encodings and provides detailed error messages ' +
'if the file cannot be read. Use this tool when you need to examine ' +
'the contents of a single file. Only works within allowed directories.',
inputSchema: zodToJsonSchema(ReadFileArgsSchema) as ToolInput
},
{
name: 'read_multiple_files',
description:
'Read the contents of multiple files simultaneously. This is more ' +
'efficient than reading files one by one when you need to analyze ' +
"or compare multiple files. Each file's content is returned with its " +
"path as a reference. Failed reads for individual files won't stop " +
'the entire operation. Only works within allowed directories.',
inputSchema: zodToJsonSchema(ReadMultipleFilesArgsSchema) as ToolInput
},
{
name: 'write_file',
description:
'Create a new file or completely overwrite an existing file with new content. ' +
'Use with caution as it will overwrite existing files without warning. ' +
'Handles text content with proper encoding. Only works within allowed directories.',
inputSchema: zodToJsonSchema(WriteFileArgsSchema) as ToolInput
},
{
name: 'edit_file',
description:
'Make line-based edits to a text file. Each edit replaces exact line sequences ' +
'with new content. Returns a git-style diff showing the changes made. ' +
'Only works within allowed directories.',
inputSchema: zodToJsonSchema(EditFileArgsSchema) as ToolInput
},
{
name: 'create_directory',
description:
'Create a new directory or ensure a directory exists. Can create multiple ' +
'nested directories in one operation. If the directory already exists, ' +
'this operation will succeed silently. Perfect for setting up directory ' +
'structures for projects or ensuring required paths exist. Only works within allowed directories.',
inputSchema: zodToJsonSchema(CreateDirectoryArgsSchema) as ToolInput
},
{
name: 'list_directory',
description:
'Get a detailed listing of all files and directories in a specified path. ' +
'Results clearly distinguish between files and directories with [FILE] and [DIR] ' +
'prefixes. This tool is essential for understanding directory structure and ' +
'finding specific files within a directory. Only works within allowed directories.',
inputSchema: zodToJsonSchema(ListDirectoryArgsSchema) as ToolInput
},
{
name: 'directory_tree',
description:
'Get a recursive tree view of files and directories as a JSON structure. ' +
"Each entry includes 'name', 'type' (file/directory), and 'children' for directories. " +
'Files have no children array, while directories always have a children array (which may be empty). ' +
'The output is formatted with 2-space indentation for readability. Only works within allowed directories.',
inputSchema: zodToJsonSchema(DirectoryTreeArgsSchema) as ToolInput
},
{
name: 'move_file',
description:
'Move or rename files and directories. Can move files between directories ' +
'and rename them in a single operation. If the destination exists, the ' +
'operation will fail. Works across different directories and can be used ' +
'for simple renaming within the same directory. Both source and destination must be within allowed directories.',
inputSchema: zodToJsonSchema(MoveFileArgsSchema) as ToolInput
},
{
name: 'search_files',
description:
'Recursively search for files and directories matching a pattern. ' +
'Searches through all subdirectories from the starting path. The search ' +
'is case-insensitive and matches partial names. Returns full paths to all ' +
"matching items. Great for finding files when you don't know their exact location. " +
'Only searches within allowed directories.',
inputSchema: zodToJsonSchema(SearchFilesArgsSchema) as ToolInput
},
{
name: 'get_file_info',
description:
'Retrieve detailed metadata about a file or directory. Returns comprehensive ' +
'information including size, creation time, last modified time, permissions, ' +
'and type. This tool is perfect for understanding file characteristics ' +
'without reading the actual content. Only works within allowed directories.',
inputSchema: zodToJsonSchema(GetFileInfoArgsSchema) as ToolInput
},
{
name: 'list_allowed_directories',
description:
'Returns the list of directories that this server is allowed to access. ' +
'Use this to understand which directories are available before trying to access files.',
inputSchema: {
type: 'object',
properties: {},
required: []
}
}
]
}
})
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
try {
const { name, arguments: args } = request.params
switch (name) {
case 'read_file': {
const parsed = ReadFileArgsSchema.safeParse(args)
if (!parsed.success) {
throw new Error(`Invalid arguments for read_file: ${parsed.error}`)
}
const validPath = await validatePath(this.allowedDirectories, parsed.data.path)
const content = await fs.readFile(validPath, 'utf-8')
return {
content: [{ type: 'text', text: content }]
}
}
case 'read_multiple_files': {
const parsed = ReadMultipleFilesArgsSchema.safeParse(args)
if (!parsed.success) {
throw new Error(`Invalid arguments for read_multiple_files: ${parsed.error}`)
}
const results = await Promise.all(
parsed.data.paths.map(async (filePath: string) => {
try {
const validPath = await validatePath(this.allowedDirectories, filePath)
const content = await fs.readFile(validPath, 'utf-8')
return `${filePath}:\n${content}\n`
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
return `${filePath}: Error - ${errorMessage}`
}
})
)
return {
content: [{ type: 'text', text: results.join('\n---\n') }]
}
}
case 'write_file': {
const parsed = WriteFileArgsSchema.safeParse(args)
if (!parsed.success) {
throw new Error(`Invalid arguments for write_file: ${parsed.error}`)
}
const validPath = await validatePath(this.allowedDirectories, parsed.data.path)
await fs.writeFile(validPath, parsed.data.content, 'utf-8')
return {
content: [{ type: 'text', text: `Successfully wrote to ${parsed.data.path}` }]
}
}
case 'edit_file': {
const parsed = EditFileArgsSchema.safeParse(args)
if (!parsed.success) {
throw new Error(`Invalid arguments for edit_file: ${parsed.error}`)
}
const validPath = await validatePath(this.allowedDirectories, parsed.data.path)
const result = await applyFileEdits(validPath, parsed.data.edits, parsed.data.dryRun)
return {
content: [{ type: 'text', text: result }]
}
}
case 'create_directory': {
const parsed = CreateDirectoryArgsSchema.safeParse(args)
if (!parsed.success) {
throw new Error(`Invalid arguments for create_directory: ${parsed.error}`)
}
const validPath = await validatePath(this.allowedDirectories, parsed.data.path)
await fs.mkdir(validPath, { recursive: true })
return {
content: [{ type: 'text', text: `Successfully created directory ${parsed.data.path}` }]
}
}
case 'list_directory': {
const parsed = ListDirectoryArgsSchema.safeParse(args)
if (!parsed.success) {
throw new Error(`Invalid arguments for list_directory: ${parsed.error}`)
}
const validPath = await validatePath(this.allowedDirectories, parsed.data.path)
const entries = await fs.readdir(validPath, { withFileTypes: true })
const formatted = entries
.map((entry) => `${entry.isDirectory() ? '[DIR]' : '[FILE]'} ${entry.name}`)
.join('\n')
return {
content: [{ type: 'text', text: formatted }]
}
}
case 'directory_tree': {
const parsed = DirectoryTreeArgsSchema.safeParse(args)
if (!parsed.success) {
throw new Error(`Invalid arguments for directory_tree: ${parsed.error}`)
}
interface TreeEntry {
name: string
type: 'file' | 'directory'
children?: TreeEntry[]
}
async function buildTree(allowedDirectories: string[], currentPath: string): Promise<TreeEntry[]> {
const validPath = await validatePath(allowedDirectories, currentPath)
const entries = await fs.readdir(validPath, { withFileTypes: true })
const result: TreeEntry[] = []
for (const entry of entries) {
const entryData: TreeEntry = {
name: entry.name,
type: entry.isDirectory() ? 'directory' : 'file'
}
if (entry.isDirectory()) {
const subPath = path.join(currentPath, entry.name)
entryData.children = await buildTree(allowedDirectories, subPath)
}
result.push(entryData)
}
return result
}
const treeData = await buildTree(this.allowedDirectories, parsed.data.path)
return {
content: [
{
type: 'text',
text: JSON.stringify(treeData, null, 2)
}
]
}
}
case 'move_file': {
const parsed = MoveFileArgsSchema.safeParse(args)
if (!parsed.success) {
throw new Error(`Invalid arguments for move_file: ${parsed.error}`)
}
const validSourcePath = await validatePath(this.allowedDirectories, parsed.data.source)
const validDestPath = await validatePath(this.allowedDirectories, parsed.data.destination)
await fs.rename(validSourcePath, validDestPath)
return {
content: [
{ type: 'text', text: `Successfully moved ${parsed.data.source} to ${parsed.data.destination}` }
]
}
}
case 'search_files': {
const parsed = SearchFilesArgsSchema.safeParse(args)
if (!parsed.success) {
throw new Error(`Invalid arguments for search_files: ${parsed.error}`)
}
const validPath = await validatePath(this.allowedDirectories, parsed.data.path)
const results = await searchFiles(
this.allowedDirectories,
validPath,
parsed.data.pattern,
parsed.data.excludePatterns
)
return {
content: [{ type: 'text', text: results.length > 0 ? results.join('\n') : 'No matches found' }]
}
}
case 'get_file_info': {
const parsed = GetFileInfoArgsSchema.safeParse(args)
if (!parsed.success) {
throw new Error(`Invalid arguments for get_file_info: ${parsed.error}`)
}
const validPath = await validatePath(this.allowedDirectories, parsed.data.path)
const info = await getFileStats(validPath)
return {
content: [
{
type: 'text',
text: Object.entries(info)
.map(([key, value]) => `${key}: ${value}`)
.join('\n')
}
]
}
}
case 'list_allowed_directories': {
return {
content: [
{
type: 'text',
text: `Allowed directories:\n${this.allowedDirectories.join('\n')}`
}
]
}
}
default:
throw new Error(`Unknown tool: ${name}`)
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
return {
content: [{ type: 'text', text: `Error: ${errorMessage}` }],
isError: true
}
}
})
}
}
export default FileSystemServer

View File

@@ -0,0 +1,700 @@
import { getConfigDir } from '@main/utils/file'
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } from '@modelcontextprotocol/sdk/types.js'
import { Mutex } from 'async-mutex' // 引入 Mutex
import { promises as fs } from 'fs'
import path from 'path'
// Define memory file path
const defaultMemoryPath = path.join(getConfigDir(), 'memory.json')
// Interfaces remain the same
interface Entity {
name: string
entityType: string
observations: string[]
}
interface Relation {
from: string
to: string
relationType: string
}
// Structure for storing the graph in memory and in the file
interface KnowledgeGraph {
entities: Entity[]
relations: Relation[]
}
// The KnowledgeGraphManager class contains all operations to interact with the knowledge graph
class KnowledgeGraphManager {
private memoryPath: string
private entities: Map<string, Entity> // Use Map for efficient entity lookup
private relations: Set<string> // Store stringified relations for easy Set operations
private fileMutex: Mutex // Mutex for file writing
private constructor(memoryPath: string) {
this.memoryPath = memoryPath
this.entities = new Map<string, Entity>()
this.relations = new Set<string>()
this.fileMutex = new Mutex()
}
// Static async factory method for initialization
public static async create(memoryPath: string): Promise<KnowledgeGraphManager> {
const manager = new KnowledgeGraphManager(memoryPath)
await manager._ensureMemoryPathExists()
await manager._loadGraphFromDisk()
return manager
}
private async _ensureMemoryPathExists(): Promise<void> {
try {
const directory = path.dirname(this.memoryPath)
await fs.mkdir(directory, { recursive: true })
try {
await fs.access(this.memoryPath)
} catch (error) {
// File doesn't exist, create an empty file with initial structure
await fs.writeFile(this.memoryPath, JSON.stringify({ entities: [], relations: [] }, null, 2))
}
} catch (error) {
console.error('Failed to ensure memory path exists:', error)
// Propagate the error or handle it more gracefully depending on requirements
throw new McpError(
ErrorCode.InternalError,
`Failed to ensure memory path: ${error instanceof Error ? error.message : String(error)}`
)
}
}
// Load graph from disk into memory (called once during initialization)
private async _loadGraphFromDisk(): Promise<void> {
try {
const data = await fs.readFile(this.memoryPath, 'utf-8')
// Handle empty file case
if (data.trim() === '') {
this.entities = new Map()
this.relations = new Set()
// Optionally write the initial empty structure back
await this._persistGraph()
return
}
const graph: KnowledgeGraph = JSON.parse(data)
this.entities.clear()
this.relations.clear()
graph.entities.forEach((entity) => this.entities.set(entity.name, entity))
graph.relations.forEach((relation) => this.relations.add(this._serializeRelation(relation)))
} catch (error) {
if (error instanceof Error && 'code' in error && (error as any).code === 'ENOENT') {
// File doesn't exist (should have been created by _ensureMemoryPathExists, but handle defensively)
this.entities = new Map()
this.relations = new Set()
await this._persistGraph() // Create the file with empty structure
} else if (error instanceof SyntaxError) {
console.error('Failed to parse memory.json, initializing with empty graph:', error)
// If JSON is invalid, start fresh and overwrite the corrupted file
this.entities = new Map()
this.relations = new Set()
await this._persistGraph()
} else {
console.error('Failed to load knowledge graph from disk:', error)
throw new McpError(
ErrorCode.InternalError,
`Failed to load graph: ${error instanceof Error ? error.message : String(error)}`
)
}
}
}
// Persist the current in-memory graph to disk using a mutex
private async _persistGraph(): Promise<void> {
const release = await this.fileMutex.acquire()
try {
const graphData: KnowledgeGraph = {
entities: Array.from(this.entities.values()),
relations: Array.from(this.relations).map((rStr) => this._deserializeRelation(rStr))
}
await fs.writeFile(this.memoryPath, JSON.stringify(graphData, null, 2))
} catch (error) {
console.error('Failed to save knowledge graph:', error)
// Decide how to handle write errors - potentially retry or notify
throw new McpError(
ErrorCode.InternalError,
`Failed to save graph: ${error instanceof Error ? error.message : String(error)}`
)
} finally {
release()
}
}
// Helper to consistently serialize relations for Set storage
private _serializeRelation(relation: Relation): string {
// Simple serialization, ensure order doesn't matter if properties are consistent
return JSON.stringify({ from: relation.from, to: relation.to, relationType: relation.relationType })
}
// Helper to deserialize relations from Set storage
private _deserializeRelation(relationStr: string): Relation {
return JSON.parse(relationStr) as Relation
}
async createEntities(entities: Entity[]): Promise<Entity[]> {
const newEntities: Entity[] = []
entities.forEach((entity) => {
if (!this.entities.has(entity.name)) {
// Ensure observations is always an array
const newEntity = { ...entity, observations: Array.isArray(entity.observations) ? entity.observations : [] }
this.entities.set(entity.name, newEntity)
newEntities.push(newEntity)
}
})
if (newEntities.length > 0) {
await this._persistGraph()
}
return newEntities
}
async createRelations(relations: Relation[]): Promise<Relation[]> {
const newRelations: Relation[] = []
relations.forEach((relation) => {
// Ensure related entities exist before creating a relation
if (!this.entities.has(relation.from) || !this.entities.has(relation.to)) {
console.warn(`Skipping relation creation: Entity not found for relation ${relation.from} -> ${relation.to}`)
return // Skip this relation
}
const relationStr = this._serializeRelation(relation)
if (!this.relations.has(relationStr)) {
this.relations.add(relationStr)
newRelations.push(relation)
}
})
if (newRelations.length > 0) {
await this._persistGraph()
}
return newRelations
}
async addObservations(
observations: { entityName: string; contents: string[] }[]
): Promise<{ entityName: string; addedObservations: string[] }[]> {
const results: { entityName: string; addedObservations: string[] }[] = []
let changed = false
observations.forEach((o) => {
const entity = this.entities.get(o.entityName)
if (!entity) {
// Option 1: Throw error
throw new McpError(ErrorCode.InvalidParams, `Entity with name ${o.entityName} not found`)
// Option 2: Skip and warn
// console.warn(`Entity with name ${o.entityName} not found when adding observations. Skipping.`);
// return;
}
// Ensure observations array exists
if (!Array.isArray(entity.observations)) {
entity.observations = []
}
const newObservations = o.contents.filter((content) => !entity.observations.includes(content))
if (newObservations.length > 0) {
entity.observations.push(...newObservations)
results.push({ entityName: o.entityName, addedObservations: newObservations })
changed = true
} else {
// Still include in results even if nothing was added, to confirm processing
results.push({ entityName: o.entityName, addedObservations: [] })
}
})
if (changed) {
await this._persistGraph()
}
return results
}
async deleteEntities(entityNames: string[]): Promise<void> {
let changed = false
const namesToDelete = new Set(entityNames)
// Delete entities
namesToDelete.forEach((name) => {
if (this.entities.delete(name)) {
changed = true
}
})
// Delete relations involving deleted entities
const relationsToDelete = new Set<string>()
this.relations.forEach((relStr) => {
const rel = this._deserializeRelation(relStr)
if (namesToDelete.has(rel.from) || namesToDelete.has(rel.to)) {
relationsToDelete.add(relStr)
}
})
relationsToDelete.forEach((relStr) => {
if (this.relations.delete(relStr)) {
changed = true
}
})
if (changed) {
await this._persistGraph()
}
}
async deleteObservations(deletions: { entityName: string; observations: string[] }[]): Promise<void> {
let changed = false
deletions.forEach((d) => {
const entity = this.entities.get(d.entityName)
if (entity && Array.isArray(entity.observations)) {
const initialLength = entity.observations.length
const observationsToDelete = new Set(d.observations)
entity.observations = entity.observations.filter((o) => !observationsToDelete.has(o))
if (entity.observations.length !== initialLength) {
changed = true
}
}
})
if (changed) {
await this._persistGraph()
}
}
async deleteRelations(relations: Relation[]): Promise<void> {
let changed = false
relations.forEach((rel) => {
const relStr = this._serializeRelation(rel)
if (this.relations.delete(relStr)) {
changed = true
}
})
if (changed) {
await this._persistGraph()
}
}
// Read the current state from memory
async readGraph(): Promise<KnowledgeGraph> {
// Return a deep copy to prevent external modification of the internal state
return JSON.parse(
JSON.stringify({
entities: Array.from(this.entities.values()),
relations: Array.from(this.relations).map((rStr) => this._deserializeRelation(rStr))
})
)
}
// Search operates on the in-memory graph
async searchNodes(query: string): Promise<KnowledgeGraph> {
const lowerCaseQuery = query.toLowerCase()
const filteredEntities = Array.from(this.entities.values()).filter(
(e) =>
e.name.toLowerCase().includes(lowerCaseQuery) ||
e.entityType.toLowerCase().includes(lowerCaseQuery) ||
(Array.isArray(e.observations) && e.observations.some((o) => o.toLowerCase().includes(lowerCaseQuery)))
)
const filteredEntityNames = new Set(filteredEntities.map((e) => e.name))
const filteredRelations = Array.from(this.relations)
.map((rStr) => this._deserializeRelation(rStr))
.filter((r) => filteredEntityNames.has(r.from) && filteredEntityNames.has(r.to))
return {
entities: filteredEntities,
relations: filteredRelations
}
}
// Open operates on the in-memory graph
async openNodes(names: string[]): Promise<KnowledgeGraph> {
const nameSet = new Set(names)
const filteredEntities = Array.from(this.entities.values()).filter((e) => nameSet.has(e.name))
const filteredEntityNames = new Set(filteredEntities.map((e) => e.name))
const filteredRelations = Array.from(this.relations)
.map((rStr) => this._deserializeRelation(rStr))
.filter((r) => filteredEntityNames.has(r.from) && filteredEntityNames.has(r.to))
return {
entities: filteredEntities,
relations: filteredRelations
}
}
}
class MemoryServer {
public server: Server
// Hold the manager instance, initialized asynchronously
private knowledgeGraphManager: KnowledgeGraphManager | null = null
private initializationPromise: Promise<void> // To track initialization
constructor(envPath: string = '') {
const memoryPath = envPath
? path.isAbsolute(envPath)
? envPath
: path.resolve(envPath) // Use path.resolve for relative paths based on CWD
: defaultMemoryPath
this.server = new Server(
{
name: 'memory-server',
version: '1.1.0' // Incremented version for changes
},
{
capabilities: {
tools: {}
}
}
)
// Start initialization, but don't block constructor
this.initializationPromise = this._initializeManager(memoryPath)
this.setupRequestHandlers() // Setup handlers immediately
}
// Private async method to handle manager initialization
private async _initializeManager(memoryPath: string): Promise<void> {
try {
this.knowledgeGraphManager = await KnowledgeGraphManager.create(memoryPath)
console.log('KnowledgeGraphManager initialized successfully.')
} catch (error) {
console.error('Failed to initialize KnowledgeGraphManager:', error)
// Server might be unusable, consider how to handle this state
// Maybe set a flag and return errors for all tool calls?
this.knowledgeGraphManager = null // Ensure it's null if init fails
}
}
// Ensures the manager is initialized before handling tool calls
private async _getManager(): Promise<KnowledgeGraphManager> {
await this.initializationPromise // Wait for initialization to complete
if (!this.knowledgeGraphManager) {
throw new McpError(ErrorCode.InternalError, 'Memory server failed to initialize. Cannot process requests.')
}
return this.knowledgeGraphManager
}
// Setup handlers (can be called from constructor)
setupRequestHandlers() {
// ListTools remains largely the same, descriptions might be updated if needed
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
// Ensure manager is ready before listing tools that depend on it
// Although ListTools itself doesn't *call* the manager, it implies the
// manager is ready to handle calls for those tools.
try {
await this._getManager() // Wait for initialization before confirming tools are available
} catch (error) {
// If manager failed to init, maybe return an empty tool list or throw?
console.error('Cannot list tools, manager initialization failed:', error)
return { tools: [] } // Return empty list if server is not ready
}
return {
tools: [
{
name: 'create_entities',
description: 'Create multiple new entities in the knowledge graph. Skips existing entities.',
inputSchema: {
type: 'object',
properties: {
entities: {
type: 'array',
items: {
type: 'object',
properties: {
name: { type: 'string', description: 'The name of the entity' },
entityType: { type: 'string', description: 'The type of the entity' },
observations: {
type: 'array',
items: { type: 'string' },
description: 'An array of observation contents associated with the entity',
default: [] // Add default empty array
}
},
required: ['name', 'entityType'] // Observations are optional now on creation
}
}
},
required: ['entities']
}
},
{
name: 'create_relations',
description:
'Create multiple new relations between EXISTING entities. Skips existing relations or relations with non-existent entities.',
inputSchema: {
type: 'object',
properties: {
relations: {
type: 'array',
items: {
type: 'object',
properties: {
from: { type: 'string', description: 'The name of the entity where the relation starts' },
to: { type: 'string', description: 'The name of the entity where the relation ends' },
relationType: { type: 'string', description: 'The type of the relation' }
},
required: ['from', 'to', 'relationType']
}
}
},
required: ['relations']
}
},
{
name: 'add_observations',
description: 'Add new observations to existing entities. Skips duplicate observations.',
inputSchema: {
type: 'object',
properties: {
observations: {
type: 'array',
items: {
type: 'object',
properties: {
entityName: { type: 'string', description: 'The name of the entity to add the observations to' },
contents: {
type: 'array',
items: { type: 'string' },
description: 'An array of observation contents to add'
}
},
required: ['entityName', 'contents']
}
}
},
required: ['observations']
}
},
{
name: 'delete_entities',
description: 'Delete multiple entities and their associated relations.',
inputSchema: {
type: 'object',
properties: {
entityNames: {
type: 'array',
items: { type: 'string' },
description: 'An array of entity names to delete'
}
},
required: ['entityNames']
}
},
{
name: 'delete_observations',
description: 'Delete specific observations from entities.',
inputSchema: {
type: 'object',
properties: {
deletions: {
type: 'array',
items: {
type: 'object',
properties: {
entityName: { type: 'string', description: 'The name of the entity containing the observations' },
observations: {
type: 'array',
items: { type: 'string' },
description: 'An array of observations to delete'
}
},
required: ['entityName', 'observations']
}
}
},
required: ['deletions']
}
},
{
name: 'delete_relations',
description: 'Delete multiple specific relations.',
inputSchema: {
type: 'object',
properties: {
relations: {
type: 'array',
items: {
type: 'object',
properties: {
from: { type: 'string', description: 'The name of the entity where the relation starts' },
to: { type: 'string', description: 'The name of the entity where the relation ends' },
relationType: { type: 'string', description: 'The type of the relation' }
},
required: ['from', 'to', 'relationType']
},
description: 'An array of relations to delete'
}
},
required: ['relations']
}
},
{
name: 'read_graph',
description: 'Read the entire knowledge graph from memory.',
inputSchema: {
type: 'object',
properties: {}
}
},
{
name: 'search_nodes',
description: 'Search nodes (entities and relations) in memory based on a query.',
inputSchema: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'The search query to match against entity names, types, and observation content'
}
},
required: ['query']
}
},
{
name: 'open_nodes',
description: 'Retrieve specific entities and their connecting relations from memory by name.',
inputSchema: {
type: 'object',
properties: {
names: {
type: 'array',
items: { type: 'string' },
description: 'An array of entity names to retrieve'
}
},
required: ['names']
}
}
]
}
})
// CallTool handler needs to await the manager and the async methods
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
const manager = await this._getManager() // Ensure manager is ready
const { name, arguments: args } = request.params
if (!args) {
// Use McpError for standard errors
throw new McpError(ErrorCode.InvalidParams, `No arguments provided for tool: ${name}`)
}
try {
switch (name) {
case 'create_entities':
// Validate args structure if necessary, though SDK might do basic validation
if (!args.entities || !Array.isArray(args.entities)) {
throw new McpError(
ErrorCode.InvalidParams,
`Invalid arguments for ${name}: 'entities' array is required.`
)
}
return {
content: [
{ type: 'text', text: JSON.stringify(await manager.createEntities(args.entities as Entity[]), null, 2) }
]
}
case 'create_relations':
if (!args.relations || !Array.isArray(args.relations)) {
throw new McpError(
ErrorCode.InvalidParams,
`Invalid arguments for ${name}: 'relations' array is required.`
)
}
return {
content: [
{
type: 'text',
text: JSON.stringify(await manager.createRelations(args.relations as Relation[]), null, 2)
}
]
}
case 'add_observations':
if (!args.observations || !Array.isArray(args.observations)) {
throw new McpError(
ErrorCode.InvalidParams,
`Invalid arguments for ${name}: 'observations' array is required.`
)
}
return {
content: [
{
type: 'text',
text: JSON.stringify(
await manager.addObservations(args.observations as { entityName: string; contents: string[] }[]),
null,
2
)
}
]
}
case 'delete_entities':
if (!args.entityNames || !Array.isArray(args.entityNames)) {
throw new McpError(
ErrorCode.InvalidParams,
`Invalid arguments for ${name}: 'entityNames' array is required.`
)
}
await manager.deleteEntities(args.entityNames as string[])
return { content: [{ type: 'text', text: 'Entities deleted successfully' }] }
case 'delete_observations':
if (!args.deletions || !Array.isArray(args.deletions)) {
throw new McpError(
ErrorCode.InvalidParams,
`Invalid arguments for ${name}: 'deletions' array is required.`
)
}
await manager.deleteObservations(args.deletions as { entityName: string; observations: string[] }[])
return { content: [{ type: 'text', text: 'Observations deleted successfully' }] }
case 'delete_relations':
if (!args.relations || !Array.isArray(args.relations)) {
throw new McpError(
ErrorCode.InvalidParams,
`Invalid arguments for ${name}: 'relations' array is required.`
)
}
await manager.deleteRelations(args.relations as Relation[])
return { content: [{ type: 'text', text: 'Relations deleted successfully' }] }
case 'read_graph':
// No arguments expected or needed for read_graph based on original schema
return {
content: [{ type: 'text', text: JSON.stringify(await manager.readGraph(), null, 2) }]
}
case 'search_nodes':
if (typeof args.query !== 'string') {
throw new McpError(ErrorCode.InvalidParams, `Invalid arguments for ${name}: 'query' string is required.`)
}
return {
content: [
{ type: 'text', text: JSON.stringify(await manager.searchNodes(args.query as string), null, 2) }
]
}
case 'open_nodes':
if (!args.names || !Array.isArray(args.names)) {
throw new McpError(ErrorCode.InvalidParams, `Invalid arguments for ${name}: 'names' array is required.`)
}
return {
content: [
{ type: 'text', text: JSON.stringify(await manager.openNodes(args.names as string[]), null, 2) }
]
}
default:
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`)
}
} catch (error) {
// Catch errors from manager methods (like entity not found) or other issues
if (error instanceof McpError) {
throw error // Re-throw McpErrors directly
}
console.error(`Error executing tool ${name}:`, error)
// Throw a generic internal error for unexpected issues
throw new McpError(
ErrorCode.InternalError,
`Error executing tool ${name}: ${error instanceof Error ? error.message : String(error)}`
)
}
})
}
}
export default MemoryServer

View File

@@ -0,0 +1,289 @@
// Sequential Thinking MCP Server
// port https://github.com/modelcontextprotocol/servers/blob/main/src/sequentialthinking/index.ts
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { CallToolRequestSchema, ListToolsRequestSchema, Tool } from '@modelcontextprotocol/sdk/types.js'
// Fixed chalk import for ESM
import chalk from 'chalk'
interface ThoughtData {
thought: string
thoughtNumber: number
totalThoughts: number
isRevision?: boolean
revisesThought?: number
branchFromThought?: number
branchId?: string
needsMoreThoughts?: boolean
nextThoughtNeeded: boolean
}
class SequentialThinkingServer {
private thoughtHistory: ThoughtData[] = []
private branches: Record<string, ThoughtData[]> = {}
private validateThoughtData(input: unknown): ThoughtData {
const data = input as Record<string, unknown>
if (!data.thought || typeof data.thought !== 'string') {
throw new Error('Invalid thought: must be a string')
}
if (!data.thoughtNumber || typeof data.thoughtNumber !== 'number') {
throw new Error('Invalid thoughtNumber: must be a number')
}
if (!data.totalThoughts || typeof data.totalThoughts !== 'number') {
throw new Error('Invalid totalThoughts: must be a number')
}
if (typeof data.nextThoughtNeeded !== 'boolean') {
throw new Error('Invalid nextThoughtNeeded: must be a boolean')
}
return {
thought: data.thought,
thoughtNumber: data.thoughtNumber,
totalThoughts: data.totalThoughts,
nextThoughtNeeded: data.nextThoughtNeeded,
isRevision: data.isRevision as boolean | undefined,
revisesThought: data.revisesThought as number | undefined,
branchFromThought: data.branchFromThought as number | undefined,
branchId: data.branchId as string | undefined,
needsMoreThoughts: data.needsMoreThoughts as boolean | undefined
}
}
private formatThought(thoughtData: ThoughtData): string {
const { thoughtNumber, totalThoughts, thought, isRevision, revisesThought, branchFromThought, branchId } =
thoughtData
let prefix = ''
let context = ''
if (isRevision) {
prefix = chalk.yellow('🔄 Revision')
context = ` (revising thought ${revisesThought})`
} else if (branchFromThought) {
prefix = chalk.green('🌿 Branch')
context = ` (from thought ${branchFromThought}, ID: ${branchId})`
} else {
prefix = chalk.blue('💭 Thought')
context = ''
}
const header = `${prefix} ${thoughtNumber}/${totalThoughts}${context}`
const border = '─'.repeat(Math.max(header.length, thought.length) + 4)
return `
${border}
${header}
${border}
${thought.padEnd(border.length - 2)}
${border}`
}
public processThought(input: unknown): { content: Array<{ type: string; text: string }>; isError?: boolean } {
try {
const validatedInput = this.validateThoughtData(input)
if (validatedInput.thoughtNumber > validatedInput.totalThoughts) {
validatedInput.totalThoughts = validatedInput.thoughtNumber
}
this.thoughtHistory.push(validatedInput)
if (validatedInput.branchFromThought && validatedInput.branchId) {
if (!this.branches[validatedInput.branchId]) {
this.branches[validatedInput.branchId] = []
}
this.branches[validatedInput.branchId].push(validatedInput)
}
const formattedThought = this.formatThought(validatedInput)
console.error(formattedThought)
return {
content: [
{
type: 'text',
text: JSON.stringify(
{
thoughtNumber: validatedInput.thoughtNumber,
totalThoughts: validatedInput.totalThoughts,
nextThoughtNeeded: validatedInput.nextThoughtNeeded,
branches: Object.keys(this.branches),
thoughtHistoryLength: this.thoughtHistory.length
},
null,
2
)
}
]
}
} catch (error) {
return {
content: [
{
type: 'text',
text: JSON.stringify(
{
error: error instanceof Error ? error.message : String(error),
status: 'failed'
},
null,
2
)
}
],
isError: true
}
}
}
}
const SEQUENTIAL_THINKING_TOOL: Tool = {
name: 'sequentialthinking',
description: `A detailed tool for dynamic and reflective problem-solving through thoughts.
This tool helps analyze problems through a flexible thinking process that can adapt and evolve.
Each thought can build on, question, or revise previous insights as understanding deepens.
When to use this tool:
- Breaking down complex problems into steps
- Planning and design with room for revision
- Analysis that might need course correction
- Problems where the full scope might not be clear initially
- Problems that require a multi-step solution
- Tasks that need to maintain context over multiple steps
- Situations where irrelevant information needs to be filtered out
Key features:
- You can adjust total_thoughts up or down as you progress
- You can question or revise previous thoughts
- You can add more thoughts even after reaching what seemed like the end
- You can express uncertainty and explore alternative approaches
- Not every thought needs to build linearly - you can branch or backtrack
- Generates a solution hypothesis
- Verifies the hypothesis based on the Chain of Thought steps
- Repeats the process until satisfied
- Provides a correct answer
Parameters explained:
- thought: Your current thinking step, which can include:
* Regular analytical steps
* Revisions of previous thoughts
* Questions about previous decisions
* Realizations about needing more analysis
* Changes in approach
* Hypothesis generation
* Hypothesis verification
- next_thought_needed: True if you need more thinking, even if at what seemed like the end
- thought_number: Current number in sequence (can go beyond initial total if needed)
- total_thoughts: Current estimate of thoughts needed (can be adjusted up/down)
- is_revision: A boolean indicating if this thought revises previous thinking
- revises_thought: If is_revision is true, which thought number is being reconsidered
- branch_from_thought: If branching, which thought number is the branching point
- branch_id: Identifier for the current branch (if any)
- needs_more_thoughts: If reaching end but realizing more thoughts needed
You should:
1. Start with an initial estimate of needed thoughts, but be ready to adjust
2. Feel free to question or revise previous thoughts
3. Don't hesitate to add more thoughts if needed, even at the "end"
4. Express uncertainty when present
5. Mark thoughts that revise previous thinking or branch into new paths
6. Ignore information that is irrelevant to the current step
7. Generate a solution hypothesis when appropriate
8. Verify the hypothesis based on the Chain of Thought steps
9. Repeat the process until satisfied with the solution
10. Provide a single, ideally correct answer as the final output
11. Only set next_thought_needed to false when truly done and a satisfactory answer is reached`,
inputSchema: {
type: 'object',
properties: {
thought: {
type: 'string',
description: 'Your current thinking step'
},
nextThoughtNeeded: {
type: 'boolean',
description: 'Whether another thought step is needed'
},
thoughtNumber: {
type: 'integer',
description: 'Current thought number',
minimum: 1
},
totalThoughts: {
type: 'integer',
description: 'Estimated total thoughts needed',
minimum: 1
},
isRevision: {
type: 'boolean',
description: 'Whether this revises previous thinking'
},
revisesThought: {
type: 'integer',
description: 'Which thought is being reconsidered',
minimum: 1
},
branchFromThought: {
type: 'integer',
description: 'Branching point thought number',
minimum: 1
},
branchId: {
type: 'string',
description: 'Branch identifier'
},
needsMoreThoughts: {
type: 'boolean',
description: 'If more thoughts are needed'
}
},
required: ['thought', 'nextThoughtNeeded', 'thoughtNumber', 'totalThoughts']
}
}
class ThinkingServer {
public server: Server
private thinkingServer: SequentialThinkingServer
constructor() {
this.thinkingServer = new SequentialThinkingServer()
this.server = new Server(
{
name: 'sequential-thinking-server',
version: '0.2.0'
},
{
capabilities: {
tools: {}
}
}
)
this.initialize()
}
initialize() {
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [SEQUENTIAL_THINKING_TOOL]
}))
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name === 'sequentialthinking') {
return this.thinkingServer.processThought(request.params.arguments)
}
return {
content: [
{
type: 'text',
text: `Unknown tool: ${request.params.name}`
}
],
isError: true
}
})
}
}
export default ThinkingServer

View File

@@ -3,14 +3,60 @@ import { KnowledgeBaseParams } from '@types'
export default abstract class BaseReranker {
protected base: KnowledgeBaseParams
constructor(base: KnowledgeBaseParams) {
if (!base.rerankModel) {
throw new Error('Rerank model is required')
}
this.base = base
}
abstract rerank(query: string, searchResults: ExtractChunkData[]): Promise<ExtractChunkData[]>
/**
* Get Rerank Request Url
*/
protected getRerankUrl() {
let baseURL = this.base?.rerankBaseURL?.endsWith('/')
? this.base.rerankBaseURL.slice(0, -1)
: this.base.rerankBaseURL
// 必须携带/v1否则会404
if (baseURL && !baseURL.endsWith('/v1')) {
baseURL = `${baseURL}/v1`
}
return `${baseURL}/rerank`
}
/**
* Get Rerank Result
* @param searchResults
* @param rerankResults
* @protected
*/
protected getRerankResult(
searchResults: ExtractChunkData[],
rerankResults: Array<{
index: number
relevance_score: number
}>
) {
const resultMap = new Map(rerankResults.map((result) => [result.index, result.relevance_score || 0]))
return searchResults
.map((doc: ExtractChunkData, index: number) => {
const score = resultMap.get(index)
if (score === undefined) return undefined
return {
...doc,
score
}
})
.filter((doc): doc is ExtractChunkData => doc !== undefined)
.sort((a, b) => b.score - a.score)
}
public defaultHeaders() {
return {
Authorization: `Bearer ${this.base.rerankApiKey}`,
@@ -18,7 +64,7 @@ export default abstract class BaseReranker {
}
}
public formatErrorMessage(url: string, error: any, requestBody: any) {
protected formatErrorMessage(url: string, error: any, requestBody: any) {
const errorDetails = {
url: url,
message: error.message,

View File

@@ -1,6 +1,6 @@
import { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
import AxiosProxy from '@main/services/AxiosProxy'
import { KnowledgeBaseParams } from '@types'
import axios from 'axios'
import BaseReranker from './BaseReranker'
@@ -10,16 +10,7 @@ export default class JinaReranker extends BaseReranker {
}
public rerank = async (query: string, searchResults: ExtractChunkData[]): Promise<ExtractChunkData[]> => {
let baseURL = this.base?.rerankBaseURL?.endsWith('/')
? this.base.rerankBaseURL.slice(0, -1)
: this.base.rerankBaseURL
// 必须携带/v1否则会404
if (baseURL && !baseURL.endsWith('/v1')) {
baseURL = `${baseURL}/v1`
}
const url = `${baseURL}/rerank`
const url = this.getRerankUrl()
const requestBody = {
model: this.base.rerankModel,
@@ -29,26 +20,12 @@ export default class JinaReranker extends BaseReranker {
}
try {
const { data } = await axios.post(url, requestBody, { headers: this.defaultHeaders() })
const { data } = await AxiosProxy.axios.post(url, requestBody, { headers: this.defaultHeaders() })
const rerankResults = data.results
console.log(rerankResults)
const resultMap = new Map(rerankResults.map((result: any) => [result.index, result.relevance_score || 0]))
return searchResults
.map((doc: ExtractChunkData, index: number) => {
const score = resultMap.get(index)
if (score === undefined) return undefined
return {
...doc,
score
}
})
.filter((doc): doc is ExtractChunkData => doc !== undefined)
.sort((a, b) => b.score - a.score)
return this.getRerankResult(searchResults, rerankResults)
} catch (error: any) {
const errorDetails = this.formatErrorMessage(url, error, requestBody)
console.error('Jina Reranker API Error:', errorDetails)
throw new Error(`重排序请求失败: ${error.message}\n请求详情: ${errorDetails}`)
}

View File

@@ -1,6 +1,6 @@
import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
import axiosProxy from '@main/services/AxiosProxy'
import { KnowledgeBaseParams } from '@types'
import axios from 'axios'
import BaseReranker from './BaseReranker'
@@ -10,16 +10,7 @@ export default class SiliconFlowReranker extends BaseReranker {
}
public rerank = async (query: string, searchResults: ExtractChunkData[]): Promise<ExtractChunkData[]> => {
let baseURL = this.base?.rerankBaseURL?.endsWith('/')
? this.base.rerankBaseURL.slice(0, -1)
: this.base.rerankBaseURL
// 必须携带/v1否则会404
if (baseURL && !baseURL.endsWith('/v1')) {
baseURL = `${baseURL}/v1`
}
const url = `${baseURL}/rerank`
const url = this.getRerankUrl()
const requestBody = {
model: this.base.rerankModel,
@@ -31,23 +22,10 @@ export default class SiliconFlowReranker extends BaseReranker {
}
try {
const { data } = await axios.post(url, requestBody, { headers: this.defaultHeaders() })
const { data } = await axiosProxy.axios.post(url, requestBody, { headers: this.defaultHeaders() })
const rerankResults = data.results
const resultMap = new Map(rerankResults.map((result: any) => [result.index, result.relevance_score || 0]))
return searchResults
.map((doc: ExtractChunkData, index: number) => {
const score = resultMap.get(index)
if (score === undefined) return undefined
return {
...doc,
score
}
})
.filter((doc): doc is ExtractChunkData => doc !== undefined)
.sort((a, b) => b.score - a.score)
return this.getRerankResult(searchResults, rerankResults)
} catch (error: any) {
const errorDetails = this.formatErrorMessage(url, error, requestBody)

View File

@@ -1,6 +1,6 @@
import { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
import axiosProxy from '@main/services/AxiosProxy'
import { KnowledgeBaseParams } from '@types'
import axios from 'axios'
import BaseReranker from './BaseReranker'
@@ -10,15 +10,7 @@ export default class VoyageReranker extends BaseReranker {
}
public rerank = async (query: string, searchResults: ExtractChunkData[]): Promise<ExtractChunkData[]> => {
let baseURL = this.base?.rerankBaseURL?.endsWith('/')
? this.base.rerankBaseURL.slice(0, -1)
: this.base.rerankBaseURL
if (baseURL && !baseURL.endsWith('/v1')) {
baseURL = `${baseURL}/v1`
}
const url = `${baseURL}/rerank`
const url = this.getRerankUrl()
const requestBody = {
model: this.base.rerankModel,
@@ -30,28 +22,14 @@ export default class VoyageReranker extends BaseReranker {
}
try {
const { data } = await axios.post(url, requestBody, {
const { data } = await axiosProxy.axios.post(url, requestBody, {
headers: {
...this.defaultHeaders()
}
})
const rerankResults = data.data
const resultMap = new Map(rerankResults.map((result: any) => [result.index, result.relevance_score || 0]))
return searchResults
.map((doc: ExtractChunkData, index: number) => {
const score = resultMap.get(index)
if (score === undefined) return undefined
return {
...doc,
score
}
})
.filter((doc): doc is ExtractChunkData => doc !== undefined)
.sort((a, b) => b.score - a.score)
return this.getRerankResult(searchResults, rerankResults)
} catch (error: any) {
const errorDetails = this.formatErrorMessage(url, error, requestBody)

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

@@ -1,9 +1,11 @@
import { IpcChannel } from '@shared/IpcChannel'
import { UpdateInfo } from 'builder-util-runtime'
import { app, BrowserWindow, dialog } from 'electron'
import logger from 'electron-log'
import { AppUpdater as _AppUpdater, autoUpdater } from 'electron-updater'
import icon from '../../../build/icon.png?asset'
import { configManager } from './ConfigManager'
export default class AppUpdater {
autoUpdater: _AppUpdater = autoUpdater
@@ -14,7 +16,8 @@ export default class AppUpdater {
autoUpdater.logger = logger
autoUpdater.forceDevUpdateConfig = !app.isPackaged
autoUpdater.autoDownload = true
autoUpdater.autoDownload = configManager.getAutoUpdate()
autoUpdater.autoInstallOnAppQuit = configManager.getAutoUpdate()
// 检测下载错误
autoUpdater.on('error', (error) => {
@@ -24,27 +27,27 @@ export default class AppUpdater {
stack: error.stack,
time: new Date().toISOString()
})
mainWindow.webContents.send('update-error', error)
mainWindow.webContents.send(IpcChannel.UpdateError, error)
})
autoUpdater.on('update-available', (releaseInfo: UpdateInfo) => {
logger.info('检测到新版本', releaseInfo)
mainWindow.webContents.send('update-available', releaseInfo)
mainWindow.webContents.send(IpcChannel.UpdateAvailable, releaseInfo)
})
// 检测到不需要更新时
autoUpdater.on('update-not-available', () => {
mainWindow.webContents.send('update-not-available')
mainWindow.webContents.send(IpcChannel.UpdateNotAvailable)
})
// 更新下载进度
autoUpdater.on('download-progress', (progress) => {
mainWindow.webContents.send('download-progress', progress)
mainWindow.webContents.send(IpcChannel.DownloadProgress, progress)
})
// 当需要更新的内容下载完成后
autoUpdater.on('update-downloaded', (releaseInfo: UpdateInfo) => {
mainWindow.webContents.send('update-downloaded', releaseInfo)
mainWindow.webContents.send(IpcChannel.UpdateDownloaded, releaseInfo)
this.releaseInfo = releaseInfo
logger.info('下载完成', releaseInfo)
})
@@ -73,7 +76,7 @@ export default class AppUpdater {
app.isQuitting = true
setImmediate(() => autoUpdater.quitAndInstall())
} else {
mainWindow.webContents.send('update-downloaded-cancelled')
mainWindow.webContents.send(IpcChannel.UpdateDownloadedCancelled)
}
})
}

View File

@@ -0,0 +1,27 @@
import { AxiosInstance, default as axios_ } from 'axios'
import { proxyManager } from './ProxyManager'
class AxiosProxy {
private cacheAxios: AxiosInstance | undefined
private proxyURL: string | undefined
get axios(): AxiosInstance {
const currentProxyURL = proxyManager.getProxyUrl()
if (this.proxyURL !== currentProxyURL) {
this.proxyURL = currentProxyURL
const agent = proxyManager.getProxyAgent()
this.cacheAxios = axios_.create({
proxy: false,
...(agent && { httpAgent: agent, httpsAgent: agent })
})
}
if (this.cacheAxios === undefined) {
this.cacheAxios = axios_.create({ proxy: false })
}
return this.cacheAxios
}
}
export default new AxiosProxy()

View File

@@ -1,3 +1,4 @@
import { IpcChannel } from '@shared/IpcChannel'
import { WebDavConfig } from '@types'
import AdmZip from 'adm-zip'
import { exec } from 'child_process'
@@ -21,6 +22,7 @@ class BackupManager {
this.backupToWebdav = this.backupToWebdav.bind(this)
this.restoreFromWebdav = this.restoreFromWebdav.bind(this)
this.listWebdavFiles = this.listWebdavFiles.bind(this)
this.deleteWebdavFile = this.deleteWebdavFile.bind(this)
}
private async setWritableRecursive(dirPath: string): Promise<void> {
@@ -79,7 +81,7 @@ class BackupManager {
const mainWindow = windowService.getMainWindow()
const onProgress = (processData: { stage: string; progress: number; total: number }) => {
mainWindow?.webContents.send('backup-progress', processData)
mainWindow?.webContents.send(IpcChannel.BackupProgress, processData)
Logger.log('[BackupManager] backup progress', processData)
}
@@ -139,7 +141,7 @@ class BackupManager {
const mainWindow = windowService.getMainWindow()
const onProgress = (processData: { stage: string; progress: number; total: number }) => {
mainWindow?.webContents.send('restore-progress', processData)
mainWindow?.webContents.send(IpcChannel.RestoreProgress, processData)
Logger.log('[BackupManager] restore progress', processData)
}
@@ -308,6 +310,16 @@ class BackupManager {
const webdavClient = new WebDav(webdavConfig)
return await webdavClient.createDirectory(path, options)
}
async deleteWebdavFile(_: Electron.IpcMainInvokeEvent, fileName: string, webdavConfig: WebDavConfig) {
try {
const webdavClient = new WebDav(webdavConfig)
return await webdavClient.deleteFile(fileName)
} catch (error: any) {
Logger.error('Failed to delete WebDAV file:', error)
throw new Error(error.message || 'Failed to delete backup file')
}
}
}
export default BackupManager

View File

@@ -1,10 +1,23 @@
import { ZOOM_SHORTCUTS } from '@shared/config/constant'
import { defaultLanguage, ZOOM_SHORTCUTS } from '@shared/config/constant'
import { LanguageVarious, Shortcut, ThemeMode } from '@types'
import { app } from 'electron'
import Store from 'electron-store'
import { locales } from '../utils/locales'
enum ConfigKeys {
Language = 'language',
Theme = 'theme',
LaunchToTray = 'launchToTray',
Tray = 'tray',
TrayOnClose = 'trayOnClose',
ZoomFactor = 'ZoomFactor',
Shortcuts = 'shortcuts',
ClickTrayToShowQuickAssistant = 'clickTrayToShowQuickAssistant',
EnableQuickAssistant = 'enableQuickAssistant',
AutoUpdate = 'autoUpdate'
}
export class ConfigManager {
private store: Store
private subscribers: Map<string, Array<(newValue: any) => void>> = new Map()
@@ -14,54 +27,62 @@ export class ConfigManager {
}
getLanguage(): LanguageVarious {
const locale = Object.keys(locales).includes(app.getLocale()) ? app.getLocale() : 'en-US'
return this.store.get('language', locale) as LanguageVarious
const locale = Object.keys(locales).includes(app.getLocale()) ? app.getLocale() : defaultLanguage
return this.get(ConfigKeys.Language, locale) as LanguageVarious
}
setLanguage(theme: LanguageVarious) {
this.store.set('language', theme)
this.set(ConfigKeys.Language, theme)
}
getTheme(): ThemeMode {
return this.store.get('theme', ThemeMode.light) as ThemeMode
return this.get(ConfigKeys.Theme, ThemeMode.light)
}
setTheme(theme: ThemeMode) {
this.store.set('theme', theme)
this.set(ConfigKeys.Theme, theme)
}
getCustomCss(): string {
return this.store.get('customCss', '') as string
}
setCustomCss(css: string) {
this.store.set('customCss', css)
}
getLaunchToTray(): boolean {
return !!this.store.get('launchToTray', false)
return !!this.get(ConfigKeys.LaunchToTray, false)
}
setLaunchToTray(value: boolean) {
this.store.set('launchToTray', value)
this.set(ConfigKeys.LaunchToTray, value)
}
getTray(): boolean {
return !!this.store.get('tray', true)
return !!this.get(ConfigKeys.Tray, true)
}
setTray(value: boolean) {
this.store.set('tray', value)
this.notifySubscribers('tray', value)
this.set(ConfigKeys.Tray, value)
this.notifySubscribers(ConfigKeys.Tray, value)
}
getTrayOnClose(): boolean {
return !!this.store.get('trayOnClose', true)
return !!this.get(ConfigKeys.TrayOnClose, true)
}
setTrayOnClose(value: boolean) {
this.store.set('trayOnClose', value)
this.set(ConfigKeys.TrayOnClose, value)
}
getZoomFactor(): number {
return this.store.get('zoomFactor', 1) as number
return this.get<number>(ConfigKeys.ZoomFactor, 1)
}
setZoomFactor(factor: number) {
this.store.set('zoomFactor', factor)
this.notifySubscribers('zoomFactor', factor)
this.set(ConfigKeys.ZoomFactor, factor)
this.notifySubscribers(ConfigKeys.ZoomFactor, factor)
}
subscribe<T>(key: string, callback: (newValue: T) => void) {
@@ -89,39 +110,47 @@ export class ConfigManager {
}
getShortcuts() {
return this.store.get('shortcuts', ZOOM_SHORTCUTS) as Shortcut[] | []
return this.get(ConfigKeys.Shortcuts, ZOOM_SHORTCUTS) as Shortcut[] | []
}
setShortcuts(shortcuts: Shortcut[]) {
this.store.set(
'shortcuts',
this.set(
ConfigKeys.Shortcuts,
shortcuts.filter((shortcut) => shortcut.system)
)
this.notifySubscribers('shortcuts', shortcuts)
this.notifySubscribers(ConfigKeys.Shortcuts, shortcuts)
}
getClickTrayToShowQuickAssistant(): boolean {
return this.store.get('clickTrayToShowQuickAssistant', false) as boolean
return this.get<boolean>(ConfigKeys.ClickTrayToShowQuickAssistant, false)
}
setClickTrayToShowQuickAssistant(value: boolean) {
this.store.set('clickTrayToShowQuickAssistant', value)
this.set(ConfigKeys.ClickTrayToShowQuickAssistant, value)
}
getEnableQuickAssistant(): boolean {
return this.store.get('enableQuickAssistant', false) as boolean
return this.get(ConfigKeys.EnableQuickAssistant, false)
}
setEnableQuickAssistant(value: boolean) {
this.store.set('enableQuickAssistant', value)
this.set(ConfigKeys.EnableQuickAssistant, value)
}
set(key: string, value: any) {
getAutoUpdate(): boolean {
return this.get<boolean>(ConfigKeys.AutoUpdate, true)
}
setAutoUpdate(value: boolean) {
this.set(ConfigKeys.AutoUpdate, value)
}
set(key: string, value: unknown) {
this.store.set(key, value)
}
get(key: string) {
return this.store.get(key)
get<T>(key: string, defaultValue?: T) {
return this.store.get(key, defaultValue) as T
}
}

View File

@@ -1,8 +1,10 @@
import axios, { AxiosRequestConfig } from 'axios'
import { AxiosRequestConfig } from 'axios'
import { app, safeStorage } from 'electron'
import fs from 'fs/promises'
import path from 'path'
import aoxisProxy from './AxiosProxy'
// 配置常量,集中管理
const CONFIG = {
GITHUB_CLIENT_ID: 'Iv1.b507a08c87ecfe98',
@@ -93,7 +95,7 @@ class CopilotService {
}
}
const response = await axios.get(CONFIG.API_URLS.GITHUB_USER, config)
const response = await aoxisProxy.axios.get(CONFIG.API_URLS.GITHUB_USER, config)
return {
login: response.data.login,
avatar: response.data.avatar_url
@@ -114,7 +116,7 @@ class CopilotService {
try {
this.updateHeaders(headers)
const response = await axios.post<AuthResponse>(
const response = await aoxisProxy.axios.post<AuthResponse>(
CONFIG.API_URLS.GITHUB_DEVICE_CODE,
{
client_id: CONFIG.GITHUB_CLIENT_ID,
@@ -146,7 +148,7 @@ class CopilotService {
await this.delay(currentDelay)
try {
const response = await axios.post<TokenResponse>(
const response = await aoxisProxy.axios.post<TokenResponse>(
CONFIG.API_URLS.GITHUB_ACCESS_TOKEN,
{
client_id: CONFIG.GITHUB_CLIENT_ID,
@@ -208,7 +210,7 @@ class CopilotService {
}
}
const response = await axios.get<CopilotTokenResponse>(CONFIG.API_URLS.COPILOT_TOKEN, config)
const response = await aoxisProxy.axios.get<CopilotTokenResponse>(CONFIG.API_URLS.COPILOT_TOKEN, config)
return response.data
} catch (error) {

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

@@ -1,5 +1,5 @@
import { getFilesDir, getFileType, getTempDir } from '@main/utils/file'
import { documentExts, imageExts } from '@shared/config/constant'
import { documentExts, imageExts, MB } from '@shared/config/constant'
import { FileType } from '@types'
import * as crypto from 'crypto'
import {
@@ -122,7 +122,7 @@ class FileStorage {
private async compressImage(sourcePath: string, destPath: string): Promise<void> {
try {
const stats = fs.statSync(sourcePath)
const fileSizeInMB = stats.size / (1024 * 1024)
const fileSizeInMB = stats.size / MB
// 如果图片大于1MB才进行压缩
if (fileSizeInMB > 1) {

View File

@@ -1,4 +1,4 @@
import { FileMetadataResponse, FileState, GoogleAIFileManager } from '@google/generative-ai/server'
import { File, FileState, GoogleGenAI, Pager } from '@google/genai'
import { FileType } from '@types'
import fs from 'fs'
@@ -8,11 +8,15 @@ export class GeminiService {
private static readonly FILE_LIST_CACHE_KEY = 'gemini_file_list'
private static readonly CACHE_DURATION = 3000
static async uploadFile(_: Electron.IpcMainInvokeEvent, file: FileType, apiKey: string) {
const fileManager = new GoogleAIFileManager(apiKey)
const uploadResult = await fileManager.uploadFile(file.path, {
mimeType: 'application/pdf',
displayName: file.origin_name
static async uploadFile(_: Electron.IpcMainInvokeEvent, file: FileType, apiKey: string): Promise<File> {
const sdk = new GoogleGenAI({ vertexai: false, apiKey })
const uploadResult = await sdk.files.upload({
file: file.path,
config: {
mimeType: 'application/pdf',
name: file.id,
displayName: file.origin_name
}
})
return uploadResult
}
@@ -24,40 +28,42 @@ export class GeminiService {
}
}
static async retrieveFile(
_: Electron.IpcMainInvokeEvent,
file: FileType,
apiKey: string
): Promise<FileMetadataResponse | undefined> {
const fileManager = new GoogleAIFileManager(apiKey)
static async retrieveFile(_: Electron.IpcMainInvokeEvent, file: FileType, apiKey: string): Promise<File | undefined> {
const sdk = new GoogleGenAI({ vertexai: false, apiKey })
const cachedResponse = CacheService.get<any>(GeminiService.FILE_LIST_CACHE_KEY)
if (cachedResponse) {
return GeminiService.processResponse(cachedResponse, file)
}
const response = await fileManager.listFiles()
const response = await sdk.files.list()
CacheService.set(GeminiService.FILE_LIST_CACHE_KEY, response, GeminiService.CACHE_DURATION)
return GeminiService.processResponse(response, file)
}
private static processResponse(response: any, file: FileType) {
if (response.files) {
return response.files
.filter((file) => file.state === FileState.ACTIVE)
.find((i) => i.displayName === file.origin_name && Number(i.sizeBytes) === file.size)
private static async processResponse(response: Pager<File>, file: FileType) {
for await (const f of response) {
if (f.state === FileState.ACTIVE) {
if (f.displayName === file.origin_name && Number(f.sizeBytes) === file.size) {
return f
}
}
}
return undefined
}
static async listFiles(_: Electron.IpcMainInvokeEvent, apiKey: string) {
const fileManager = new GoogleAIFileManager(apiKey)
return await fileManager.listFiles()
static async listFiles(_: Electron.IpcMainInvokeEvent, apiKey: string): Promise<File[]> {
const sdk = new GoogleGenAI({ vertexai: false, apiKey })
const files: File[] = []
for await (const f of await sdk.files.list()) {
files.push(f)
}
return files
}
static async deleteFile(_: Electron.IpcMainInvokeEvent, apiKey: string, fileId: string) {
const fileManager = new GoogleAIFileManager(apiKey)
await fileManager.deleteFile(fileId)
static async deleteFile(_: Electron.IpcMainInvokeEvent, fileId: string, apiKey: string) {
const sdk = new GoogleGenAI({ vertexai: false, apiKey })
await sdk.files.delete({ name: fileId })
}
}

View File

@@ -26,7 +26,9 @@ import { addFileLoader } from '@main/loader'
import Reranker from '@main/reranker/Reranker'
import { windowService } from '@main/services/WindowService'
import { getAllFiles } from '@main/utils/file'
import { MB } from '@shared/config/constant'
import type { LoaderReturn } from '@shared/config/types'
import { IpcChannel } from '@shared/IpcChannel'
import { FileType, KnowledgeBaseParams, KnowledgeItem } from '@types'
import { app } from 'electron'
import Logger from 'electron-log'
@@ -91,7 +93,7 @@ class KnowledgeService {
private workload = 0
private processingItemCount = 0
private knowledgeItemProcessingQueueMappingPromise: Map<LoaderTaskOfSet, () => void> = new Map()
private static MAXIMUM_WORKLOAD = 1024 * 1024 * 80
private static MAXIMUM_WORKLOAD = 80 * MB
private static MAXIMUM_PROCESSING_ITEM_COUNT = 30
private static ERROR_LOADER_RETURN: LoaderReturn = { entriesAdded: 0, uniqueId: '', uniqueIds: [''], loaderType: '' }
@@ -194,7 +196,7 @@ class KnowledgeService {
const sendDirectoryProcessingPercent = (totalFiles: number, processedFiles: number) => {
const mainWindow = windowService.getMainWindow()
mainWindow?.webContents.send('directory-processing-percent', {
mainWindow?.webContents.send(IpcChannel.DirectoryProcessingPercent, {
itemId: item.id,
percent: (processedFiles / totalFiles) * 100
})
@@ -270,7 +272,7 @@ class KnowledgeService {
return KnowledgeService.ERROR_LOADER_RETURN
})
},
evaluateTaskWorkload: { workload: 1024 * 1024 * 2 }
evaluateTaskWorkload: { workload: 2 * MB }
}
],
loaderDoneReturn: null
@@ -309,7 +311,7 @@ class KnowledgeService {
Logger.error(err)
return KnowledgeService.ERROR_LOADER_RETURN
}),
evaluateTaskWorkload: { workload: 1024 * 1024 * 20 }
evaluateTaskWorkload: { workload: 20 * MB }
}
],
loaderDoneReturn: null

View File

@@ -1,18 +1,69 @@
import crypto from 'node:crypto'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { isLinux, isMac, isWin } from '@main/constant'
import { createInMemoryMCPServer } from '@main/mcpServers/factory'
import { makeSureDirExists } from '@main/utils'
import { getBinaryName, getBinaryPath } from '@main/utils/process'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
import { SSEClientTransport, SSEClientTransportOptions } from '@modelcontextprotocol/sdk/client/sse.js'
import { getDefaultEnvironment, StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory'
import { nanoid } from '@reduxjs/toolkit'
import { MCPServer, MCPTool } from '@types'
import {
GetMCPPromptResponse,
GetResourceResponse,
MCPCallToolResponse,
MCPPrompt,
MCPResource,
MCPServer,
MCPTool
} from '@types'
import { app } from 'electron'
import Logger from 'electron-log'
import { EventEmitter } from 'events'
import { memoize } from 'lodash'
import { CacheService } from './CacheService'
import { CallBackServer } from './mcp/oauth/callback'
import { McpOAuthClientProvider } from './mcp/oauth/provider'
import { StreamableHTTPClientTransport, type StreamableHTTPClientTransportOptions } from './MCPStreamableHttpClient'
// Generic type for caching wrapped functions
type CachedFunction<T extends unknown[], R> = (...args: T) => Promise<R>
/**
* Higher-order function to add caching capability to any async function
* @param fn The original function to be wrapped with caching
* @param getCacheKey Function to generate a cache key from the function arguments
* @param ttl Time to live for the cache entry in milliseconds
* @param logPrefix Prefix for log messages
* @returns The wrapped function with caching capability
*/
function withCache<T extends unknown[], R>(
fn: (...args: T) => Promise<R>,
getCacheKey: (...args: T) => string,
ttl: number,
logPrefix: string
): CachedFunction<T, R> {
return async (...args: T): Promise<R> => {
const cacheKey = getCacheKey(...args)
if (CacheService.has(cacheKey)) {
Logger.info(`${logPrefix} loaded from cache`)
const cachedData = CacheService.get<R>(cacheKey)
if (cachedData) {
return cachedData
}
}
const result = await fn(...args)
CacheService.set(cacheKey, result, ttl)
return result
}
}
class McpService {
private clients: Map<string, Client> = new Map()
@@ -32,10 +83,15 @@ class McpService {
this.initClient = this.initClient.bind(this)
this.listTools = this.listTools.bind(this)
this.callTool = this.callTool.bind(this)
this.listPrompts = this.listPrompts.bind(this)
this.getPrompt = this.getPrompt.bind(this)
this.listResources = this.listResources.bind(this)
this.getResource = this.getResource.bind(this)
this.closeClient = this.closeClient.bind(this)
this.removeServer = this.removeServer.bind(this)
this.restartServer = this.restartServer.bind(this)
this.stopServer = this.stopServer.bind(this)
this.cleanup = this.cleanup.bind(this)
}
async initClient(server: MCPServer): Promise<Client> {
@@ -44,33 +100,77 @@ class McpService {
// Check if we already have a client for this server configuration
const existingClient = this.clients.get(serverKey)
if (existingClient) {
// Check if the existing client is still connected
const pingResult = await existingClient.ping()
Logger.info(`[MCP] Ping result for ${server.name}:`, pingResult)
// If the ping fails, remove the client from the cache
// and create a new one
if (!pingResult) {
try {
// Check if the existing client is still connected
const pingResult = await existingClient.ping()
Logger.info(`[MCP] Ping result for ${server.name}:`, pingResult)
// If the ping fails, remove the client from the cache
// and create a new one
if (!pingResult) {
this.clients.delete(serverKey)
} else {
return existingClient
}
} catch (error) {
Logger.error(`[MCP] Error pinging server ${server.name}:`, error)
this.clients.delete(serverKey)
} else {
return existingClient
}
}
// Create new client instance for each connection
const client = new Client({ name: 'Cherry Studio', version: app.getVersion() }, { capabilities: {} })
const args = [...(server.args || [])]
let transport: StdioClientTransport | SSEClientTransport
// let transport: StdioClientTransport | SSEClientTransport | InMemoryTransport | StreamableHTTPClientTransport
const authProvider = new McpOAuthClientProvider({
serverUrlHash: crypto
.createHash('md5')
.update(server.baseUrl || '')
.digest('hex')
})
try {
const initTransport = async (): Promise<
StdioClientTransport | SSEClientTransport | InMemoryTransport | StreamableHTTPClientTransport
> => {
// Create appropriate transport based on configuration
if (server.baseUrl) {
transport = new SSEClientTransport(new URL(server.baseUrl))
if (server.type === 'inMemory') {
Logger.info(`[MCP] Using in-memory transport for server: ${server.name}`)
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
// start the in-memory server with the given name and environment variables
const inMemoryServer = createInMemoryMCPServer(server.name, args, server.env || {})
try {
await inMemoryServer.connect(serverTransport)
Logger.info(`[MCP] In-memory server started: ${server.name}`)
} catch (error: Error | any) {
Logger.error(`[MCP] Error starting in-memory server: ${error}`)
throw new Error(`Failed to start in-memory server: ${error.message}`)
}
// set the client transport to the client
return clientTransport
} else if (server.baseUrl) {
if (server.type === 'streamableHttp') {
const options: StreamableHTTPClientTransportOptions = {
requestInit: {
headers: server.headers || {}
},
authProvider
}
return new StreamableHTTPClientTransport(new URL(server.baseUrl!), options)
} else if (server.type === 'sse') {
const options: SSEClientTransportOptions = {
requestInit: {
headers: server.headers || {}
},
authProvider
}
return new SSEClientTransport(new URL(server.baseUrl!), options)
} else {
throw new Error('Invalid server type')
}
} else if (server.command) {
let cmd = server.command
if (server.command === 'npx' || server.command === 'bun' || server.command === 'bunx') {
if (server.command === 'npx') {
cmd = await getBinaryPath('bun')
Logger.info(`[MCP] Using command: ${cmd}`)
@@ -90,10 +190,10 @@ class McpService {
}
// if the server name is mcp-auto-install, use the mcp-registry.json file in the bin directory
if (server.name === 'mcp-auto-install') {
if (server.name.includes('mcp-auto-install')) {
const binPath = await getBinaryPath()
makeSureDirExists(binPath)
server.env.MCP_REGISTRY_PATH = path.join(binPath, 'mcp-registry.json')
server.env.MCP_REGISTRY_PATH = path.join(binPath, '..', 'config', 'mcp-registry.json')
}
}
} else if (server.command === 'uvx' || server.command === 'uv') {
@@ -110,20 +210,82 @@ class McpService {
Logger.info(`[MCP] Starting server with command: ${cmd} ${args ? args.join(' ') : ''}`)
// Logger.info(`[MCP] Environment variables for server:`, server.env)
transport = new StdioClientTransport({
const stdioTransport = new StdioClientTransport({
command: cmd,
args,
env: {
...getDefaultEnvironment(),
PATH: this.getEnhancedPath(process.env.PATH || ''),
PATH: await this.getEnhancedPath(process.env.PATH || ''),
...server.env
}
},
stderr: 'pipe'
})
stdioTransport.stderr?.on('data', (data) =>
Logger.info(`[MCP] Stdio stderr for server: ${server.name} `, data.toString())
)
return stdioTransport
} else {
throw new Error('Either baseUrl or command must be provided')
}
}
await client.connect(transport)
const handleAuth = async (client: Client, transport: SSEClientTransport | StreamableHTTPClientTransport) => {
Logger.info(`[MCP] Starting OAuth flow for server: ${server.name}`)
// Create an event emitter for the OAuth callback
const events = new EventEmitter()
// Create a callback server
const callbackServer = new CallBackServer({
port: authProvider.config.callbackPort,
path: authProvider.config.callbackPath || '/oauth/callback',
events
})
// Set a timeout to close the callback server
const timeoutId = setTimeout(() => {
Logger.warn(`[MCP] OAuth flow timed out for server: ${server.name}`)
callbackServer.close()
}, 300000) // 5 minutes timeout
try {
// Wait for the authorization code
const authCode = await callbackServer.waitForAuthCode()
Logger.info(`[MCP] Received auth code: ${authCode}`)
// Complete the OAuth flow
await transport.finishAuth(authCode)
Logger.info(`[MCP] OAuth flow completed for server: ${server.name}`)
const newTransport = await initTransport()
// Try to connect again
await client.connect(newTransport)
Logger.info(`[MCP] Successfully authenticated with server: ${server.name}`)
} catch (oauthError) {
Logger.error(`[MCP] OAuth authentication failed for server ${server.name}:`, oauthError)
throw new Error(
`OAuth authentication failed: ${oauthError instanceof Error ? oauthError.message : String(oauthError)}`
)
} finally {
// Clear the timeout and close the callback server
clearTimeout(timeoutId)
callbackServer.close()
}
}
try {
const transport = await initTransport()
try {
await client.connect(transport)
} catch (error: Error | any) {
if (error instanceof Error && (error.name === 'UnauthorizedError' || error.message.includes('Unauthorized'))) {
Logger.info(`[MCP] Authentication required for server: ${server.name}`)
await handleAuth(client, transport as SSEClientTransport | StreamableHTTPClientTransport)
} else {
throw error
}
}
// Store the new client in the cache
this.clients.set(serverKey, client)
@@ -132,7 +294,7 @@ class McpService {
return client
} catch (error: any) {
Logger.error(`[MCP] Error activating server ${server.name}:`, error)
throw error
throw new Error(`[MCP] Error activating server ${server.name}: ${error.message}`)
}
}
@@ -171,31 +333,50 @@ class McpService {
await this.initClient(server)
}
async listTools(_: Electron.IpcMainInvokeEvent, server: MCPServer) {
const client = await this.initClient(server)
const serverKey = this.getServerKey(server)
const cacheKey = `mcp:list_tool:${serverKey}`
if (CacheService.has(cacheKey)) {
Logger.info(`[MCP] Tools from ${server.name} loaded from cache`)
const cachedTools = CacheService.get<MCPTool[]>(cacheKey)
if (cachedTools && cachedTools.length > 0) {
return cachedTools
async cleanup() {
for (const [key] of this.clients) {
try {
await this.closeClient(key)
} catch (error) {
Logger.error(`[MCP] Failed to close client: ${error}`)
}
}
}
private async listToolsImpl(server: MCPServer): Promise<MCPTool[]> {
Logger.info(`[MCP] Listing tools for server: ${server.name}`)
const { tools } = await client.listTools()
const serverTools: MCPTool[] = []
tools.map((tool: any) => {
const serverTool: MCPTool = {
...tool,
id: `f${nanoid()}`,
serverId: server.id,
serverName: server.name
}
serverTools.push(serverTool)
})
CacheService.set(cacheKey, serverTools, 5 * 60 * 1000)
return serverTools
const client = await this.initClient(server)
try {
const { tools } = await client.listTools()
const serverTools: MCPTool[] = []
tools.map((tool: any) => {
const serverTool: MCPTool = {
...tool,
id: `f${nanoid()}`,
serverId: server.id,
serverName: server.name
}
serverTools.push(serverTool)
})
return serverTools
} catch (error) {
Logger.error(`[MCP] Failed to list tools for server: ${server.name}`, error)
return []
}
}
async listTools(_: Electron.IpcMainInvokeEvent, server: MCPServer) {
const cachedListTools = withCache<[MCPServer], MCPTool[]>(
this.listToolsImpl.bind(this),
(server) => {
const serverKey = this.getServerKey(server)
return `mcp:list_tool:${serverKey}`
},
5 * 60 * 1000, // 5 minutes TTL
`[MCP] Tools from ${server.name}`
)
return cachedListTools(server)
}
/**
@@ -204,12 +385,12 @@ class McpService {
public async callTool(
_: Electron.IpcMainInvokeEvent,
{ server, name, args }: { server: MCPServer; name: string; args: any }
): Promise<any> {
): Promise<MCPCallToolResponse> {
try {
Logger.info('[MCP] Calling:', server.name, name, args)
const client = await this.initClient(server)
const result = await client.callTool({ name, arguments: args })
return result
return result as MCPCallToolResponse
} catch (error) {
Logger.error(`[MCP] Error calling tool ${name} on ${server.name}:`, error)
throw error
@@ -225,13 +406,243 @@ class McpService {
return { dir, uvPath, bunPath }
}
/**
* List prompts available on an MCP server
*/
private async listPromptsImpl(server: MCPServer): Promise<MCPPrompt[]> {
Logger.info(`[MCP] Listing prompts for server: ${server.name}`)
const client = await this.initClient(server)
try {
const { prompts } = await client.listPrompts()
const serverPrompts = prompts.map((prompt: any) => ({
...prompt,
id: `p${nanoid()}`,
serverId: server.id,
serverName: server.name
}))
return serverPrompts
} catch (error) {
Logger.error(`[MCP] Failed to list prompts for server: ${server.name}`, error)
return []
}
}
/**
* List prompts available on an MCP server with caching
*/
public async listPrompts(_: Electron.IpcMainInvokeEvent, server: MCPServer): Promise<MCPPrompt[]> {
const cachedListPrompts = withCache<[MCPServer], MCPPrompt[]>(
this.listPromptsImpl.bind(this),
(server) => {
const serverKey = this.getServerKey(server)
return `mcp:list_prompts:${serverKey}`
},
60 * 60 * 1000, // 60 minutes TTL
`[MCP] Prompts from ${server.name}`
)
return cachedListPrompts(server)
}
/**
* Get a specific prompt from an MCP server (implementation)
*/
private async getPromptImpl(
server: MCPServer,
name: string,
args?: Record<string, any>
): Promise<GetMCPPromptResponse> {
Logger.info(`[MCP] Getting prompt ${name} from server: ${server.name}`)
const client = await this.initClient(server)
return await client.getPrompt({ name, arguments: args })
}
/**
* Get a specific prompt from an MCP server with caching
*/
public async getPrompt(
_: Electron.IpcMainInvokeEvent,
{ server, name, args }: { server: MCPServer; name: string; args?: Record<string, any> }
): Promise<GetMCPPromptResponse> {
const cachedGetPrompt = withCache<[MCPServer, string, Record<string, any> | undefined], GetMCPPromptResponse>(
this.getPromptImpl.bind(this),
(server, name, args) => {
const serverKey = this.getServerKey(server)
const argsKey = args ? JSON.stringify(args) : 'no-args'
return `mcp:get_prompt:${serverKey}:${name}:${argsKey}`
},
30 * 60 * 1000, // 30 minutes TTL
`[MCP] Prompt ${name} from ${server.name}`
)
return await cachedGetPrompt(server, name, args)
}
/**
* List resources available on an MCP server (implementation)
*/
private async listResourcesImpl(server: MCPServer): Promise<MCPResource[]> {
Logger.info(`[MCP] Listing resources for server: ${server.name}`)
const client = await this.initClient(server)
try {
const result = await client.listResources()
const resources = result.resources || []
const serverResources = (Array.isArray(resources) ? resources : []).map((resource: any) => ({
...resource,
serverId: server.id,
serverName: server.name
}))
return serverResources
} catch (error) {
Logger.error(`[MCP] Failed to list resources for server: ${server.name}`, error)
return []
}
}
/**
* List resources available on an MCP server with caching
*/
public async listResources(_: Electron.IpcMainInvokeEvent, server: MCPServer): Promise<MCPResource[]> {
const cachedListResources = withCache<[MCPServer], MCPResource[]>(
this.listResourcesImpl.bind(this),
(server) => {
const serverKey = this.getServerKey(server)
return `mcp:list_resources:${serverKey}`
},
60 * 60 * 1000, // 60 minutes TTL
`[MCP] Resources from ${server.name}`
)
return cachedListResources(server)
}
/**
* Get a specific resource from an MCP server (implementation)
*/
private async getResourceImpl(server: MCPServer, uri: string): Promise<GetResourceResponse> {
Logger.info(`[MCP] Getting resource ${uri} from server: ${server.name}`)
const client = await this.initClient(server)
try {
const result = await client.readResource({ uri: uri })
const contents: MCPResource[] = []
if (result.contents && result.contents.length > 0) {
result.contents.forEach((content: any) => {
contents.push({
...content,
serverId: server.id,
serverName: server.name
})
})
}
return {
contents: contents
}
} catch (error: Error | any) {
Logger.error(`[MCP] Failed to get resource ${uri} from server: ${server.name}`, error)
throw new Error(`Failed to get resource ${uri} from server: ${server.name}: ${error.message}`)
}
}
/**
* Get a specific resource from an MCP server with caching
*/
public async getResource(
_: Electron.IpcMainInvokeEvent,
{ server, uri }: { server: MCPServer; uri: string }
): Promise<GetResourceResponse> {
const cachedGetResource = withCache<[MCPServer, string], GetResourceResponse>(
this.getResourceImpl.bind(this),
(server, uri) => {
const serverKey = this.getServerKey(server)
return `mcp:get_resource:${serverKey}:${uri}`
},
30 * 60 * 1000, // 30 minutes TTL
`[MCP] Resource ${uri} from ${server.name}`
)
return await cachedGetResource(server, uri)
}
private getSystemPath = memoize(async (): Promise<string> => {
return new Promise((resolve, reject) => {
let command: string
let shell: string
if (process.platform === 'win32') {
shell = 'powershell.exe'
command = '$env:PATH'
} else {
// 尝试获取当前用户的默认 shell
let userShell = process.env.SHELL
if (!userShell) {
if (fs.existsSync('/bin/zsh')) {
userShell = '/bin/zsh'
} else if (fs.existsSync('/bin/bash')) {
userShell = '/bin/bash'
} else if (fs.existsSync('/bin/fish')) {
userShell = '/bin/fish'
} else {
userShell = '/bin/sh'
}
}
shell = userShell
// 根据不同的 shell 构建不同的命令
if (userShell.includes('zsh')) {
command =
'source /etc/zshenv 2>/dev/null || true; source ~/.zshenv 2>/dev/null || true; source /etc/zprofile 2>/dev/null || true; source ~/.zprofile 2>/dev/null || true; source /etc/zshrc 2>/dev/null || true; source ~/.zshrc 2>/dev/null || true; source /etc/zlogin 2>/dev/null || true; source ~/.zlogin 2>/dev/null || true; echo $PATH'
} else if (userShell.includes('bash')) {
command =
'source /etc/profile 2>/dev/null || true; source ~/.bash_profile 2>/dev/null || true; source ~/.bash_login 2>/dev/null || true; source ~/.profile 2>/dev/null || true; source ~/.bashrc 2>/dev/null || true; echo $PATH'
} else if (userShell.includes('fish')) {
command =
'source /etc/fish/config.fish 2>/dev/null || true; source ~/.config/fish/config.fish 2>/dev/null || true; source ~/.config/fish/config.local.fish 2>/dev/null || true; echo $PATH'
} else {
// 默认使用 zsh
shell = '/bin/zsh'
command =
'source /etc/zshenv 2>/dev/null || true; source ~/.zshenv 2>/dev/null || true; source /etc/zprofile 2>/dev/null || true; source ~/.zprofile 2>/dev/null || true; source /etc/zshrc 2>/dev/null || true; source ~/.zshrc 2>/dev/null || true; source /etc/zlogin 2>/dev/null || true; source ~/.zlogin 2>/dev/null || true; echo $PATH'
}
}
console.log(`Using shell: ${shell} with command: ${command}`)
const child = require('child_process').spawn(shell, ['-c', command], {
env: { ...process.env },
cwd: app.getPath('home')
})
let path = ''
child.stdout.on('data', (data: Buffer) => {
path += data.toString()
})
child.stderr.on('data', (data: Buffer) => {
console.error('Error getting PATH:', data.toString())
})
child.on('close', (code: number) => {
if (code === 0) {
const trimmedPath = path.trim()
resolve(trimmedPath)
} else {
reject(new Error(`Failed to get system PATH, exit code: ${code}`))
}
})
})
})
/**
* Get enhanced PATH including common tool locations
*/
private getEnhancedPath(originalPath: string): string {
private async getEnhancedPath(originalPath: string): Promise<string> {
let systemPath = ''
try {
systemPath = await this.getSystemPath()
} catch (error) {
Logger.error('[MCP] Failed to get system PATH:', error)
}
// 将原始 PATH 按分隔符分割成数组
const pathSeparator = process.platform === 'win32' ? ';' : ':'
const existingPaths = new Set(originalPath.split(pathSeparator).filter(Boolean))
const existingPaths = new Set(
[...systemPath.split(pathSeparator), ...originalPath.split(pathSeparator)].filter(Boolean)
)
const homeDir = process.env.HOME || process.env.USERPROFILE || ''
// 定义要添加的新路径
@@ -290,4 +701,5 @@ class McpService {
}
}
export default new McpService()
const mcpService = new McpService()
export default mcpService

View File

@@ -0,0 +1,365 @@
import { auth, AuthResult, OAuthClientProvider, UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js'
import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
import { JSONRPCMessage, JSONRPCMessageSchema } from '@modelcontextprotocol/sdk/types.js'
export class StreamableHTTPError extends Error {
constructor(
public readonly code: number | undefined,
message: string | undefined,
public readonly event: ErrorEvent
) {
super(`Streamable HTTP error: ${message}`)
}
}
/**
* Configuration options for the `StreamableHTTPClientTransport`.
*/
export type StreamableHTTPClientTransportOptions = {
/**
* An OAuth client provider to use for authentication.
*
* When an `authProvider` is specified and the connection is started:
* 1. The connection is attempted with any existing access token from the `authProvider`.
* 2. If the access token has expired, the `authProvider` is used to refresh the token.
* 3. If token refresh fails or no access token exists, and auth is required, `OAuthClientProvider.redirectToAuthorization` is called, and an `UnauthorizedError` will be thrown from `connect`/`start`.
*
* After the user has finished authorizing via their user agent, and is redirected back to the MCP client application, call `StreamableHTTPClientTransport.finishAuth` with the authorization code before retrying the connection.
*
* If an `authProvider` is not provided, and auth is required, an `UnauthorizedError` will be thrown.
*
* `UnauthorizedError` might also be thrown when sending any message over the transport, indicating that the session has expired, and needs to be re-authed and reconnected.
*/
authProvider?: OAuthClientProvider
/**
* Customizes HTTP requests to the server.
*/
requestInit?: RequestInit
}
/**
* Client transport for Streamable HTTP: this implements the MCP Streamable HTTP transport specification.
* It will connect to a server using HTTP POST for sending messages and HTTP GET with Server-Sent Events
* for receiving messages.
*/
export class StreamableHTTPClientTransport implements Transport {
private _activeStreams: Map<string, ReadableStreamDefaultReader<Uint8Array>> = new Map()
private _abortController?: AbortController
private _url: URL
private _requestInit?: RequestInit
private _authProvider?: OAuthClientProvider
private _sessionId?: string
private _lastEventId?: string
onclose?: () => void
onerror?: (error: Error) => void
onmessage?: (message: JSONRPCMessage) => void
constructor(url: URL, opts?: StreamableHTTPClientTransportOptions) {
this._url = url
this._requestInit = opts?.requestInit
this._authProvider = opts?.authProvider
}
private async _authThenStart(): Promise<void> {
if (!this._authProvider) {
throw new UnauthorizedError('No auth provider')
}
let result: AuthResult
try {
result = await auth(this._authProvider, { serverUrl: this._url })
} catch (error) {
this.onerror?.(error as Error)
throw error
}
if (result !== 'AUTHORIZED') {
throw new UnauthorizedError()
}
return await this._startOrAuth()
}
private async _commonHeaders(): Promise<HeadersInit> {
const headers: HeadersInit = {}
if (this._authProvider) {
const tokens = await this._authProvider.tokens()
if (tokens) {
headers['Authorization'] = `Bearer ${tokens.access_token}`
}
}
if (this._sessionId) {
headers['mcp-session-id'] = this._sessionId
}
return headers
}
private async _startOrAuth(): Promise<void> {
try {
// Try to open an initial SSE stream with GET to listen for server messages
// This is optional according to the spec - server may not support it
const commonHeaders = await this._commonHeaders()
const headers = new Headers(commonHeaders)
headers.set('Accept', 'text/event-stream')
// Include Last-Event-ID header for resumable streams
if (this._lastEventId) {
headers.set('last-event-id', this._lastEventId)
}
const response = await fetch(this._url, {
method: 'GET',
headers,
signal: this._abortController?.signal
})
if (response.status === 405) {
// Server doesn't support GET for SSE, which is allowed by the spec
// We'll rely on SSE responses to POST requests for communication
return
}
if (!response.ok) {
if (response.status === 401 && this._authProvider) {
// Need to authenticate
return await this._authThenStart()
}
const error = new Error(`Failed to open SSE stream: ${response.status} ${response.statusText}`)
this.onerror?.(error)
throw error
}
// Successful connection, handle the SSE stream as a standalone listener
const streamId = `initial-${Date.now()}`
this._handleSseStream(response.body, streamId)
} catch (error) {
this.onerror?.(error as Error)
throw error
}
}
async start() {
if (this._activeStreams.size > 0) {
throw new Error(
'StreamableHTTPClientTransport already started! If using Client class, note that connect() calls start() automatically.'
)
}
this._abortController = new AbortController()
return await this._startOrAuth()
}
/**
* Call this method after the user has finished authorizing via their user agent and is redirected back to the MCP client application. This will exchange the authorization code for an access token, enabling the next connection attempt to successfully auth.
*/
async finishAuth(authorizationCode: string): Promise<void> {
if (!this._authProvider) {
throw new UnauthorizedError('No auth provider')
}
const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode })
if (result !== 'AUTHORIZED') {
throw new UnauthorizedError('Failed to authorize')
}
}
async close(): Promise<void> {
// Close all active streams
for (const reader of this._activeStreams.values()) {
try {
reader.cancel()
} catch (error) {
this.onerror?.(error as Error)
}
}
this._activeStreams.clear()
// Abort any pending requests
this._abortController?.abort()
// If we have a session ID, send a DELETE request to explicitly terminate the session
if (this._sessionId) {
try {
const commonHeaders = await this._commonHeaders()
const response = await fetch(this._url, {
method: 'DELETE',
headers: commonHeaders,
signal: this._abortController?.signal
})
if (!response.ok) {
// Server might respond with 405 if it doesn't support explicit session termination
// We don't throw an error in that case
if (response.status !== 405) {
const text = await response.text().catch(() => null)
throw new Error(`Error terminating session (HTTP ${response.status}): ${text}`)
}
}
} catch (error) {
// We still want to invoke onclose even if the session termination fails
this.onerror?.(error as Error)
}
}
this.onclose?.()
}
async send(message: JSONRPCMessage | JSONRPCMessage[]): Promise<void> {
try {
const commonHeaders = await this._commonHeaders()
const headers = new Headers({ ...commonHeaders, ...this._requestInit?.headers })
headers.set('content-type', 'application/json')
headers.set('accept', 'application/json, text/event-stream')
const init = {
...this._requestInit,
method: 'POST',
headers,
body: JSON.stringify(message),
signal: this._abortController?.signal
}
const response = await fetch(this._url, init)
// Handle session ID received during initialization
const sessionId = response.headers.get('mcp-session-id')
if (sessionId) {
this._sessionId = sessionId
}
if (!response.ok) {
if (response.status === 401 && this._authProvider) {
const result = await auth(this._authProvider, { serverUrl: this._url })
if (result !== 'AUTHORIZED') {
throw new UnauthorizedError()
}
// Purposely _not_ awaited, so we don't call onerror twice
return this.send(message)
}
const text = await response.text().catch(() => null)
throw new Error(`Error POSTing to endpoint (HTTP ${response.status}): ${text}`)
}
// If the response is 202 Accepted, there's no body to process
if (response.status === 202) {
return
}
// Get original message(s) for detecting request IDs
const messages = Array.isArray(message) ? message : [message]
// Extract IDs from request messages for tracking responses
const requestIds = messages
.filter((msg) => 'method' in msg && 'id' in msg)
.map((msg) => ('id' in msg ? msg.id : undefined))
.filter((id) => id !== undefined)
// If we have request IDs and an SSE response, create a unique stream ID
const hasRequests = requestIds.length > 0
// Check the response type
const contentType = response.headers.get('content-type')
if (hasRequests) {
if (contentType?.includes('text/event-stream')) {
// For streaming responses, create a unique stream ID based on request IDs
const streamId = `req-${requestIds.join('-')}-${Date.now()}`
this._handleSseStream(response.body, streamId)
} else if (contentType?.includes('application/json')) {
// For non-streaming servers, we might get direct JSON responses
const data = await response.json()
const responseMessages = Array.isArray(data)
? data.map((msg) => JSONRPCMessageSchema.parse(msg))
: [JSONRPCMessageSchema.parse(data)]
for (const msg of responseMessages) {
this.onmessage?.(msg)
}
}
}
} catch (error) {
this.onerror?.(error as Error)
throw error
}
}
private _handleSseStream(stream: ReadableStream<Uint8Array> | null, streamId: string): void {
if (!stream) {
return
}
// Set up stream handling for server-sent events
const reader = stream.getReader()
this._activeStreams.set(streamId, reader)
const decoder = new TextDecoder()
let buffer = ''
const processStream = async () => {
try {
while (true) {
const { done, value } = await reader.read()
if (done) {
// Stream closed by server
this._activeStreams.delete(streamId)
break
}
buffer += decoder.decode(value, { stream: true })
// Process SSE messages in the buffer
const events = buffer.split('\n\n')
buffer = events.pop() || ''
for (const event of events) {
const lines = event.split('\n')
let id: string | undefined
let eventType: string | undefined
let data: string | undefined
// Parse SSE message according to the format
for (const line of lines) {
if (line.startsWith('id:')) {
id = line.slice(3).trim()
} else if (line.startsWith('event:')) {
eventType = line.slice(6).trim()
} else if (line.startsWith('data:')) {
data = line.slice(5).trim()
}
}
// Update last event ID if provided by server
// As per spec: the ID MUST be globally unique across all streams within that session
if (id) {
this._lastEventId = id
}
// Handle message event
if (data) {
// Default event type is 'message' per SSE spec if not specified
if (!eventType || eventType === 'message') {
try {
const message = JSONRPCMessageSchema.parse(JSON.parse(data))
this.onmessage?.(message)
} catch (error) {
this.onerror?.(error as Error)
}
}
}
}
}
} catch (error) {
this._activeStreams.delete(streamId)
this.onerror?.(error as Error)
}
}
processStream()
}
}

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

@@ -5,6 +5,8 @@ import { XMLParser } from 'fast-xml-parser'
import { isNil, partial } from 'lodash'
import { type FileStat } from 'webdav'
import { createOAuthUrl, decryptSecret } from '../integration/nutstore/sso/lib/index.mjs'
interface OAuthResponse {
username: string
userid: string
@@ -30,18 +32,18 @@ interface WebDAVResponse {
}
export async function getNutstoreSSOUrl() {
const { createOAuthUrl } = await import('../integration/nutstore/sso/lib')
const url = createOAuthUrl({
const url = await createOAuthUrl({
app: 'cherrystudio'
})
return url
}
export async function decryptToken(token: string) {
const { decrypt } = await import('../integration/nutstore/sso/lib')
try {
const decrypted = decrypt('cherrystudio', token)
const decrypted = await decryptSecret({
app: 'cherrystudio',
s: token
})
return JSON.parse(decrypted) as OAuthResponse
} catch (error) {
console.error('解密失败:', error)

View File

@@ -1,3 +1,4 @@
import { IpcChannel } from '@shared/IpcChannel'
import { ipcMain } from 'electron'
import { EventEmitter } from 'events'
@@ -10,6 +11,8 @@ export class ReduxService extends EventEmitter {
private stateCache: any = {}
private isReady = false
private readonly STATUS_CHANGE_EVENT = 'statusChange'
constructor() {
super()
this.setupIpcHandlers()
@@ -17,15 +20,15 @@ export class ReduxService extends EventEmitter {
private setupIpcHandlers() {
// 监听 store 就绪事件
ipcMain.handle('redux-store-ready', () => {
ipcMain.handle(IpcChannel.ReduxStoreReady, () => {
this.isReady = true
this.emit('ready')
})
// 监听 store 状态变化
ipcMain.on('redux-state-change', (_, newState) => {
ipcMain.on(IpcChannel.ReduxStateChange, (_, newState) => {
this.stateCache = newState
this.emit('stateChange', newState)
this.emit(this.STATUS_CHANGE_EVENT, newState)
})
}
@@ -122,19 +125,23 @@ export class ReduxService extends EventEmitter {
await this.waitForStoreReady(mainWindow.webContents)
// 在渲染进程中设置监听
await mainWindow.webContents.executeJavaScript(`
await mainWindow.webContents.executeJavaScript(
`
if (!window._storeSubscriptions) {
window._storeSubscriptions = new Set();
// 设置全局状态变化监听
const unsubscribe = window.store.subscribe(() => {
const state = window.store.getState();
window.electron.ipcRenderer.send('redux-state-change', state);
window.electron.ipcRenderer.send('` +
IpcChannel.ReduxStateChange +
`', state);
});
window._storeSubscriptions.add(unsubscribe);
}
`)
`
)
// 在主进程中处理回调
const handler = async () => {
@@ -146,9 +153,9 @@ export class ReduxService extends EventEmitter {
}
}
this.on('stateChange', handler)
this.on(this.STATUS_CHANGE_EVENT, handler)
return () => {
this.off('stateChange', handler)
this.off(this.STATUS_CHANGE_EVENT, handler)
}
}
@@ -180,41 +187,41 @@ export class ReduxService extends EventEmitter {
export const reduxService = new ReduxService()
/** example
async function example() {
try {
// 读取状态
const settings = await reduxService.select('state.settings')
console.log('settings', settings)
async function example() {
try {
// 读取状态
const settings = await reduxService.select('state.settings')
console.log('settings', settings)
// 派发 action
await reduxService.dispatch({
type: 'settings/updateApiKey',
payload: 'new-api-key'
})
// 派发 action
await reduxService.dispatch({
type: 'settings/updateApiKey',
payload: 'new-api-key'
})
// 订阅状态变化
const unsubscribe = await reduxService.subscribe('state.settings.apiKey', (newValue) => {
console.log('API key changed:', newValue)
})
// 订阅状态变化
const unsubscribe = await reduxService.subscribe('state.settings.apiKey', (newValue) => {
console.log('API key changed:', newValue)
})
// 批量执行 actions
await reduxService.batch([
{ type: 'action1', payload: 'data1' },
{ type: 'action2', payload: 'data2' }
])
// 批量执行 actions
await reduxService.batch([
{ type: 'action1', payload: 'data1' },
{ type: 'action2', payload: 'data2' }
])
// 同步方法虽然可能不是最新的数据,但响应更快
const apiKey = reduxService.selectSync('state.settings.apiKey')
console.log('apiKey', apiKey)
// 同步方法虽然可能不是最新的数据,但响应更快
const apiKey = reduxService.selectSync('state.settings.apiKey')
console.log('apiKey', apiKey)
// 处理保证是最新的数据
const apiKey1 = await reduxService.select('state.settings.apiKey')
console.log('apiKey1', apiKey1)
// 处理保证是最新的数据
const apiKey1 = await reduxService.select('state.settings.apiKey')
console.log('apiKey1', apiKey1)
// 取消订阅
unsubscribe()
} catch (error) {
console.error('Error:', error)
}
}
*/
// 取消订阅
unsubscribe()
} catch (error) {
console.error('Error:', error)
}
}
*/

View File

@@ -0,0 +1,82 @@
import { is } from '@electron-toolkit/utils'
import { BrowserWindow } from 'electron'
export class SearchService {
private static instance: SearchService | null = null
private searchWindows: Record<string, BrowserWindow> = {}
public static getInstance(): SearchService {
if (!SearchService.instance) {
SearchService.instance = new SearchService()
}
return SearchService.instance
}
constructor() {
// Initialize the service
}
private async createNewSearchWindow(uid: string): Promise<BrowserWindow> {
const newWindow = new BrowserWindow({
width: 800,
height: 600,
show: false,
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
devTools: is.dev
}
})
newWindow.webContents.session.webRequest.onBeforeSendHeaders({ urls: ['*://*/*'] }, (details, callback) => {
const headers = {
...details.requestHeaders,
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
}
callback({ requestHeaders: headers })
})
this.searchWindows[uid] = newWindow
newWindow.on('closed', () => {
delete this.searchWindows[uid]
})
return newWindow
}
public async openSearchWindow(uid: string): Promise<void> {
await this.createNewSearchWindow(uid)
}
public async closeSearchWindow(uid: string): Promise<void> {
const window = this.searchWindows[uid]
if (window) {
window.close()
delete this.searchWindows[uid]
}
}
public async openUrlInSearchWindow(uid: string, url: string): Promise<any> {
let window = this.searchWindows[uid]
if (window) {
await window.loadURL(url)
} else {
window = await this.createNewSearchWindow(uid)
await window.loadURL(url)
}
// Get the page content after loading the URL
// Wait for the page to fully load before getting the content
await new Promise<void>((resolve) => {
const loadTimeout = setTimeout(() => resolve(), 10000) // 10 second timeout
window.webContents.once('did-finish-load', () => {
clearTimeout(loadTimeout)
// Small delay to ensure JavaScript has executed
setTimeout(resolve, 500)
})
})
// Get the page content after ensuring it's fully loaded
const content = await window.webContents.executeJavaScript('document.documentElement.outerHTML')
return content
}
}
export const searchService = SearchService.getInstance()

View File

@@ -26,6 +26,7 @@ export default class WebDav {
this.putFileContents = this.putFileContents.bind(this)
this.getFileContents = this.getFileContents.bind(this)
this.createDirectory = this.createDirectory.bind(this)
this.deleteFile = this.deleteFile.bind(this)
}
public putFileContents = async (
@@ -98,4 +99,19 @@ export default class WebDav {
throw error
}
}
public deleteFile = async (filename: string) => {
if (!this.instance) {
throw new Error('WebDAV client not initialized')
}
const remoteFilePath = `${this.webdavPath}/${filename}`
try {
return await this.instance.deleteFile(remoteFilePath)
} catch (error) {
Logger.error('[WebDAV] Error deleting file on WebDAV:', error)
throw error
}
}
}

View File

@@ -1,6 +1,7 @@
import { is } from '@electron-toolkit/utils'
import { isDev, isLinux, isMac, isWin } from '@main/constant'
import { getFilesDir } from '@main/utils/file'
import { IpcChannel } from '@shared/IpcChannel'
import { app, BrowserWindow, ipcMain, Menu, MenuItem, shell } from 'electron'
import Logger from 'electron-log'
import windowStateKeeper from 'electron-window-state'
@@ -16,7 +17,6 @@ export class WindowService {
private mainWindow: BrowserWindow | null = null
private miniWindow: BrowserWindow | null = null
private isPinnedMiniWindow: boolean = false
private wasFullScreen: boolean = false
//hacky-fix: store the focused status of mainWindow before miniWindow shows
//to restore the focus status when miniWindow hides
private wasMainWindowFocused: boolean = false
@@ -40,7 +40,8 @@ export class WindowService {
const mainWindowState = windowStateKeeper({
defaultWidth: 1080,
defaultHeight: 670
defaultHeight: 670,
fullScreen: false
})
const theme = configManager.getTheme()
@@ -52,7 +53,7 @@ export class WindowService {
height: mainWindowState.height,
minWidth: 1080,
minHeight: 600,
show: false, // 初始不显示
show: false,
autoHideMenuBar: true,
transparent: isMac,
vibrancy: 'sidebar',
@@ -82,36 +83,6 @@ export class WindowService {
return this.mainWindow
}
public createMinappWindow({
url,
parent,
windowOptions
}: {
url: string
parent?: BrowserWindow
windowOptions?: Electron.BrowserWindowConstructorOptions
}): BrowserWindow {
const width = windowOptions?.width || 1000
const height = windowOptions?.height || 680
const minappWindow = new BrowserWindow({
width,
height,
autoHideMenuBar: true,
title: 'Cherry Studio',
...windowOptions,
parent,
webPreferences: {
preload: join(__dirname, '../preload/minapp.js'),
sandbox: false,
contextIsolation: false
}
})
minappWindow.loadURL(url)
return minappWindow
}
private setupMainWindow(mainWindow: BrowserWindow, mainWindowState: any) {
mainWindowState.manage(mainWindow)
@@ -167,13 +138,11 @@ export class WindowService {
// 处理全屏相关事件
mainWindow.on('enter-full-screen', () => {
this.wasFullScreen = true
mainWindow.webContents.send('fullscreen-status-changed', true)
mainWindow.webContents.send(IpcChannel.FullscreenStatusChanged, true)
})
mainWindow.on('leave-full-screen', () => {
this.wasFullScreen = false
mainWindow.webContents.send('fullscreen-status-changed', false)
mainWindow.webContents.send(IpcChannel.FullscreenStatusChanged, false)
})
// set the zoom factor again when the window is going to resize
@@ -274,6 +243,7 @@ export class WindowService {
private loadMainWindowContent(mainWindow: BrowserWindow) {
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
// mainWindow.webContents.openDevTools()
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
}
@@ -303,23 +273,20 @@ export class WindowService {
}
}
//上述逻辑以下,是“开启托盘+设置关闭时最小化到托盘”的情况
// 如果是Windows或Linux且处于全屏状态则退出应用
if (this.wasFullScreen) {
if (isWin || isLinux) {
return app.quit()
} else {
event.preventDefault()
mainWindow.setFullScreen(false)
return
}
}
/**
* 上述逻辑以下:
* win/linux: 是“开启托盘+设置关闭时最小化到托盘”的情况
* mac: 任何情况都会到这里因此需要单独处理mac
*/
event.preventDefault()
mainWindow.hide()
//for mac users, should hide dock icon if close to tray
app.dock?.hide()
if (isMac && isTrayOnClose) {
app.dock?.hide()
}
})
mainWindow.on('closed', () => {
@@ -343,13 +310,38 @@ export class WindowService {
this.mainWindow.restore()
return
}
//[macOS] Known Issue
// setVisibleOnAllWorkspaces true/false will NOT bring window to current desktop in Mac (works fine with Windows)
// AppleScript may be a solution, but it's not worth
this.mainWindow.setVisibleOnAllWorkspaces(true)
/**
* About setVisibleOnAllWorkspaces
*
* [macOS] Known Issue
* setVisibleOnAllWorkspaces true/false will NOT bring window to current desktop in Mac (works fine with Windows)
* AppleScript may be a solution, but it's not worth
*
* [Linux] Known Issue
* setVisibleOnAllWorkspaces 在 Linux 环境下(特别是 KDE Wayland会导致窗口进入"假弹出"状态
* 因此在 Linux 环境下不执行这两行代码
*/
if (!isLinux) {
this.mainWindow.setVisibleOnAllWorkspaces(true)
}
/**
* [macOS] After being closed in fullscreen, the fullscreen behavior will become strange when window shows again
* So we need to set it to FALSE explicitly.
* althougle other platforms don't have the issue, but it's a good practice to do so
*
* Check if window is visible to prevent interrupting fullscreen state when clicking dock icon
*/
if (this.mainWindow.isFullScreen() && !this.mainWindow.isVisible()) {
this.mainWindow.setFullScreen(false)
}
this.mainWindow.show()
this.mainWindow.focus()
this.mainWindow.setVisibleOnAllWorkspaces(false)
if (!isLinux) {
this.mainWindow.setVisibleOnAllWorkspaces(false)
}
} else {
this.mainWindow = this.createMainWindow()
}
@@ -357,7 +349,9 @@ export class WindowService {
public toggleMainWindow() {
// should not toggle main window when in full screen
if (this.wasFullScreen) {
// but if the main window is close to tray when it's in full screen, we can show it again
// (it's a bug in macos, because we can close the window when it's in full screen, and the state will be remained)
if (this.mainWindow?.isFullScreen() && this.mainWindow?.isVisible()) {
return
}
@@ -411,7 +405,8 @@ export class WindowService {
//miniWindow should show in current desktop
this.miniWindow?.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true })
//make miniWindow always on top of fullscreen apps with level set
this.miniWindow.setAlwaysOnTop(true, 'screen-saver', 1)
//[mac] level higher than 'floating' will cover the pinyin input method
this.miniWindow.setAlwaysOnTop(true, 'floating')
this.miniWindow.on('ready-to-show', () => {
if (isPreload) {
@@ -434,14 +429,14 @@ export class WindowService {
})
this.miniWindow.on('hide', () => {
this.miniWindow?.webContents.send('hide-mini-window')
this.miniWindow?.webContents.send(IpcChannel.HideMiniWindow)
})
this.miniWindow.on('show', () => {
this.miniWindow?.webContents.send('show-mini-window')
this.miniWindow?.webContents.send(IpcChannel.ShowMiniWindow)
})
ipcMain.on('miniwindow-reload', () => {
ipcMain.on(IpcChannel.MiniWindowReload, () => {
this.miniWindow?.reload()
})
@@ -547,7 +542,7 @@ export class WindowService {
// 点击其他地方时隐藏窗口
this.selectionMenuWindow.on('blur', () => {
this.selectionMenuWindow?.hide()
this.miniWindow?.webContents.send('selection-action', {
this.miniWindow?.webContents.send(IpcChannel.SelectionAction, {
action: 'home',
selectedText: this.lastSelectedText
})
@@ -565,12 +560,12 @@ export class WindowService {
private setupSelectionMenuEvents() {
if (!this.selectionMenuWindow) return
ipcMain.removeHandler('selection-menu:action')
ipcMain.handle('selection-menu:action', (_, action) => {
ipcMain.removeHandler(IpcChannel.SelectionMenu_Action)
ipcMain.handle(IpcChannel.SelectionMenu_Action, (_, action) => {
this.selectionMenuWindow?.hide()
this.showMiniWindow()
setTimeout(() => {
this.miniWindow?.webContents.send('selection-action', {
this.miniWindow?.webContents.send(IpcChannel.SelectionAction, {
action,
selectedText: this.lastSelectedText
})

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