Compare commits

..

209 Commits

Author SHA1 Message Date
Vaayne
d98c7e7405 Move i18n.py to scripts/auto-i18n.py with usage examples
Add documentation showing how to set the API key and run the script
with uv. Also improve code formatting with consistent quote style and
spacing.
2025-04-25 21:24:59 +08:00
Vaayne
779d4c4787 Add i18n translation script
Implement Python utility for managing internationalization filesi1 with
support for en-us, zh-8cn, ja-jp, ru-ru,n and zh-tw languages. Uses Agno
agent with OpenRouter for AI-powered translations an
2025-04-25 21:08:24 +08:00
Miter
32f160444b 获取searxng搜索结果url的内容作为大模型回答的参考,而不是使用searxng搜索结果的摘要内容 2025-04-25 18:13:43 +08:00
beyondkmp
a546c265ee fix: update electron-updater to patch to support window arm upgrade (#5337) 2025-04-25 18:13:07 +08:00
kangfenmao
ea89a37b1d feat: update licensing terms and UI components
- Revised the licensing agreement to introduce a User-Segmented Dual Licensing model, detailing conditions for individual and organizational use.
- Enhanced UI components to display a specific empty state image in various popups and pages.
- Adjusted styles in MiniAppIconsManager and MiniAppSettings for improved layout and appearance.
- Updated default web search provider to 'local-bing' and modified font size in ProviderOAuth description for better readability.
- Changed window style setting from 'transparent' to 'opaque' for a more consistent user experience.
2025-04-25 17:50:33 +08:00
kangfenmao
c288b4a8d0 refactor: reorder llm providers 2025-04-25 17:16:40 +08:00
kangfenmao
e8384db91a refactor: remove ollama settings 2025-04-25 17:16:27 +08:00
kangfenmao
36c87451d9 feat: enhance OAuth provider settings with new functionality
- Added a new ProviderOAuth component to manage OAuth authentication and billing for providers.
- Updated existing components to integrate the new ProviderOAuth functionality.
- Enhanced internationalization support for OAuth-related texts across multiple languages.
- Introduced new utility functions for handling provider billing.
2025-04-25 16:55:36 +08:00
fullex
eaa37fe674 feat: open popup url in external browser (#4446)
* feat: open popup url in external browser

* fix: allow google auth popup internal

* feat: add functionality(including settings) to open links in external browser for webviews

* fix: set useragent globally

* fix: remove setUserAgent in webview

* fix: set Chrome version to newest
2025-04-25 09:45:54 +08:00
kangfenmao
308ad9f68f feat: update release notes with new features and fixes
- Added support for grok-2-image and gpt-4o-image.
- Enabled portable version of Windows to use the data directory for storage.
- Revamped MCP interface with new description display.
- Optimized Mermaid rendering logic.
- Added option to disable public rendering.
- Fixed OpenAI type rendering errors.
2025-04-25 09:44:13 +08:00
shiquda
62b6584d65 feat: support preview of MCP call results (#5236) 2025-04-25 08:59:41 +08:00
Chen Tao
5a44f6aca8 fix: image abort error and message render error (#5303) 2025-04-24 21:55:47 +08:00
dcai
4c6a904929 fix: Resolve unsafe map call in MessageContent.tsx (#5311) 2025-04-24 21:39:35 +08:00
kangfenmao
be4ef2990f chore(version): 1.2.8 2025-04-24 18:16:00 +08:00
kangfenmao
2807e71f1a docs: contributor guide 2025-04-24 18:14:21 +08:00
SuYao
a5f8ac8587 feat: add English contributor guide and update issue templates (#5300)
- Introduced a new English version of the contributor guide (CONTRIBUTING_EN.md) to enhance accessibility for non-Chinese speakers.
- Updated issue templates to use more specific labels (kind/bug, kind/enhancement, kind/question) for better categorization.
- Added a testing section in the developer guide to clarify testing procedures.
2025-04-24 18:04:45 +08:00
Chen Tao
4bd50251ff fix(openai): 修复OpenAI类型渲染错误 (#5263) 2025-04-24 17:51:13 +08:00
Chen Tao
f0d60052c4 feat(image): support grok-2-image image and gpt-4o-image (#4767)
* feat(image): support grok image

* feat: add gpt-4o-image

* feat: 添加 gpt-image-1 到生成图像模型列表
2025-04-24 17:26:15 +08:00
Song
84f4b565f3 feat: support portable config dir (#5039)
* feat: support portable config dir

* fix: remove redundant mkdir
2025-04-24 17:23:56 +08:00
kanweiwei
ebdacdde3e refactor(MessageAttachments): move styled component definition inside the component for better encapsulation 2025-04-24 17:06:35 +08:00
kangfenmao
aeb66195a0 feat: enhance MinAppIcon component with sidebar prop
- Added optional sidebar prop to MinAppIcon for conditional styling.
- Updated Sidebar component to pass sidebar prop to MinAppIcon for consistent appearance in sidebar context.
2025-04-24 17:03:12 +08:00
HuiZhang
53ef8b0f32 Create pull_request_template.md 2025-04-24 16:56:43 +08:00
beyondkmp
794c23f296 feat(WindowService): add maximize functionality and disable electron-window-state maxmize (#5292)
* feat(WindowService): add maximize functionality and clean up window close logic

- Introduced a new `maximize` option in the window state configuration.
- Added `setupMaximize` method to handle window maximization based on the launch state.
- Removed redundant logic from the window close event handler for clarity.

* add code

* update code
2025-04-24 16:55:51 +08:00
kangfenmao
62440cbfa1 chore: update dependencies and clean up code
- Reintroduced @mozilla/readability, @shikijs/markdown-it, and @xyflow/react to package.json.
- Updated shiki version to 3.2.2 in both package.json and yarn.lock.
- Removed trailing whitespace in IpcChannel.ts and index.ts for code cleanliness.
- Added outline style to .ant-tabs-tab-btn in ant.scss for improved UI consistency.
2025-04-24 16:39:09 +08:00
寇佳龙
39b723f143 feat(mcp): mcp setting add service description page 2025-04-24 15:55:43 +08:00
kangfenmao
bdc75f2f4e style(settings): update border-radius to use CSS variable for consistency 2025-04-24 15:18:53 +08:00
kangfenmao
eb3f136997 feat: add cherry-text-logo.svg and remove npm.svg; update MCPSettings and NpxSearch components
- Introduced a new cherry-text-logo.svg file for branding.
- Removed the deprecated npm.svg file.
- Refactored MCPSettings and NpxSearch components to enhance functionality and UI, including state management and layout adjustments.
- Updated translations in multiple locales to include new types for MCP servers.
2025-04-24 15:17:20 +08:00
LiuVaayne
6ba5768650 Update @modelcontextprotocol/sdk to v1.10.2 (#5266)
- Removed MCPStreamableHttpClient.ts as it is now provided by the SDK.
- Adjusted imports in MCPService.ts to use the SDK's implementation.
- Updated yarn.lock to reflect the new SDK version.
2025-04-24 15:05:42 +08:00
SuYao
1f588d242e refactor(GeminiProvider): streamline abort signal handling and improve stream processing (#5276) 2025-04-24 13:52:29 +08:00
kangfenmao
25b1e309ed build: remove sentry integration 2025-04-24 11:48:53 +08:00
kangfenmao
7a7b24fe2f build: fix nightly build error 2025-04-24 10:59:52 +08:00
Lucas
0686b2d813 fix(ci): Remove a deleted step which make the nightly build pipeline fail
These lines were deleted in `release.yml` in commit 75f98608.
2025-04-24 10:29:05 +08:00
Chen Tao
4a027892b9 feat: 添加嵌入维度配置 (#3947) 2025-04-24 10:18:23 +08:00
beyondkmp
be323b6304 feat: update os-proxy-config to 1.1.2 and delete the patch (#5255)
updte os-proxy-config to 1.1.2 and delete the patch
2025-04-24 09:24:08 +08:00
Asurada
2f5cfc0162 fix(settings): handle undefined content limit in BasicSettings component (#5252) 2025-04-24 02:58:36 +08:00
kabu1204
4a00eb57ad feat(ProviderSettings): move model provider to the top when toggled
When the model provider is toggled (OFF to ON), it is moved to the top of the provider setting for convenience. The change is minimal.
2025-04-24 00:04:30 +08:00
one
784b02e62e perf: improve streaming performance (#4986) 2025-04-24 00:02:04 +08:00
suyao
4c2c026f6d refactor: switch from @vitejs/plugin-react to @vitejs/plugin-react-swc for improved performance 2025-04-23 23:56:02 +08:00
Rocky LIU Yan
07b2c6f169 fix sse no headers
add eventSourceInit
2025-04-23 22:35:10 +08:00
kangfenmao
aafd04090e fix(WebdavBackupManager): update modal confirmation to use window.modal and center content 2025-04-23 22:33:18 +08:00
Teo
af10ae3f37 style: fix animation (#5243)
* style: fix animation

* fix(animation): correct animation name typos in multiple components
2025-04-23 22:31:26 +08:00
kangfenmao
3df4680c7b feat(mermaid): update Mermaid integration and improve rendering logic
- Upgraded Mermaid script to version 11.6.0.
- Refactored rendering logic to use a debounced function for improved performance.
- Added event listener for 'mermaid-loaded' to trigger rendering.
- Enhanced error handling during Mermaid chart rendering in both main and popup components.
- Removed unnecessary initialization calls and streamlined the use of theme settings.
2025-04-23 21:12:54 +08:00
kangfenmao
5d04ef2508 chore(release): increase Node.js memory limit in release workflow
- Added NODE_OPTIONS to set max-old-space-size to 8192 in the release workflow for Mac, Windows, and Linux builds.
2025-04-23 18:59:53 +08:00
beyondkmp
7fb6eb1949 feat(auto-update): improve auto-update toggle functionality (#5215)
* feat(auto-update): improve auto-update toggle functionality

- Added setAutoUpdate method in AppUpdater to control auto-update behavior.
- Updated IPC handler to set auto-update status based on user preference.
- Modified AboutSettings component to conditionally display update options based on auto-check setting.

* update autoupdate position
2025-04-23 16:13:55 +08:00
fullex
4fe99cddce fix: should give more time to init autosync (#5219) 2025-04-23 16:01:02 +08:00
beyondkmp
a84763def6 fix(proxy): update os-proxy-config patch to correct proxy URL handling (#5222)
* fix(proxy): update os-proxy-config patch to correct proxy URL handling

- Modified the os-proxy-config dependency in package.json to apply a patch.
- The patch updates the logic in getSystemProxy to correctly handle HTTP and SOCKS proxy settings.

* use http instead of https for https proxy
2025-04-23 15:53:25 +08:00
PilgrimLyieu
8125fac309 feat: add support for 'none' option in math engine settings (#5122) 2025-04-23 14:53:19 +08:00
kangfenmao
6c6b2f0b9e fix(sentry): update Sentry configuration and initialization logic
- Changed the organization in the Sentry Vite plugin configuration.
- Modified Sentry initialization in the main process to always check data collection settings.
- Simplified Sentry initialization in the renderer process by removing the packaged check.
2025-04-23 10:59:17 +08:00
kangfenmao
314be9b198 feat: add sentry integration 2025-04-22 22:05:56 +08:00
kangfenmao
409e0096d8 chore(version): 1.2.7 2025-04-22 20:39:14 +08:00
Chen Tao
a1ffabae41 fix(knowledge): fix citation bug and optimize extract logic (#5195)
* fix(knowledge): change search ui and fix search bug

* fix: knowledge citation

* feat: optimize extract logic
2025-04-22 20:17:11 +08:00
kangfenmao
0fa10627bc feat(BackupManager): replace AdmZip with archiver for improved backup compression and add extract-zip for unzipping functionality
- Updated BackupManager to use archiver for creating ZIP files, enabling better performance and support for large files.
- Integrated extract-zip for unzipping backup files, enhancing the backup restoration process.
- Adjusted progress reporting during backup and restore operations for better user feedback.
- Updated package.json and yarn.lock to include archiver and extract-zip dependencies.
2025-04-22 18:03:16 +08:00
eeee0717
80618b2331 fix(knowledge): change search ui and fix search bug 2025-04-22 16:10:03 +08:00
kangfenmao
bf8baedfcf feat(Messages): add MessageCitations and MessageTranslate components for citation and translation display
- Introduced MessageCitations component to handle and display citations from messages.
- Added MessageTranslate component to show translated content with loading state.
- Updated MessageContent to integrate new components and streamline citation formatting.
- Refactored citation handling logic in formats utility for improved performance and clarity.
- Enhanced MessageImage component to manage image download and clipboard copy functionality.

refactor(MCP): optimize MCP server handling in Inputbar and MCPToolsButton

wip

refactor(MCPSettings): streamline MCP server management and enhance UI components

- Removed unused imports and optimized state management for selected MCP servers.
- Introduced McpServersList component to encapsulate server listing and management logic.
- Updated routing to accommodate the new component structure.
- Adjusted styles for better layout and user experience in MCP settings.
2025-04-22 15:40:39 +08:00
SuYao
98f2c8a0b6 Revert "fix(minapps): remove AI Studio entry from default mini apps list" (#5177)
This reverts commit aed9c04c20.
2025-04-22 15:40:33 +08:00
tchigher
3887cf2a6f fix: electron-builder 新增配置导致的无法构建的问题 (#5175)
fix: electron-builder 新增配置导致的无法构建的问题

当前 electron-builder 的版本为 "26.0.13",但在 v26 之后,StartupWMClass 等配置标签要在 desktop > entry 下,而不是直接在 desktop 下,否则会导致无法构建打包
2025-04-22 15:40:33 +08:00
fullex
eb89c6ea21 fix: purify minapp user agent tag (#5173) 2025-04-22 15:40:33 +08:00
Roland
fd09edc2b9 fix(models): 更新OpenRouter模型ID和名称,简化模型组分类 (#5172) 2025-04-22 15:40:33 +08:00
kangfenmao
55a9447a7b refactor(Markdown): remove rehype-sanitize and implement custom element filtering
- Removed rehype-sanitize dependency and its related configuration.
- Introduced ALLOWED_ELEMENTS and DISALLOWED_ELEMENTS for custom HTML element filtering.
- Updated rehypePlugins logic to conditionally apply plugins based on message content.
- Added encodeHTML utility function for HTML entity encoding.
2025-04-22 11:00:27 +08:00
SuYao
c576aa5cb4 fix(MinApp): integrate dynamic background color for MinappPopupContainer (#5142) 2025-04-21 23:44:15 +08:00
Asurada
ca553a2454 chore(electron-builder): add StartupWMClass for CherryStudio in liunx desktop configuration (#5158)
chore(electron-builder): add StartupWMClass for CherryStudio in desktop configuration
2025-04-21 23:40:20 +08:00
beyondkmp
ef9c8fd037 disable auto update in portable exe 2025-04-21 20:14:07 +08:00
kangfenmao
234a5e085f chore(release): update default release tag to v1.0.0 and install setuptools for Mac build 2025-04-21 20:02:37 +08:00
kangfenmao
cb22b80ead fix: zipfile dependencies 2025-04-21 19:25:09 +08:00
kangfenmao
a6d9ad6716 chore(version): 1.2.6 2025-04-21 18:52:01 +08:00
beyondkmp
185900ada6 feat(proxy): use os-proxy-config to get system proxy info instead of resolveProxy (#5123)
* feat(proxy): integrate os-proxy-config for system proxy management

- Added os-proxy-config dependency to manage system proxy settings.
- Refactored setSystemProxy method to utilize getSystemProxy for improved proxy handling.

* fix lint error
2025-04-21 12:45:01 +08:00
Chen Tao
288ebe5222 fix: 知识库和网络搜索使用输出语言问题 (#5129) 2025-04-21 11:42:48 +08:00
kangfenmao
6e91066e5d refactor: remove search enhanceMode 2025-04-21 11:35:06 +08:00
beyondkmp
49a7b2dc8b refactor(AxiosProxy): improve proxy handling and initialization logic
- Changed cacheAxios from undefined to null for better initialization.
- Updated proxy handling to use ProxyAgent, ensuring axios instance is recreated when the proxy changes.
- Simplified axios instance creation by directly using the current proxy agent.
2025-04-21 11:26:13 +08:00
kangfenmao
4789ba3e8f feat: add PostHogProvider for analytics integration
- Introduced PostHogProvider to manage data collection based on user settings.
- Wrapped the main application in PostHogProvider to enable analytics when data collection is allowed.
2025-04-21 11:18:11 +08:00
kangfenmao
cc18f0f0c3 refactor: remove google analytics 2025-04-21 11:01:22 +08:00
chenxi
9bb96c212d fix: deepseek-reasoner does not support successive user or assistant messages in MCP scenario (#5112)
* fix: deepseek-reasoner does not support successive user or assistant messages in MCP scenario.

* fix: @ts-ignore
2025-04-21 09:04:47 +08:00
one
81eab1179b test: add vitest (#5085)
* test: migrate to vitest

* test: update vitest config

* test: updates tests for utils

* ci: fix test command

* test: add tests for format.ts

* test: add snapshots

* test: update snapshots

* test: add tests for linkConverter

* test: add tests for error.ts

* test: update test coverage script name

* test: update tests for prompt.ts

* test: re-group utils, add tests

* test: add tests for export.ts

* test: add tests for sort.ts
2025-04-20 22:44:01 +08:00
beyondkmp
24c9a8e8f1 refactor(locales): fix locales errors (#5080) 2025-04-20 21:27:49 +08:00
Pleasurecruise
c4d0f8e950 fix: language error 2025-04-20 19:03:06 +08:00
kangfenmao
3bdf0be4ad update(README): replace outdated screenshots in English, Japanese, and Chinese documentation 2025-04-20 15:50:58 +08:00
kangfenmao
9e4ebf7c6f Revert "refactor(ipc): remove Windows ARM update check from IPC handler and AboutSettings component"
This reverts commit d1c2bbed1b.
2025-04-20 11:32:59 +08:00
fullex
2408566d34 fix(AssistantSettings): temporarily disable transitionName to resolve modal closing issues in production 2025-04-20 11:20:15 +08:00
lossercode
cf61ae927c fix(MCPService):修复MCP server 请求头不生效 (#5072) 2025-04-20 11:18:34 +08:00
chenxi
60680936d3 feat: support escaping the comma character in the API key. (#5088)
feat: support escaping the comma character in the API key.
2025-04-20 10:25:28 +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
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
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
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
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
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
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
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
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
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
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
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
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
kangfenmao
5c44f71684 refactor(ModelList): replace FileItem with ListItem and HStack for improved layout and styling 2025-04-09 20:42:36 +08:00
fullex
3462be2a2a fix:[mac] window level to show py input 2025-04-09 20:12:33 +08:00
291 changed files with 17827 additions and 141827 deletions

View File

@@ -1,7 +1,7 @@
name: 🐛 错误报告 (中文)
description: 创建一个报告以帮助我们改进
title: '[错误]: '
labels: ['bug']
labels: ['kind/bug']
body:
- type: markdown
attributes:
@@ -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

@@ -1,7 +1,7 @@
name: 💡 功能建议 (中文)
description: 为项目提出新的想法
title: '[功能]: '
labels: ['enhancement']
labels: ['kind/enhancement']
body:
- type: markdown
attributes:

View File

@@ -1,7 +1,7 @@
name: ❓ 讨论 & 提问 (中文)
description: 寻求帮助、讨论问题、提出疑问等...
title: '[讨论]: '
labels: ['question']
labels: ['kind/question']
body:
- type: markdown
attributes:

View File

@@ -1,7 +1,7 @@
name: 🐛 Bug Report (English)
description: Create a report to help us improve
title: '[Bug]: '
labels: ['bug']
labels: ['kind/bug']
body:
- type: markdown
attributes:
@@ -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

View File

@@ -1,7 +1,7 @@
name: 💡 Feature Request (English)
description: Suggest an idea for this project
title: '[Feature]: '
labels: ['enhancement']
labels: ['kind/enhancement']
body:
- type: markdown
attributes:

View File

@@ -1,7 +1,7 @@
name: ❓ Discussion & Questions
description: Seeking help, discussing issues, asking questions, etc...
title: '[Discussion]: '
labels: ['question']
labels: ['kind/question']
body:
- type: markdown
attributes:

54
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,54 @@
<!-- Template from https://github.com/kubevirt/kubevirt/blob/main/.github/PULL_REQUEST_TEMPLATE.md?-->
<!-- Thanks for sending a pull request! Here are some tips for you:
1. Consider creating this PR as draft: https://github.com/CherryHQ/cherry-studio/blob/main/CONTRIBUTING.md
-->
### What this PR does
Before this PR:
After this PR:
<!-- (optional, in `fixes #<issue number>(, fixes #<issue_number>, ...)` format, will close the issue(s) when PR gets merged)*: -->
Fixes #
### Why we need it and why it was done in this way
The following tradeoffs were made:
The following alternatives were considered:
Links to places where the discussion took place: <!-- optional: slack, other GH issue, mailinglist, ... -->
### Breaking changes
<!-- optional -->
If this PR introduces breaking changes, please describe the changes and the impact on users.
### Special notes for your reviewer
<!-- optional -->
### Checklist
This checklist is not enforcing, but it's a reminder of items that could be relevant to every PR.
Approvers are expected to review this list.
- [ ] PR: The PR description is expressive enough and will help future contributors
- [ ] Code: [Write code that humans can understand](https://en.wikiquote.org/wiki/Martin_Fowler#code-for-humans) and [Keep it simple](https://en.wikipedia.org/wiki/KISS_principle)
- [ ] Refactor: You have [left the code cleaner than you found it (Boy Scout Rule)](https://learning.oreilly.com/library/view/97-things-every/9780596809515/ch08.html)
- [ ] Upgrade: Impact of this change on upgrade flows was considered and addressed if required
- [ ] Documentation: A [user-guide update](https://docs.cherry-ai.com) was considered and is present (link) or not required. You want a user-guide update if it's a user facing feature.
### Release note
<!-- Write your release note:
1. Enter your extended release note in the below block. If the PR requires additional action from users switching to the new release, include the string "action required".
2. If no release note is required, just write "NONE".
-->
```release-note
```

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

@@ -7,9 +7,41 @@ on:
permissions:
contents: write
actions: write # Required for deleting artifacts
jobs:
cleanup-artifacts:
runs-on: ubuntu-latest
steps:
- name: Delete old artifacts
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
run: |
# Calculate the date 14 days ago
cutoff_date=$(date -d "14 days ago" +%Y-%m-%d)
# List and delete artifacts older than cutoff date
gh api repos/$REPO/actions/artifacts --paginate | \
jq -r '.artifacts[] | select(.name | startswith("cherry-studio-nightly-")) | select(.created_at < "'$cutoff_date'") | .id' | \
while read artifact_id; do
echo "Deleting artifact $artifact_id"
gh api repos/$REPO/actions/artifacts/$artifact_id -X DELETE
done
check-repository:
runs-on: ubuntu-latest
outputs:
should_run: ${{ github.repository == 'CherryHQ/cherry-studio' }}
steps:
- name: Check if running in main repository
run: |
echo "Running in repository: ${{ github.repository }}"
echo "Should run: ${{ github.repository == 'CherryHQ/cherry-studio' }}"
nightly-build:
needs: check-repository
if: needs.check-repository.outputs.should_run == 'true'
runs-on: ${{ matrix.os }}
strategy:
@@ -26,6 +58,11 @@ jobs:
with:
node-version: 20
- name: macos-latest dependencies fix
if: matrix.os == 'macos-latest'
run: |
brew install python-setuptools
- name: Install corepack
run: corepack enable && corepack prepare yarn@4.6.0 --activate
@@ -59,6 +96,7 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
NODE_OPTIONS: --max-old-space-size=8192
- name: Build Mac
if: matrix.os == 'macos-latest'
@@ -73,19 +111,17 @@ jobs:
APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_OPTIONS: --max-old-space-size=8192
- name: Build Windows
if: matrix.os == 'windows-latest'
run: |
yarn build:npm windows
yarn build:win:x64
yarn build:win:arm64
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
NODE_OPTIONS: --max-old-space-size=8192
- name: Rename artifacts with nightly format
shell: bash
@@ -96,39 +132,24 @@ jobs:
# Windows artifacts - based on actual file naming pattern
if [ "${{ matrix.os }}" == "windows-latest" ]; then
# Setup installer
find dist -name "*setup.exe" -exec cp {} renamed-artifacts/cherry-studio-nightly-${DATE}-setup.exe \;
# Portable exe
find dist -name "*portable.exe" -exec cp {} renamed-artifacts/cherry-studio-nightly-${DATE}-portable.exe \;
find dist -name "*-x64-setup.exe" -exec cp {} renamed-artifacts/cherry-studio-nightly-${DATE}-x64-setup.exe \;
find dist -name "*-arm64-setup.exe" -exec cp {} renamed-artifacts/cherry-studio-nightly-${DATE}-arm64-setup.exe \;
# Rename blockmap files to match the new exe names
if [ -f "dist/*setup.exe.blockmap" ]; then
cp dist/*setup.exe.blockmap renamed-artifacts/cherry-studio-nightly-${DATE}-setup.exe.blockmap || true
fi
# Portable exe
find dist -name "*-x64-portable.exe" -exec cp {} renamed-artifacts/cherry-studio-nightly-${DATE}-x64-portable.exe \;
find dist -name "*-arm64-portable.exe" -exec cp {} renamed-artifacts/cherry-studio-nightly-${DATE}-arm64-portable.exe \;
fi
# macOS artifacts
if [ "${{ matrix.os }}" == "macos-latest" ]; then
# 处理arm64架构文件
find dist -name "*-arm64.dmg" -exec cp {} renamed-artifacts/cherry-studio-nightly-${DATE}-arm64.dmg \;
find dist -name "*-arm64.dmg.blockmap" -exec cp {} renamed-artifacts/cherry-studio-nightly-${DATE}-arm64.dmg.blockmap \;
find dist -name "*-arm64.zip" -exec cp {} renamed-artifacts/cherry-studio-nightly-${DATE}-arm64.zip \;
find dist -name "*-arm64.zip.blockmap" -exec cp {} renamed-artifacts/cherry-studio-nightly-${DATE}-arm64.zip.blockmap \;
# 处理x64架构文件
find dist -name "*-x64.dmg" -exec cp {} renamed-artifacts/cherry-studio-nightly-${DATE}-x64.dmg \;
find dist -name "*-x64.dmg.blockmap" -exec cp {} renamed-artifacts/cherry-studio-nightly-${DATE}-x64.dmg.blockmap \;
find dist -name "*-x64.zip" -exec cp {} renamed-artifacts/cherry-studio-nightly-${DATE}-x64.zip \;
find dist -name "*-x64.zip.blockmap" -exec cp {} renamed-artifacts/cherry-studio-nightly-${DATE}-x64.zip.blockmap \;
fi
# Linux artifacts
if [ "${{ matrix.os }}" == "ubuntu-latest" ]; then
find dist -name "*.AppImage" -exec cp {} renamed-artifacts/cherry-studio-nightly-${DATE}.AppImage \;
find dist -name "*.snap" -exec cp {} renamed-artifacts/cherry-studio-nightly-${DATE}.snap \;
find dist -name "*.deb" -exec cp {} renamed-artifacts/cherry-studio-nightly-${DATE}.deb \;
find dist -name "*.rpm" -exec cp {} renamed-artifacts/cherry-studio-nightly-${DATE}.rpm \;
find dist -name "*.tar.gz" -exec cp {} renamed-artifacts/cherry-studio-nightly-${DATE}.tar.gz \;
find dist -name "*-x86_64.AppImage" -exec cp {} renamed-artifacts/cherry-studio-nightly-${DATE}-x86_64.AppImage \;
find dist -name "*-arm64.AppImage" -exec cp {} renamed-artifacts/cherry-studio-nightly-${DATE}-arm64.AppImage \;
fi
# Copy update files

View File

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

5
.gitignore vendored
View File

@@ -46,3 +46,8 @@ local
.aider*
.cursorrules
.cursor/rules
# test
coverage
.vitest-cache
vitest.config.*.timestamp-*

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 // 界面显示语言
}

View File

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

View File

@@ -0,0 +1,38 @@
diff --git a/out/MacUpdater.js b/out/MacUpdater.js
index 8f18dc5416c91835ded4e47f2358fba680c129ac..a3fb43c2450dc3484bf099b5ea79a362a3b372cc 100644
--- a/out/MacUpdater.js
+++ b/out/MacUpdater.js
@@ -74,7 +74,7 @@ class MacUpdater extends AppUpdater_1.AppUpdater {
else {
files = files.filter(file => !isArm64(file));
}
- const zipFileInfo = (0, Provider_1.findFile)(files, "zip", ["pkg", "dmg"]);
+ const zipFileInfo = (0, Provider_1.findFile)(files, "zip", ["pkg", "dmg"], false /*has been filtered by myself*/);
if (zipFileInfo == null) {
throw (0, builder_util_runtime_1.newError)(`ZIP file not provided: ${(0, builder_util_runtime_1.safeStringifyJson)(files)}`, "ERR_UPDATER_ZIP_FILE_NOT_FOUND");
}
diff --git a/out/providers/Provider.js b/out/providers/Provider.js
index 9829dff7e95aa9baa0bfdf29f52e6f761c9b7243..6ecaade9e294c87c03bb42e77ff5463f2782cb3c 100644
--- a/out/providers/Provider.js
+++ b/out/providers/Provider.js
@@ -61,11 +61,18 @@ class Provider {
}
}
exports.Provider = Provider;
-function findFile(files, extension, not) {
+function findFile(files, extension, not, filterByArch = true) {
if (files.length === 0) {
throw (0, builder_util_runtime_1.newError)("No files provided", "ERR_UPDATER_NO_FILES_PROVIDED");
}
- const result = files.find(it => it.url.pathname.toLowerCase().endsWith(`.${extension.toLowerCase()}`));
+ const result = files
+ .filter(file => {
+ if (!filterByArch) {
+ return true;
+ }
+ return (process.arch == "arm64") === (file.url.pathname.includes("arm64") || file.info.url.includes("arm64"));
+ })
+ .find(it => it.url.pathname.toLowerCase().endsWith(`.${extension.toLowerCase()}`));
if (result != null) {
return result;
}

View File

@@ -1,45 +1,73 @@
# Cherry Studio 贡献者指南
[中文](./docs/CONTRIBUTING.zh.md) | [English](./CONTRIBUTING.md)
欢迎来到 Cherry Studio 的贡献者社区!我们致力于将 Cherry Studio 打造成一个长期提供价值的项目,并希望邀请更多的开发者加入我们的行列。无论您是经验丰富的开发者还是刚刚起步的初学者,您的贡献都将帮助我们更好地服务用户,提升软件质量。
# Cherry Studio Contributor Guide
## 如何贡献
Welcome to the Cherry Studio contributor community! We are committed to making Cherry Studio a project that provides long-term value and hope to invite more developers to join us. Whether you are an experienced developer or a beginner just starting out, your contributions will help us better serve users and improve software quality.
以下是您可以参与的几种方式:
## How to Contribute
1. **贡献代码**:帮助我们开发新功能或优化现有代码。请确保您的代码符合我们的编码标准,并通过所有测试。
Here are several ways you can participate:
2. **修复 BUG**:如果您发现了 BUG欢迎提交修复方案。请在提交前确认问题已被解决并附上相关测试。
1. **Contribute Code**: Help us develop new features or optimize existing code. Please ensure your code adheres to our coding standards and passes all tests.
3. **维护 Issue**:协助我们管理 GitHub 上的 issue帮助标记、分类和解决问题。
2. **Fix Bugs**: If you find a bug, you are welcome to submit a fix. Please confirm the issue is resolved before submitting and include relevant tests.
4. **产品设计**:参与产品设计讨论,帮助我们改进用户体验和界面设计。
3. **Maintain Issues**: Help us manage issues on GitHub by assisting with tagging, classifying, and resolving problems.
5. **编写文档**帮助我们完善用户手册、API 文档和开发者指南。
4. **Product Design**: Participate in product design discussions to help us improve user experience and interface design.
6. **社区维护**:参与社区讨论,帮助解答用户问题,促进社区活跃。
5. **Write Documentation**: Help us improve the user manual, API documentation, and developer guides.
7. **推广使用**:通过博客、社交媒体等渠道推广 Cherry Studio吸引更多用户和开发者。
6. **Community Maintenance**: Participate in community discussions, help answer user questions, and promote community activity.
## 开始贡献
7. **Promote Usage**: Promote Cherry Studio through blogs, social media, and other channels to attract more users and developers.
1. **Fork 仓库**:在 GitHub 上 fork 我们的仓库,并将其克隆到本地。
## Before You Start
2. **创建分支**:为您要进行的更改创建一个新的分支。
Please make sure you have read the [Code of Conduct](CODE_OF_CONDUCT.md) and the [LICENSE](LICENSE).
3. **提交更改**:在本地进行更改并提交。请确保您的提交信息清晰明了。
## Getting Started
4. **发起 Pull Request**:将您的更改推送到 GitHub,并发起 Pull Request。请描述您的更改内容和原因。
To help you get familiar with the codebase, we recommend tackling issues tagged with one or more of the following labels: [good-first-issue](https://github.com/CherryHQ/cherry-studio/labels/good%20first%20issue), [help-wanted](https://github.com/CherryHQ/cherry-studio/labels/help%20wanted), or [kind/bug](https://github.com/CherryHQ/cherry-studio/labels/kind%2Fbug). Any help is welcome.
### 其他建议
### Testing
- **联系开发者**:在提交 PR 之前,您可以先和开发者进行联系,共同探讨或者获取帮助。
- **成为核心开发者**:如果您能够稳定为项目贡献,恭喜您可以成为项目核心开发者,获取到项目成员身份。
Features without tests are considered non-existent. To ensure code is truly effective, relevant processes should be covered by unit tests and functional tests. Therefore, when considering contributions, please also consider testability. All tests can be run locally without dependency on CI. Please refer to the "Testing" section in the [Developer Guide](docs/dev.md).
## 联系我们
### Automated Testing for Pull Requests
如果您有任何问题或建议,欢迎通过以下方式联系我们:
Automated tests are triggered on pull requests (PRs) opened by members of the Cherry Studio organization, except for draft PRs. PRs opened by new contributors will initially be marked with the `needs-ok-to-test` label and will not be automatically tested. Once a Cherry Studio organization member adds `/ok-to-test` to the PR, the test pipeline will be created.
- 微信kangfenmao
### Consider Opening Your Pull Request as a Draft
Not all pull requests are ready for review when created. This might be because the author wants to start a discussion, they are not entirely sure if the changes are heading in the right direction, or the changes are not yet complete. Please consider creating these PRs as [draft pull requests](https://github.blog/2019-02-14-introducing-draft-pull-requests/). Draft PRs are skipped by CI, thus saving CI resources. This also means reviewers will not be automatically assigned, and the community will understand that this PR is not yet ready for review.
Reviewers will be assigned after you mark the draft pull request as ready for review.
### Contributor Compliance with Project Terms
We require every contributor to certify that they have the right to legally contribute to our project. Contributors express this by consciously signing their commits, thereby indicating their compliance with the [LICENSE](LICENSE).
A signed commit is one where the commit message includes the following:
You can generate a signed commit using the following command [git commit --signoff](https://git-scm.com/docs/git-commit#Documentation/git-commit.txt---signoff):
```
git commit --signoff -m "Your commit message"
```
### Getting Code Reviewed/Merged
Maintainers are here to help you implement your use case within a reasonable timeframe. They will do their best to review your code and provide constructive feedback promptly. However, if you get stuck during the review process or feel your Pull Request is not receiving the attention it deserves, please contact us via comments in the Issue or through the [Community](README.md#-community).
### Other Suggestions
- **Contact Developers**: Before submitting a PR, you can contact the developers first to discuss or get help.
- **Become a Core Developer**: If you contribute to the project consistently, congratulations, you can become a core developer and gain project membership status. Please check our [Membership Guide](https://github.com/CherryHQ/community/blob/main/docs/membership.en.md).
## Contact Us
If you have any questions or suggestions, feel free to contact us through the following ways:
- WeChat: kangfenmao
- [GitHub Issues](https://github.com/CherryHQ/cherry-studio/issues)
感谢您的支持和贡献!我们期待与您一起将 Cherry Studio 打造成更好的产品。
Thank you for your support and contributions! We look forward to working with you to make Cherry Studio a better product.

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

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

77
docs/CONTRIBUTING.zh.md Normal file
View File

@@ -0,0 +1,77 @@
# Cherry Studio 贡献者指南
[**English**](../CONTRIBUTING.md) | [**中文**](./CONTRIBUTING.zh.md)
欢迎来到 Cherry Studio 的贡献者社区!我们致力于将 Cherry Studio 打造成一个长期提供价值的项目,并希望邀请更多的开发者加入我们的行列。无论您是经验丰富的开发者还是刚刚起步的初学者,您的贡献都将帮助我们更好地服务用户,提升软件质量。
## 如何贡献
以下是您可以参与的几种方式:
1. **贡献代码**:帮助我们开发新功能或优化现有代码。请确保您的代码符合我们的编码标准,并通过所有测试。
2. **修复 BUG**:如果您发现了 BUG欢迎提交修复方案。请在提交前确认问题已被解决并附上相关测试。
3. **维护 Issue**:协助我们管理 GitHub 上的 issue帮助标记、分类和解决问题。
4. **产品设计**:参与产品设计讨论,帮助我们改进用户体验和界面设计。
5. **编写文档**帮助我们完善用户手册、API 文档和开发者指南。
6. **社区维护**:参与社区讨论,帮助解答用户问题,促进社区活跃。
7. **推广使用**:通过博客、社交媒体等渠道推广 Cherry Studio吸引更多用户和开发者。
## 开始之前
请确保阅读了[行为准则](CODE_OF_CONDUCT.md)和[LICENSE](LICENSE)。
## 开始贡献
为了让您更熟悉代码,建议您处理一些标记有以下标签之一或多个的问题:[good-first-issue](https://github.com/CherryHQ/cherry-studio/labels/good%20first%20issue)、[help-wanted](https://github.com/CherryHQ/cherry-studio/labels/help%20wanted) 或 [kind/bug](https://github.com/CherryHQ/cherry-studio/labels/kind%2Fbug)。任何帮助都会收到欢迎。
### 测试
未经测试的功能等同于不存在。为确保代码真正有效,应通过单元测试和功能测试覆盖相关流程。因此,在考虑贡献时,也请考虑可测试性。所有测试均可本地运行,无需依赖 CI。请参阅[开发者指南](docs/dev.md#test)中的“Test”部分。
### 拉取请求的自动化测试
自动化测试会在 Cherry Studio 组织成员开启的拉取请求PR上触发草稿 PR 除外。新贡献者开启的 PR 最初会标记为 needs-ok-to-test 标签且不自动测试。待 Cherry Studio 组织成员在 PR 上添加 /ok-to-test 后,测试通道将被创建。
### 考虑将您的拉取请求作为草稿打开
并非所有拉取请求在创建时就准备好接受审查。这可能是因为作者想发起讨论,或者他们不完全确定更改是否朝着正确的方向发展,甚至可能是因为更改尚未完成。请考虑将这些 PR 创建为[草稿拉取请求](https://github.blog/2019-02-14-introducing-draft-pull-requests/)。草稿 PR 会被CI跳过从而节省CI资源。这也意味着审阅者不会被自动分配社区会理解此 PR 尚未准备好接受审阅。
在您将草稿拉取请求标记为准备审核后,审核人员将被分配
### 贡献者遵守项目条款
我们要求每位贡献者证明他们有权合法地为我们的项目做出贡献。贡献者通过有意识地签署他们的提交来表达这一点,并通过这一行为表明他们遵守许可证[LICENSE](LICENSE)。
签名提交是指提交信息中包含以下内容的提交:
```
Signed-off-by: Your Name <your.email@example.com>
```
您可以通过以下命令[git commit --signoff](https://git-scm.com/docs/git-commit#Documentation/git-commit.txt---signoff)生成签名提交:
```
git commit --signoff -m "Your commit message"
```
### 获取代码审查/合并
维护者在此帮助您在合理时间内实现您的用例。他们会尽力在合理时间内审查您的代码并提供建设性反馈。但如果您在审查过程中受阻,或认为您的 Pull Request 未得到应有的关注,请通过 Issue 中的评论或者[社群](README.md#-community)联系我们
### 其他建议
- **联系开发者**:在提交 PR 之前,您可以先和开发者进行联系,共同探讨或者获取帮助。
- **成为核心开发者**:如果您能够稳定为项目贡献,恭喜您可以成为项目核心开发者,获取到项目成员身份。请查看我们的[成员指南](https://github.com/CherryHQ/community/blob/main/membership.md)
## 联系我们
如果您有任何问题或建议,欢迎通过以下方式联系我们:
- 微信kangfenmao
- [GitHub Issues](https://github.com/CherryHQ/cherry-studio/issues)
感谢您的支持和贡献!我们期待与您一起将 Cherry Studio 打造成更好的产品。

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) をして開発をサポートしてください!❤️
@@ -24,14 +24,12 @@ https://docs.cherry-ai.com
# 🌠 スクリーンショット
![](https://github.com/user-attachments/assets/28585d83-4bf0-4714-b561-8c7bf57cc600)
![](https://github.com/user-attachments/assets/8576863a-f632-4776-bc12-657eeced9da3)
![](https://github.com/user-attachments/assets/790790d7-b462-48dd-bde1-91c1697a4648)
![](https://github.com/user-attachments/assets/082efa42-c4df-4863-a9cb-80435cecce0f)
![](https://github.com/user-attachments/assets/f8411a65-c51f-47d3-9273-62ae384cc6f1)
![](https://github.com/user-attachments/assets/0d235b3e-65ae-45ab-987f-8dbe003c52be)
# 🌟 主な機能
![](https://github.com/user-attachments/assets/7b4f2f78-5cbe-4be8-9aec-f98d8405a505)
1. **多様な LLM サービス対応**
- ☁️ 主要な LLM クラウドサービス対応OpenAI、Gemini、Anthropic など
@@ -85,8 +83,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)! ❤️
@@ -24,14 +24,12 @@ https://docs.cherry-ai.com
# 🌠 界面
![](https://github.com/user-attachments/assets/28585d83-4bf0-4714-b561-8c7bf57cc600)
![](https://github.com/user-attachments/assets/8576863a-f632-4776-bc12-657eeced9da3)
![](https://github.com/user-attachments/assets/790790d7-b462-48dd-bde1-91c1697a4648)
![](https://github.com/user-attachments/assets/082efa42-c4df-4863-a9cb-80435cecce0f)
![](https://github.com/user-attachments/assets/f8411a65-c51f-47d3-9273-62ae384cc6f1)
![](https://github.com/user-attachments/assets/0d235b3e-65ae-45ab-987f-8dbe003c52be)
# 🌟 主要特性
![](https://github.com/user-attachments/assets/995910f3-177a-4d1e-97ea-04e3b009ba36)
1. **多样化 LLM 服务支持**
- ☁️ 支持主流 LLM 云服务OpenAI、Gemini、Anthropic、硅基流动等
@@ -85,8 +83,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 更多主题
@@ -113,7 +114,7 @@ Aero 主题https://github.com/hakadao/CherryStudio-Aero
3. **提交更改**:提交并推送您的更改。
4. **打开 Pull Request**:描述您的更改和原因。
有关更详细的指南,请参阅我们的 [贡献指南](../CONTRIBUTING.md)。
有关更详细的指南,请参阅我们的 [贡献指南](./CONTRIBUTING.zh.md)。
感谢您的支持和贡献!

View File

@@ -37,6 +37,12 @@ yarn install
yarn dev
```
### Test
```bash
yarn test
```
### Build
```bash

View File

@@ -1,5 +1,14 @@
appId: com.kangfenmao.CherryStudio
productName: Cherry Studio
electronLanguages:
- zh-CN
- zh-TW
- en-US
- ja # macOS/linux/win
- ru # macOS/linux/win
- zh_CN # for macOS
- zh_TW # for macOS
- en # for macOS
directories:
buildResources: build
files:
@@ -29,7 +38,7 @@ files:
- '!node_modules/mammoth/{mammoth.browser.js,mammoth.browser.min.js}'
asarUnpack:
- resources/**
- '**/*.{node,dll,metal,exp,lib}'
- '**/*.{metal,exp,lib}'
win:
executableName: Cherry Studio
artifactName: ${productName}-${version}-${arch}-setup.${ext}
@@ -44,6 +53,7 @@ nsis:
allowToChangeInstallationDirectory: true
oneClick: false
include: build/nsis-installer.nsh
buildUniversalInstaller: false
portable:
artifactName: ${productName}-${version}-${arch}-portable.${ext}
mac:
@@ -57,35 +67,29 @@ 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
desktop:
entry:
StartupWMClass: CherryStudio
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: |
知识库和服务商界面更新
增加 Dangbei 小程序
可以强制使用搜索引擎覆盖模型自带搜索能力
修复部分公式无法正常渲染问题
新增对 grok-2-image 和 gpt-4o-image 图像支持
支持 Windows 便携版使用 data 目录存储数据
MCP 界面改版,新增描述信息显示
Mermaid 渲染逻辑优化
支持关闭公示渲染
修复 OpenAI 类型渲染错误

View File

@@ -1,4 +1,4 @@
import react from '@vitejs/plugin-react'
import react from '@vitejs/plugin-react-swc'
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
import { resolve } from 'path'
import { visualizer } from 'rollup-plugin-visualizer'
@@ -52,19 +52,17 @@ export default defineConfig({
renderer: {
plugins: [
react({
babel: {
plugins: [
[
'styled-components',
{
displayName: true, // 开发环境下启用组件名
fileName: false, // 不在类名中包含文件名
pure: true, // 优化性能
ssr: false // 不需要服务端渲染
}
]
plugins: [
[
'@swc/plugin-styled-components',
{
displayName: true, // 开发环境下启用组件名称
fileName: false, // 不在类名中包含文件名
pure: true, // 优化性能
ssr: false // 不需要服务端渲染
}
]
}
]
}),
...visualizerPlugin('renderer')
],

View File

@@ -1,6 +1,6 @@
{
"name": "CherryStudio",
"version": "1.2.1",
"version": "1.2.8",
"private": true,
"description": "A powerful AI assistant for producer.",
"main": "./out/main/index.js",
@@ -23,13 +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:win:arm64": "dotenv npm run build && electron-builder --win --arm64",
"build:mac": "dotenv electron-vite build && electron-builder --mac",
"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",
@@ -44,7 +44,12 @@
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
"check:i18n": "node scripts/check-i18n.js",
"test": "npx -y tsx --test src/**/*.test.ts",
"test": "yarn test:renderer",
"test:coverage": "yarn test:renderer:coverage",
"test:node": "npx -y tsx --test src/**/*.test.ts",
"test:renderer": "vitest run",
"test:renderer:ui": "vitest --ui",
"test:renderer:coverage": "vitest run --coverage",
"format": "prettier --write .",
"lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
"postinstall": "electron-builder install-app-deps",
@@ -64,43 +69,44 @@
"@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",
"@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",
"archiver": "^7.0.1",
"async-mutex": "^0.5.0",
"bufferutil": "^4.0.9",
"color": "^5.0.0",
"diff": "^7.0.0",
"docx": "^9.0.2",
"electron-log": "^5.1.5",
"electron-store": "^8.2.0",
"electron-updater": "^6.3.9",
"electron-updater": "patch:electron-updater@npm%3A6.6.3#~/.yarn/patches/electron-updater-npm-6.6.3-9269dbaf84.patch",
"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",
"extract-zip": "^2.0.1",
"fast-xml-parser": "^5.2.0",
"fetch-socks": "^1.3.2",
"fs-extra": "^11.2.0",
"got-scraping": "^4.1.1",
"jsdom": "^26.0.0",
"markdown-it": "^14.1.0",
"node-stream-zip": "^1.15.0",
"officeparser": "^4.1.1",
"os-proxy-config": "^1.1.2",
"proxy-agent": "^6.5.0",
"tar": "^7.4.3",
"tiny-pinyin": "^1.3.2",
"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",
@@ -110,12 +116,15 @@
"@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": "^0.10.0",
"@hello-pangea/dnd": "^16.6.0",
"@kangfenmao/keyv-storage": "^0.1.0",
"@modelcontextprotocol/sdk": "^1.9.0",
"@modelcontextprotocol/sdk": "^1.10.2",
"@mozilla/readability": "^0.6.0",
"@notionhq/client": "^2.2.15",
"@reduxjs/toolkit": "^2.2.5",
"@shikijs/markdown-it": "^3.2.2",
"@swc/plugin-styled-components": "^7.1.3",
"@tavily/core": "patch:@tavily/core@npm%3A0.3.1#~/.yarn/patches/@tavily-core-npm-0.3.1-fe69bf2bea.patch",
"@tryfabric/martian": "^1.2.4",
"@types/adm-zip": "^0",
@@ -130,8 +139,11 @@
"@types/react-dom": "^19.0.4",
"@types/react-infinite-scroll-component": "^5.0.0",
"@types/tinycolor2": "^1",
"@vitejs/plugin-react": "^4.2.1",
"analytics": "^0.8.16",
"@types/ws": "^8",
"@vitejs/plugin-react-swc": "^3.9.0",
"@vitest/coverage-v8": "^3.1.1",
"@vitest/ui": "^3.1.1",
"@xyflow/react": "^12.4.4",
"antd": "^5.22.5",
"applescript": "^1.0.0",
"axios": "^1.7.3",
@@ -142,7 +154,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",
@@ -158,6 +170,7 @@
"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.87.3#~/.yarn/patches/openai-npm-4.87.3-2b30a7685f.patch",
@@ -184,14 +197,16 @@
"remark-math": "^6.0.0",
"rollup-plugin-visualizer": "^5.12.0",
"sass": "^1.77.2",
"shiki": "^3.2.1",
"shiki": "^3.2.2",
"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",
"vitest": "^3.1.1"
},
"resolutions": {
"pdf-parse@npm:1.1.1": "patch:pdf-parse@npm%3A1.1.1#~/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch",
@@ -200,7 +215,9 @@
"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"
"pkce-challenge@npm:^4.1.0": "patch:pkce-challenge@npm%3A4.1.0#~/.yarn/patches/pkce-challenge-npm-4.1.0-fbc51695a3.patch",
"app-builder-lib@npm:26.0.13": "patch:app-builder-lib@npm%3A26.0.13#~/.yarn/patches/app-builder-lib-npm-26.0.13-a064c9e1d0.patch",
"shiki": "3.2.2"
},
"packageManager": "yarn@4.6.0",
"lint-staged": {

View File

@@ -12,12 +12,16 @@ export enum IpcChannel {
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',
Webview_SetOpenLinkExternal = 'webview:set-open-link-external',
// Open
Open_Path = 'open:path',
Open_Website = 'open:website',
@@ -39,6 +43,10 @@ export enum IpcChannel {
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',
@@ -116,6 +124,7 @@ export enum IpcChannel {
Backup_ListWebdavFiles = 'backup:listWebdavFiles',
Backup_CheckConnection = 'backup:checkConnection',
Backup_CreateDirectory = 'backup:createDirectory',
Backup_DeleteWebdavFile = 'backup:deleteWebdavFile',
// zip
Zip_Compress = 'zip:compress',
@@ -123,6 +132,7 @@ export enum IpcChannel {
// system
System_GetDeviceType = 'system:getDeviceType',
System_GetHostname = 'system:getHostname',
// events
SelectionAction = 'selection-action',
@@ -146,5 +156,10 @@ export enum IpcChannel {
MiniWindowReload = 'miniwindow-reload',
ReduxStateChange = 'redux-state-change',
ReduxStoreReady = 'redux-store-ready'
ReduxStoreReady = 'redux-store-ready',
// Search Window
SearchWindow_Open = 'search-window:open',
SearchWindow_Close = 'search-window:close',
SearchWindow_OpenUrl = 'search-window:open-url'
}

View File

@@ -1,111 +1,199 @@
<!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" />
</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 时还应遵守以下附加条款:
<head>
<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-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">本项目采用<strong>区分用户的双重许可 (User-Segmented Dual Licensing)</strong> 模式。</p>
<section class="mb-8">
<h2 class="text-xl font-semibold mb-4 text-gray-900">核心原则</h2>
<ul class="list-disc pl-6 space-y-2 text-gray-700">
<li><strong>个人用户 和 10人及以下企业/组织:</strong> 默认适用 <strong>GNU Affero 通用公共许可证 v3.0 (AGPLv3)</strong></li>
<li><strong>超过10人的企业/组织:</strong> <strong>必须</strong> 获取 <strong>商业许可证 (Commercial License)</strong></li>
</ul>
</section>
<section class="mb-8">
<h2 class="text-xl font-semibold mb-4 text-gray-900">定义:"10人及以下"</h2>
<p class="text-gray-700">
指在您的组织包括公司、非营利组织、政府机构、教育机构等任何实体能够访问、使用或以任何方式直接或间接受益于本软件Cherry
Studio功能的个人总数不超过10人。这包括但不限于开发者、测试人员、运营人员、最终用户、通过集成系统间接使用者等。
</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>
</section>
<section class="mb-8">
<h2 class="text-xl font-semibold mb-4 text-gray-900">1. 开源许可证 (Open Source License): AGPLv3 - 适用于个人及10人及以下组织
</h2>
<ul class="list-disc pl-6 space-y-2 text-gray-700">
<li>如果您是个人用户,或者您的组织满足上述"10人及以下"的定义,您可以在 <strong>AGPLv3</strong> 的条款下自由使用、修改和分发 Cherry Studio。AGPLv3 的完整文本可以访问
<a href="https://www.gnu.org/licenses/agpl-3.0.html"
class="text-blue-600 hover:underline">https://www.gnu.org/licenses/agpl-3.0.html</a> 获取。
</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>
</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><strong>核心义务:</strong> AGPLv3 的一个关键要求是,如果您修改了 Cherry Studio 并通过网络提供服务,或者分发了修改后的版本,您必须以 AGPLv3
许可证向接收者提供相应的<strong>完整源代码</strong>。即使您符合"10人及以下"的标准,如果您希望避免此源代码公开义务,您也需要考虑获取商业许可证(见下文)。</li>
<li>使用前请务必仔细阅读并理解 AGPLv3 的所有条款。</li>
</ul>
</section>
<section class="mb-8">
<h2 class="text-xl font-semibold mb-4 text-gray-900">2. 商业许可证 (Commercial License) - 适用于超过10人的组织或希望规避 AGPLv3
义务的用户</h2>
<ul class="list-disc pl-6 space-y-2 text-gray-700">
<li><strong>强制要求:</strong>
如果您的组织<strong></strong>满足上述"10人及以下"的定义即有11人或更多人可以访问、使用或受益于本软件<strong>必须</strong>联系我们获取并签署一份商业许可证才能使用
Cherry Studio。</li>
<li><strong>自愿选择:</strong> 即使您的组织满足"10人及以下"的条件,但如果您的使用场景<strong>无法满足 AGPLv3
的条款要求</strong>(特别是关于<strong>源代码公开</strong>的义务),或者您需要 AGPLv3 <strong>未提供</strong>的特定商业条款(如保证、赔偿、无 Copyleft
限制等),您也<strong>必须</strong>联系我们获取并签署一份商业许可证。</li>
<li><strong>需要商业许可证的常见情况包括(但不限于):</strong>
<ul class="list-disc pl-6 mt-2 space-y-1">
<li>您的组织规模超过10人。</li>
<li>(无论组织规模)您希望分发修改过的 Cherry Studio 版本,但<strong>不希望</strong>根据 AGPLv3 公开您修改部分的源代码。</li>
<li>(无论组织规模)您希望基于修改过的 Cherry Studio 提供网络服务SaaS<strong>不希望</strong>根据 AGPLv3 向服务使用者提供修改后的源代码。</li>
<li>(无论组织规模)您的公司政策、客户合同或项目要求不允许使用 AGPLv3 许可的软件,或要求闭源分发及保密。</li>
</ul>
</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
<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>
</div>
<li><strong>获取商业许可:</strong> 请通过邮箱 <a href="mailto:bd@cherry-ai.com"
class="text-blue-600 hover:underline">bd@cherry-ai.com</a> 联系 Cherry Studio 开发团队洽谈商业授权事宜。</li>
</ul>
</section>
<section class="mb-8">
<h2 class="text-xl font-semibold mb-4 text-gray-900">3. 贡献 (Contributions)</h2>
<ul class="list-disc pl-6 space-y-2 text-gray-700">
<li>我们欢迎社区对 Cherry Studio 的贡献。所有向本项目提交的贡献都将被视为在 <strong>AGPLv3</strong> 许可证下提供。</li>
<li>通过向本项目提交贡献(例如通过 Pull Request即表示您同意您的代码以 AGPLv3 许可证授权给本项目及所有后续使用者(无论这些使用者最终遵循 AGPLv3 还是商业许可)。</li>
<li>您也理解并同意,您的贡献可能会被包含在根据商业许可证分发的 Cherry Studio 版本中。</li>
</ul>
</section>
<section class="mb-8">
<h2 class="text-xl font-semibold mb-4 text-gray-900">4. 其他条款 (Other Terms)</h2>
<ul class="list-disc pl-6 space-y-2 text-gray-700">
<li>关于商业许可证的具体条款和条件,以双方签署的正式商业许可协议为准。</li>
<li>项目维护者保留根据需要更新本许可政策(包括用户规模定义和阈值)的权利。相关更新将通过项目官方渠道(如代码仓库、官方网站)进行通知。</li>
</ul>
</section>
</div>
</body>
</html>
<hr class="my-12 border-gray-300">
<!-- English Version -->
<div>
<h1 class="text-3xl font-bold mb-8 text-gray-900">Licensing</h1>
<p class="mb-6 text-gray-700">This project employs a <strong>User-Segmented Dual Licensing</strong> model.</p>
<section class="mb-8">
<h2 class="text-xl font-semibold mb-4 text-gray-900">Core Principle</h2>
<ul class="list-disc pl-6 space-y-2 text-gray-700">
<li><strong>Individual Users and Organizations with 10 or Fewer Individuals:</strong> Governed by default
under the <strong>GNU Affero General Public License v3.0 (AGPLv3)</strong>.</li>
<li><strong>Organizations with More Than 10 Individuals:</strong> <strong>Must</strong> obtain a
<strong>Commercial License</strong>.
</li>
</ul>
</section>
<section class="mb-8">
<h2 class="text-xl font-semibold mb-4 text-gray-900">Definition: "10 or Fewer Individuals"</h2>
<p class="text-gray-700">
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.
</p>
</section>
<section class="mb-8">
<h2 class="text-xl font-semibold mb-4 text-gray-900">1. Open Source License: AGPLv3 - For Individuals and
Organizations of 10 or Fewer</h2>
<ul class="list-disc pl-6 space-y-2 text-gray-700">
<li>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
<strong>AGPLv3</strong>. The full text of the AGPLv3 can be found at <a
href="https://www.gnu.org/licenses/agpl-3.0.html"
class="text-blue-600 hover:underline">https://www.gnu.org/licenses/agpl-3.0.html</a>.
</li>
<li><strong>Core Obligation:</strong> 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 <strong>complete
corresponding source code</strong> 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).</li>
<li>Please read and understand the full terms of the AGPLv3 carefully before use.</li>
</ul>
</section>
<section class="mb-8">
<h2 class="text-xl font-semibold mb-4 text-gray-900">2. Commercial License - For Organizations with More Than 10
Individuals, or Users Needing to Avoid AGPLv3 Obligations</h2>
<ul class="list-disc pl-6 space-y-2 text-gray-700">
<li><strong>Mandatory Requirement:</strong> If your organization does <strong>not</strong> meet the "10 or
Fewer Individuals" definition above (i.e., 11 or more individuals can access, use, or benefit from the
software), you <strong>must</strong> contact us to obtain and execute a Commercial License to use Cherry
Studio.</li>
<li><strong>Voluntary Option:</strong> Even if your organization meets the "10 or Fewer Individuals"
condition, if your intended use case <strong>cannot comply with the terms of the AGPLv3</strong>
(particularly the obligations regarding <strong>source code disclosure</strong>), or if you require specific
commercial terms <strong>not offered</strong> by the AGPLv3 (such as warranties, indemnities, or freedom
from copyleft restrictions), you also <strong>must</strong> contact us to obtain and execute a Commercial
License.</li>
<li><strong>Common scenarios requiring a Commercial License include (but are not limited to):</strong>
<ul class="list-disc pl-6 mt-2 space-y-1">
<li>Your organization has more than 10 individuals who can access, use, or benefit from the software.</li>
<li>(Regardless of organization size) You wish to distribute a modified version of Cherry Studio but
<strong>do not want</strong> to disclose the source code of your modifications under AGPLv3.
</li>
<li>(Regardless of organization size) You wish to provide a network service (SaaS) based on a modified
version of Cherry Studio but <strong>do not want</strong> to provide the modified source code to users
of the service under AGPLv3.</li>
<li>(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.
</li>
</ul>
</li>
<li><strong>Obtaining a Commercial License:</strong> Please contact the Cherry Studio development team via
email at <a href="mailto:bd@cherry-ai.com" class="text-blue-600 hover:underline">bd@cherry-ai.com</a> to
discuss commercial licensing options.</li>
</ul>
</section>
<section class="mb-8">
<h2 class="text-xl font-semibold mb-4 text-gray-900">3. Contributions</h2>
<ul class="list-disc pl-6 space-y-2 text-gray-700">
<li>We welcome community contributions to Cherry Studio. All contributions submitted to this project are
considered to be offered under the <strong>AGPLv3</strong> license.</li>
<li>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).</li>
<li>You also understand and agree that your contribution may be included in distributions of Cherry Studio
offered under our commercial license.</li>
</ul>
</section>
<section class="mb-8">
<h2 class="text-xl font-semibold mb-4 text-gray-900">4. Other Terms</h2>
<ul class="list-disc pl-6 space-y-2 text-gray-700">
<li>The specific terms and conditions of the Commercial License are governed by the formal commercial license
agreement signed by both parties.</li>
<li>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).</li>
</ul>
</section>
</div>
</div>
</body>
</html>

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

@@ -1,10 +1,8 @@
const { Arch } = require('electron-builder')
const { default: removeLocales } = require('./remove-locales')
const fs = require('fs')
const path = require('path')
exports.default = async function (context) {
await removeLocales(context)
const platform = context.packager.platform.name
const arch = context.arch
@@ -18,35 +16,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')
if (arch === Arch.arm64) {
removeDifferentArchNodeFiles(node_modules_path, '@strongtz', ['win32-arm64-msvc'])
removeDifferentArchNodeFiles(node_modules_path, '@libsql', ['win32-arm64-msvc'])
keepPackageNodeFiles(node_modules_path, '@strongtz', ['win32-arm64-msvc'])
keepPackageNodeFiles(node_modules_path, '@libsql', ['win32-arm64-msvc'])
}
if (arch === Arch.x64) {
removeDifferentArchNodeFiles(node_modules_path, '@strongtz', ['win32-x64-msvc'])
removeDifferentArchNodeFiles(node_modules_path, '@libsql', ['win32-x64-msvc'])
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)
}
}

157
scripts/auto-i18n.py Normal file
View File

@@ -0,0 +1,157 @@
# /// script
# requires-python = ">=3.13"
# dependencies = [
# "agno",
# "openai",
# ]
# ///
#
# Example of how to run the script:
#
# 1. First, set the OpenRouter API key environment variable:
# ```
# export OPENROUTER_API_KEY=your-api-key
# ```
#
# 2. Then run the script using uv:
# ```
# uv run i18n.py --dir src/renderer/src/i18n/locales "settings.mcp.autoDescription='auto set i18n', settings.mcp.autoName='auto set i18n name'"
# ```
import json
import argparse
from pathlib import Path
from agno.agent import Agent
from agno.models.openrouter import OpenRouter
from agno.tools import tool
LANGUAGES = ["en-us", "zh-cn", "ja-jp", "ru-ru", "zh-tw"]
def ensure_json_files_exist(output_dir=None):
"""Ensure that all language JSON files exist with at least an empty object."""
output_dir = Path(output_dir) if output_dir else Path(".")
# Create the directory if it doesn't exist
output_dir.mkdir(parents=True, exist_ok=True)
for lang in LANGUAGES:
file_path = output_dir / f"{lang}.json"
if not file_path.exists():
with open(file_path, "w") as f:
json.dump({}, f, indent=4)
def set_nested_value(data, keys, value):
"""Recursively navigate through a nested dictionary and set the value."""
if len(keys) == 1:
data[keys[0]] = value
return
key = keys[0]
if key not in data:
data[key] = {}
set_nested_value(data[key], keys[1:], value)
@tool(show_result=True, stop_after_tool_call=True)
def set_i18n(key: str, translations: dict[str, str], output_dir=None):
"""
Set i18n translations for a key in all language files.
Args:
key: The i18n key (e.g., "settings.mcp.sync.title")
translations: Dictionary with translations for different languages
output_dir: Directory to store the i18n JSON files
Example:
set_i18n("settings.mcp.hello", {
"en-us": "Hello",
"zh-cn": "你好",
"ja-jp": "こんにちは",
"ru-ru": "Привет",
"zh-tw": "你好"
})
"""
ensure_json_files_exist(output_dir)
output_dir = Path(output_dir) if output_dir else Path(".")
results = {}
keys = key.split(".")
if keys[0] != "translation":
keys = ["translation"] + keys
for lang, text in translations.items():
if lang not in LANGUAGES:
continue
file_path = output_dir / f"{lang}.json"
try:
# Load existing data
with open(file_path, "r", encoding="utf-8") as f:
try:
data = json.load(f)
except json.JSONDecodeError:
data = {}
# Set the value at the nested path
set_nested_value(data, keys, text)
# Save the updated data
with open(file_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
results[lang] = f"Updated {key} in {file_path}"
except Exception as e:
results[lang] = f"Error updating {file_path}: {str(e)}"
return results
def main():
"""Main function to run the i18n translation agent."""
# Set up command line argument parser
parser = argparse.ArgumentParser(description="Translate i18n JSON content")
parser.add_argument("content", help="JSON content to translate")
parser.add_argument(
"-m",
"--model",
default="gpt-4.1-mini",
help="Model to use for translation (default: gpt-4.1-mini)",
)
parser.add_argument(
"--dir",
default=None,
help="Directory to store i18n JSON files (default: current directory)",
)
# Parse arguments
args = parser.parse_args()
# Initialize the agent with the specified model
agent = Agent(
model=OpenRouter(id=args.model),
tools=[set_i18n],
markdown=True,
)
# Create the prompt with the provided content
prompt = f"""Please help set i18n translations for the following content to all supported languages: {LANGUAGES}.
<content>
{args.content}
</content>
Use the provided directory {args.dir} for storing the i18n JSON files.
"""
# Call the agent with the tools context that includes the output directory
agent.print_response(
prompt, stream=True, tools_context={"set_i18n": {"output_dir": args.dir}}
)
if __name__ == "__main__":
main()

View File

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

View File

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

@@ -2,3 +2,4 @@ export const isMac = process.platform === 'darwin'
export const isWin = process.platform === 'win32'
export const isLinux = process.platform === 'linux'
export const isDev = process.env.NODE_ENV === 'development'
export const isPortable = isWin && 'PORTABLE_EXECUTABLE_DIR' in process.env

View File

@@ -11,6 +11,7 @@ export default class VoyageEmbeddings extends BaseEmbeddings {
if (!this.configuration.outputDimension) {
throw new Error('You need to pass in the optional dimensions parameter for this model')
}
console.log('VoyageEmbeddings', this.configuration)
this.model = new _VoyageEmbeddings(this.configuration)
}
override async getDimensions(): Promise<number> {

View File

@@ -3,13 +3,16 @@ 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'
import { windowService } from './services/WindowService'
import { setAppDataDir } from './utils/file'
// Check for single instance lock
if (!app.requestSingleInstanceLock()) {
@@ -48,6 +51,8 @@ if (!app.requestSingleInstanceLock()) {
replaceDevtoolsFont(mainWindow)
setAppDataDir()
if (process.env.NODE_ENV === 'development') {
installExtension([REDUX_DEVTOOLS, REACT_DEVELOPER_TOOLS])
.then((name) => console.log(`Added Extension: ${name}`))
@@ -56,14 +61,10 @@ if (!app.requestSingleInstanceLock()) {
ipcMain.handle(IpcChannel.System_GetDeviceType, () => {
return process.platform === 'darwin' ? 'mac' : process.platform === 'win32' ? 'windows' : 'linux'
})
})
registerProtocolClient(app)
// macOS specific: handle protocol when app is already running
app.on('open-url', (event, url) => {
event.preventDefault()
handleProtocolUrl(url)
ipcMain.handle(IpcChannel.System_GetHostname, () => {
return require('os').hostname()
})
})
registerProtocolClient(app)
@@ -92,6 +93,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 it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -1,4 +1,5 @@
import fs from 'node:fs'
import { arch } from 'node:os'
import { isMac, isWin } from '@main/constant'
import { getBinaryPath, isBinaryExists, runInstallScript } from '@main/utils/process'
@@ -21,8 +22,10 @@ import mcpService from './services/MCPService'
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 { setOpenLinkExternal } from './services/WebviewService'
import { windowService } from './services/WindowService'
import { getResourcePath } from './utils'
import { decrypt, encrypt } from './utils/aes'
@@ -45,7 +48,9 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
configPath: getConfigDir(),
appDataPath: app.getPath('userData'),
resourcesPath: getResourcePath(),
logsPath: log.transports.file.getFile().path
logsPath: log.transports.file.getFile().path,
arch: arch(),
isPortable: isWin && 'PORTABLE_EXECUTABLE_DIR' in process.env
}))
ipcMain.handle(IpcChannel.App_Proxy, async (_, proxy: string) => {
@@ -97,6 +102,12 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
configManager.setTrayOnClose(isActive)
})
// auto update
ipcMain.handle(IpcChannel.App_SetAutoUpdate, (_, isActive: boolean) => {
appUpdater.setAutoUpdate(isActive)
configManager.setAutoUpdate(isActive)
})
ipcMain.handle(IpcChannel.App_RestartTray, () => TrayService.getInstance().restartTray())
ipcMain.handle(IpcChannel.Config_Set, (_, key: string, value: any) => {
@@ -127,6 +138,22 @@ 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(IpcChannel.App_ClearCache, async () => {
const sessions = [session.defaultSession, session.fromPartition('persist:webview')]
@@ -151,7 +178,15 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
// check for update
ipcMain.handle(IpcChannel.App_CheckForUpdate, async () => {
if (isWin && 'PORTABLE_EXECUTABLE_DIR' in process.env) {
return {
currentVersion: app.getVersion(),
updateInfo: null
}
}
const update = await appUpdater.autoUpdater.checkForUpdates()
return {
currentVersion: appUpdater.autoUpdater.currentVersion,
updateInfo: update?.updateInfo
@@ -170,6 +205,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
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(IpcChannel.File_Open, fileManager.open)
@@ -261,6 +297,10 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
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(IpcChannel.App_IsBinaryExist, (_, name: string) => isBinaryExists(name))
@@ -291,4 +331,20 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.Nutstore_GetDirectoryContents, (_, token: string, path: string) =>
NutstoreService.getDirectoryContents(token, path)
)
// search window
ipcMain.handle(IpcChannel.SearchWindow_Open, async (_, uid: string) => {
await searchService.openSearchWindow(uid)
})
ipcMain.handle(IpcChannel.SearchWindow_Close, async (_, uid: string) => {
await searchService.closeSearchWindow(uid)
})
ipcMain.handle(IpcChannel.SearchWindow_OpenUrl, async (_, uid: string, url: string) => {
return await searchService.openUrlInSearchWindow(uid, url)
})
// webview
ipcMain.handle(IpcChannel.Webview_SetOpenLinkExternal, (_, webviewId: number, isExternal: boolean) =>
setOpenLinkExternal(webviewId, isExternal)
)
}

View File

@@ -1,15 +1,14 @@
// port https://github.com/modelcontextprotocol/servers/blob/main/src/memory/index.ts
import { getConfigDir } from '@main/utils/file'
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.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'
import { fileURLToPath } from 'url'
// Define memory file path using environment variable with fallback
// Define memory file path
const defaultMemoryPath = path.join(getConfigDir(), 'memory.json')
// We are storing our memory using entities, relations, and observations in a graph structure
// Interfaces remain the same
interface Entity {
name: string
entityType: string
@@ -22,6 +21,7 @@ interface Relation {
relationType: string
}
// Structure for storing the graph in memory and in the file
interface KnowledgeGraph {
entities: Entity[]
relations: Relation[]
@@ -30,200 +30,315 @@ interface KnowledgeGraph {
// 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
constructor(memoryPath: string) {
private constructor(memoryPath: string) {
this.memoryPath = memoryPath
this.ensureMemoryPathExists()
this.entities = new Map<string, Entity>()
this.relations = new Set<string>()
this.fileMutex = new Mutex()
}
private async ensureMemoryPathExists(): Promise<void> {
// 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 {
// Ensure the directory exists
const directory = path.dirname(this.memoryPath)
await fs.mkdir(directory, { recursive: true })
// Check if the file exists, if not create an empty one
try {
await fs.access(this.memoryPath)
} catch (error) {
// File doesn't exist, create an empty file
await fs.writeFile(this.memoryPath, '')
// 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 create memory path:', 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)}`
)
}
}
private async loadGraph(): Promise<KnowledgeGraph> {
// 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')
const lines = data.split('\n').filter((line) => line.trim() !== '')
return lines.reduce(
(graph: KnowledgeGraph, line) => {
const item = JSON.parse(line)
if (item.type === 'entity') graph.entities.push(item as Entity)
if (item.type === 'relation') graph.relations.push(item as Relation)
return graph
},
{ entities: [], relations: [] }
)
// 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') {
return { entities: [], relations: [] }
// 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)}`
)
}
throw error
}
}
private async saveGraph(graph: KnowledgeGraph): Promise<void> {
const lines = [
...graph.entities.map((e) => JSON.stringify({ type: 'entity', ...e })),
...graph.relations.map((r) => JSON.stringify({ type: 'relation', ...r }))
]
await fs.writeFile(this.memoryPath, lines.join('\n'))
// 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 graph = await this.loadGraph()
const newEntities = entities.filter((e) => !graph.entities.some((existingEntity) => existingEntity.name === e.name))
graph.entities.push(...newEntities)
await this.saveGraph(graph)
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 graph = await this.loadGraph()
const newRelations = relations.filter(
(r) =>
!graph.relations.some(
(existingRelation) =>
existingRelation.from === r.from &&
existingRelation.to === r.to &&
existingRelation.relationType === r.relationType
)
)
graph.relations.push(...newRelations)
await this.saveGraph(graph)
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 graph = await this.loadGraph()
const results = observations.map((o) => {
const entity = graph.entities.find((e) => e.name === o.entityName)
const results: { entityName: string; addedObservations: string[] }[] = []
let changed = false
observations.forEach((o) => {
const entity = this.entities.get(o.entityName)
if (!entity) {
throw new Error(`Entity with name ${o.entityName} not found`)
// 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))
entity.observations.push(...newObservations)
return { entityName: o.entityName, addedObservations: newObservations }
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: [] })
}
})
await this.saveGraph(graph)
if (changed) {
await this._persistGraph()
}
return results
}
async deleteEntities(entityNames: string[]): Promise<void> {
const graph = await this.loadGraph()
graph.entities = graph.entities.filter((e) => !entityNames.includes(e.name))
graph.relations = graph.relations.filter((r) => !entityNames.includes(r.from) && !entityNames.includes(r.to))
await this.saveGraph(graph)
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> {
const graph = await this.loadGraph()
let changed = false
deletions.forEach((d) => {
const entity = graph.entities.find((e) => e.name === d.entityName)
if (entity) {
entity.observations = entity.observations.filter((o) => !d.observations.includes(o))
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
}
}
})
await this.saveGraph(graph)
if (changed) {
await this._persistGraph()
}
}
async deleteRelations(relations: Relation[]): Promise<void> {
const graph = await this.loadGraph()
graph.relations = graph.relations.filter(
(r) =>
!relations.some(
(delRelation) =>
r.from === delRelation.from && r.to === delRelation.to && r.relationType === delRelation.relationType
)
)
await this.saveGraph(graph)
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 this.loadGraph()
// 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))
})
)
}
// Very basic search function
// Search operates on the in-memory graph
async searchNodes(query: string): Promise<KnowledgeGraph> {
const graph = await this.loadGraph()
// Filter entities
const filteredEntities = graph.entities.filter(
const lowerCaseQuery = query.toLowerCase()
const filteredEntities = Array.from(this.entities.values()).filter(
(e) =>
e.name.toLowerCase().includes(query.toLowerCase()) ||
e.entityType.toLowerCase().includes(query.toLowerCase()) ||
e.observations.some((o) => o.toLowerCase().includes(query.toLowerCase()))
e.name.toLowerCase().includes(lowerCaseQuery) ||
e.entityType.toLowerCase().includes(lowerCaseQuery) ||
(Array.isArray(e.observations) && e.observations.some((o) => o.toLowerCase().includes(lowerCaseQuery)))
)
// Create a Set of filtered entity names for quick lookup
const filteredEntityNames = new Set(filteredEntities.map((e) => e.name))
// Filter relations to only include those between filtered entities
const filteredRelations = graph.relations.filter(
(r) => filteredEntityNames.has(r.from) && filteredEntityNames.has(r.to)
)
const filteredRelations = Array.from(this.relations)
.map((rStr) => this._deserializeRelation(rStr))
.filter((r) => filteredEntityNames.has(r.from) && filteredEntityNames.has(r.to))
const filteredGraph: KnowledgeGraph = {
return {
entities: filteredEntities,
relations: filteredRelations
}
return filteredGraph
}
// Open operates on the in-memory graph
async openNodes(names: string[]): Promise<KnowledgeGraph> {
const graph = await this.loadGraph()
// Filter entities
const filteredEntities = graph.entities.filter((e) => names.includes(e.name))
// Create a Set of filtered entity names for quick lookup
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))
// Filter relations to only include those between filtered entities
const filteredRelations = graph.relations.filter(
(r) => filteredEntityNames.has(r.from) && filteredEntityNames.has(r.to)
)
const filteredRelations = Array.from(this.relations)
.map((rStr) => this._deserializeRelation(rStr))
.filter((r) => filteredEntityNames.has(r.from) && filteredEntityNames.has(r.to))
const filteredGraph: KnowledgeGraph = {
return {
entities: filteredEntities,
relations: filteredRelations
}
return filteredGraph
}
}
class MemoryServer {
public server: Server
private knowledgeGraphManager: KnowledgeGraphManager
// 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.join(path.dirname(fileURLToPath(import.meta.url)), envPath)
: path.resolve(envPath) // Use path.resolve for relative paths based on CWD
: defaultMemoryPath
this.knowledgeGraphManager = new KnowledgeGraphManager(memoryPath)
this.server = new Server(
{
name: 'memory-server',
version: '1.0.0'
version: '1.1.0' // Incremented version for changes
},
{
capabilities: {
@@ -231,17 +346,53 @@ class MemoryServer {
}
}
)
this.initialize()
// Start initialization, but don't block constructor
this.initializationPromise = this._initializeManager(memoryPath)
this.setupRequestHandlers() // Setup handlers immediately
}
initialize() {
// The server instance and tools exposed to Claude
// 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',
description: 'Create multiple new entities in the knowledge graph. Skips existing entities.',
inputSchema: {
type: 'object',
properties: {
@@ -255,10 +406,11 @@ class MemoryServer {
observations: {
type: 'array',
items: { type: 'string' },
description: 'An array of observation contents associated with the entity'
description: 'An array of observation contents associated with the entity',
default: [] // Add default empty array
}
},
required: ['name', 'entityType', 'observations']
required: ['name', 'entityType'] // Observations are optional now on creation
}
}
},
@@ -268,7 +420,7 @@ class MemoryServer {
{
name: 'create_relations',
description:
'Create multiple new relations between entities in the knowledge graph. Relations should be in active voice',
'Create multiple new relations between EXISTING entities. Skips existing relations or relations with non-existent entities.',
inputSchema: {
type: 'object',
properties: {
@@ -290,7 +442,7 @@ class MemoryServer {
},
{
name: 'add_observations',
description: 'Add new observations to existing entities in the knowledge graph',
description: 'Add new observations to existing entities. Skips duplicate observations.',
inputSchema: {
type: 'object',
properties: {
@@ -315,7 +467,7 @@ class MemoryServer {
},
{
name: 'delete_entities',
description: 'Delete multiple entities and their associated relations from the knowledge graph',
description: 'Delete multiple entities and their associated relations.',
inputSchema: {
type: 'object',
properties: {
@@ -330,7 +482,7 @@ class MemoryServer {
},
{
name: 'delete_observations',
description: 'Delete specific observations from entities in the knowledge graph',
description: 'Delete specific observations from entities.',
inputSchema: {
type: 'object',
properties: {
@@ -355,7 +507,7 @@ class MemoryServer {
},
{
name: 'delete_relations',
description: 'Delete multiple relations from the knowledge graph',
description: 'Delete multiple specific relations.',
inputSchema: {
type: 'object',
properties: {
@@ -378,7 +530,7 @@ class MemoryServer {
},
{
name: 'read_graph',
description: 'Read the entire knowledge graph',
description: 'Read the entire knowledge graph from memory.',
inputSchema: {
type: 'object',
properties: {}
@@ -386,7 +538,7 @@ class MemoryServer {
},
{
name: 'search_nodes',
description: 'Search for nodes in the knowledge graph based on a query',
description: 'Search nodes (entities and relations) in memory based on a query.',
inputSchema: {
type: 'object',
properties: {
@@ -400,7 +552,7 @@ class MemoryServer {
},
{
name: 'open_nodes',
description: 'Open specific nodes in the knowledge graph by their names',
description: 'Retrieve specific entities and their connecting relations from memory by name.',
inputSchema: {
type: 'object',
properties: {
@@ -417,90 +569,129 @@ class MemoryServer {
}
})
// 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) {
throw new Error(`No arguments provided for tool: ${name}`)
// Use McpError for standard errors
throw new McpError(ErrorCode.InvalidParams, `No arguments provided for tool: ${name}`)
}
switch (name) {
case 'create_entities':
return {
content: [
{
type: 'text',
text: JSON.stringify(
await this.knowledgeGraphManager.createEntities(args.entities as Entity[]),
null,
2
)
}
]
}
case 'create_relations':
return {
content: [
{
type: 'text',
text: JSON.stringify(
await this.knowledgeGraphManager.createRelations(args.relations as Relation[]),
null,
2
)
}
]
}
case 'add_observations':
return {
content: [
{
type: 'text',
text: JSON.stringify(
await this.knowledgeGraphManager.addObservations(
args.observations as { entityName: string; contents: string[] }[]
),
null,
2
)
}
]
}
case 'delete_entities':
await this.knowledgeGraphManager.deleteEntities(args.entityNames as string[])
return { content: [{ type: 'text', text: 'Entities deleted successfully' }] }
case 'delete_observations':
await this.knowledgeGraphManager.deleteObservations(
args.deletions as { entityName: string; observations: string[] }[]
)
return { content: [{ type: 'text', text: 'Observations deleted successfully' }] }
case 'delete_relations':
await this.knowledgeGraphManager.deleteRelations(args.relations as Relation[])
return { content: [{ type: 'text', text: 'Relations deleted successfully' }] }
case 'read_graph':
return {
content: [{ type: 'text', text: JSON.stringify(await this.knowledgeGraphManager.readGraph(), null, 2) }]
}
case 'search_nodes':
return {
content: [
{
type: 'text',
text: JSON.stringify(await this.knowledgeGraphManager.searchNodes(args.query as string), null, 2)
}
]
}
case 'open_nodes':
return {
content: [
{
type: 'text',
text: JSON.stringify(await this.knowledgeGraphManager.openNodes(args.names as string[]), null, 2)
}
]
}
default:
throw new Error(`Unknown 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)}`
)
}
})
}

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'
@@ -20,7 +20,7 @@ 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
return this.getRerankResult(searchResults, rerankResults)

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'
@@ -22,7 +22,7 @@ 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
return this.getRerankResult(searchResults, rerankResults)

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'
@@ -22,7 +22,7 @@ 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()
}

View File

@@ -5,6 +5,7 @@ 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
@@ -15,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) => {
@@ -53,6 +55,11 @@ export default class AppUpdater {
this.autoUpdater = autoUpdater
}
public setAutoUpdate(isActive: boolean) {
autoUpdater.autoDownload = isActive
autoUpdater.autoInstallOnAppQuit = isActive
}
public async showUpdateDialog(mainWindow: BrowserWindow) {
if (!this.releaseInfo) {
return

View File

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

View File

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

@@ -14,7 +14,9 @@ enum ConfigKeys {
ZoomFactor = 'ZoomFactor',
Shortcuts = 'shortcuts',
ClickTrayToShowQuickAssistant = 'clickTrayToShowQuickAssistant',
EnableQuickAssistant = 'enableQuickAssistant'
EnableQuickAssistant = 'enableQuickAssistant',
AutoUpdate = 'autoUpdate',
EnableDataCollection = 'enableDataCollection'
}
export class ConfigManager {
@@ -42,6 +44,14 @@ export class ConfigManager {
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.get(ConfigKeys.LaunchToTray, false)
}
@@ -128,6 +138,22 @@ export class ConfigManager {
this.set(ConfigKeys.EnableQuickAssistant, value)
}
getAutoUpdate(): boolean {
return this.get<boolean>(ConfigKeys.AutoUpdate, true)
}
setAutoUpdate(value: boolean) {
this.set(ConfigKeys.AutoUpdate, value)
}
getEnableDataCollection(): boolean {
return this.get<boolean>(ConfigKeys.EnableDataCollection, true)
}
setEnableDataCollection(value: boolean) {
this.set(ConfigKeys.EnableDataCollection, value)
}
set(key: string, value: unknown) {
this.store.set(key, value)
}

View File

@@ -1,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,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

@@ -1,3 +1,5 @@
import crypto from 'node:crypto'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
@@ -6,16 +8,65 @@ 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 {
StreamableHTTPClientTransport,
type StreamableHTTPClientTransportOptions
} from '@modelcontextprotocol/sdk/client/streamableHttp'
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 { StreamableHTTPClientTransport, type StreamableHTTPClientTransportOptions } from './MCPStreamableHttpClient'
import { CallBackServer } from './mcp/oauth/callback'
import { McpOAuthClientProvider } from './mcp/oauth/provider'
// 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()
@@ -35,10 +86,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> {
@@ -68,9 +124,17 @@ class McpService {
const args = [...(server.args || [])]
let transport: StdioClientTransport | SSEClientTransport | InMemoryTransport | StreamableHTTPClientTransport
// 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.type === 'inMemory') {
Logger.info(`[MCP] Using in-memory transport for server: ${server.name}`)
@@ -80,27 +144,39 @@ class McpService {
try {
await inMemoryServer.connect(serverTransport)
Logger.info(`[MCP] In-memory server started: ${server.name}`)
} catch (error) {
} catch (error: Error | any) {
Logger.error(`[MCP] Error starting in-memory server: ${error}`)
throw new Error(`Failed to start in-memory server: ${error}`)
throw new Error(`Failed to start in-memory server: ${error.message}`)
}
// set the client transport to the client
transport = clientTransport
return clientTransport
} else if (server.baseUrl) {
if (server.type === 'streamableHttp') {
transport = new StreamableHTTPClientTransport(
new URL(server.baseUrl!),
{} as StreamableHTTPClientTransportOptions
)
const options: StreamableHTTPClientTransportOptions = {
requestInit: {
headers: server.headers || {}
},
authProvider
}
return new StreamableHTTPClientTransport(new URL(server.baseUrl!), options)
} else if (server.type === 'sse') {
transport = new SSEClientTransport(new URL(server.baseUrl!))
const options: SSEClientTransportOptions = {
eventSourceInit: {
fetch: (url, init) => fetch(url, { ...init, headers: server.headers || {} })
},
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}`)
@@ -140,20 +216,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)
@@ -162,7 +300,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}`)
}
}
@@ -201,31 +339,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)
}
/**
@@ -234,12 +391,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
@@ -255,13 +412,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 || ''
// 定义要添加的新路径
@@ -320,4 +707,5 @@ class McpService {
}
}
export default new McpService()
const mcpService = new McpService()
export default mcpService

View File

@@ -1,365 +0,0 @@
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

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

@@ -0,0 +1,35 @@
import { session, shell, webContents } from 'electron'
/**
* init the useragent of the webview session
* remove the CherryStudio and Electron from the useragent
*/
export function initSessionUserAgent() {
const wvSession = session.fromPartition('persist:webview')
const newChromeVersion = '135.0.7049.96'
const originUA = wvSession.getUserAgent()
const newUA = originUA
.replace(/CherryStudio\/\S+\s/, '')
.replace(/Electron\/\S+\s/, '')
.replace(/Chrome\/\d+\.\d+\.\d+\.\d+/, `Chrome/${newChromeVersion}`)
wvSession.setUserAgent(newUA)
}
/**
* WebviewService handles the behavior of links opened from webview elements
* It controls whether links should be opened within the application or in an external browser
*/
export function setOpenLinkExternal(webviewId: number, isExternal: boolean) {
const webview = webContents.fromId(webviewId)
if (!webview) return
webview.setWindowOpenHandler(({ url }) => {
if (isExternal) {
shell.openExternal(url)
return { action: 'deny' }
} else {
return { action: 'allow' }
}
})
}

View File

@@ -11,13 +11,13 @@ import icon from '../../../build/icon.png?asset'
import { titleBarOverlayDark, titleBarOverlayLight } from '../config'
import { locales } from '../utils/locales'
import { configManager } from './ConfigManager'
import { initSessionUserAgent } from './WebviewService'
export class WindowService {
private static instance: WindowService | null = null
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
@@ -41,7 +41,9 @@ export class WindowService {
const mainWindowState = windowStateKeeper({
defaultWidth: 1080,
defaultHeight: 670
defaultHeight: 670,
fullScreen: false,
maximize: false
})
const theme = configManager.getTheme()
@@ -53,7 +55,7 @@ export class WindowService {
height: mainWindowState.height,
minWidth: 1080,
minHeight: 600,
show: false, // 初始不显示
show: false,
autoHideMenuBar: true,
transparent: isMac,
vibrancy: 'sidebar',
@@ -80,12 +82,16 @@ export class WindowService {
this.miniWindow = this.createMiniWindow(true)
}
//init the MinApp webviews' useragent
initSessionUserAgent()
return this.mainWindow
}
private setupMainWindow(mainWindow: BrowserWindow, mainWindowState: any) {
mainWindowState.manage(mainWindow)
this.setupMaximize(mainWindow, mainWindowState.isMaximized)
this.setupContextMenu(mainWindow)
this.setupWindowEvents(mainWindow)
this.setupWebContentsHandlers(mainWindow)
@@ -93,6 +99,17 @@ export class WindowService {
this.loadMainWindowContent(mainWindow)
}
private setupMaximize(mainWindow: BrowserWindow, isMaximized: boolean) {
if (isMaximized) {
// 如果是从托盘启动,则需要延迟最大化,否则显示的就不是重启前的最大化窗口了
configManager.getLaunchToTray()
? mainWindow.once('show', () => {
mainWindow.maximize()
})
: mainWindow.maximize()
}
}
private setupContextMenu(mainWindow: BrowserWindow) {
if (!this.contextMenu) {
const locale = locales[configManager.getLanguage()]
@@ -138,12 +155,10 @@ export class WindowService {
// 处理全屏相关事件
mainWindow.on('enter-full-screen', () => {
this.wasFullScreen = true
mainWindow.webContents.send(IpcChannel.FullscreenStatusChanged, true)
})
mainWindow.on('leave-full-screen', () => {
this.wasFullScreen = false
mainWindow.webContents.send(IpcChannel.FullscreenStatusChanged, false)
})
@@ -193,9 +208,11 @@ export class WindowService {
const oauthProviderUrls = [
'https://account.siliconflow.cn/oauth',
'https://cloud.siliconflow.cn/bills',
'https://cloud.siliconflow.cn/expensebill',
'https://aihubmix.com/token',
'https://aihubmix.com/topup'
'https://aihubmix.com/topup',
'https://aihubmix.com/statistics'
]
if (oauthProviderUrls.some((link) => url.startsWith(link))) {
@@ -245,6 +262,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'))
}
@@ -274,19 +292,14 @@ 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
@@ -316,16 +329,33 @@ 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
// [Linux] Known Issue
// setVisibleOnAllWorkspaces 在 Linux 环境下(特别是 KDE Wayland会导致窗口进入"假弹出"状态
// 因此在 Linux 环境下不执行这两行代码
/**
* 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()
if (!isLinux) {
@@ -338,7 +368,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
}
@@ -392,7 +424,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) {

View File

@@ -0,0 +1,76 @@
import Logger from 'electron-log'
import EventEmitter from 'events'
import http from 'http'
import { URL } from 'url'
import { OAuthCallbackServerOptions } from './types'
export class CallBackServer {
private server: Promise<http.Server>
private events: EventEmitter
constructor(options: OAuthCallbackServerOptions) {
const { port, path, events } = options
this.events = events
this.server = this.initialize(port, path)
}
initialize(port: number, path: string): Promise<http.Server> {
const server = http.createServer((req, res) => {
// Only handle requests to the callback path
if (req.url?.startsWith(path)) {
try {
// Parse the URL to extract the authorization code
const url = new URL(req.url, `http://localhost:${port}`)
const code = url.searchParams.get('code')
if (code) {
// Emit the code event
this.events.emit('auth-code-received', code)
}
} catch (error) {
Logger.error('Error processing OAuth callback:', error)
res.writeHead(500, { 'Content-Type': 'text/plain' })
res.end('Internal Server Error')
}
} else {
// Not a callback request
res.writeHead(404, { 'Content-Type': 'text/plain' })
res.end('Not Found')
}
})
// Handle server errors
server.on('error', (error) => {
Logger.error('OAuth callback server error:', error)
})
const runningServer = new Promise<http.Server>((resolve, reject) => {
server.listen(port, () => {
Logger.info(`OAuth callback server listening on port ${port}`)
resolve(server)
})
server.on('error', (error) => {
reject(error)
})
})
return runningServer
}
get getServer(): Promise<http.Server> {
return this.server
}
async close() {
const server = await this.server
server.close()
}
async waitForAuthCode(): Promise<string> {
return new Promise((resolve) => {
this.events.once('auth-code-received', (code) => {
resolve(code)
})
})
}
}

View File

@@ -0,0 +1,78 @@
import path from 'node:path'
import { getConfigDir } from '@main/utils/file'
import { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth'
import { OAuthClientInformation, OAuthClientInformationFull, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth'
import Logger from 'electron-log'
import open from 'open'
import { JsonFileStorage } from './storage'
import { OAuthProviderOptions } from './types'
export class McpOAuthClientProvider implements OAuthClientProvider {
private storage: JsonFileStorage
public readonly config: Required<OAuthProviderOptions>
constructor(options: OAuthProviderOptions) {
const configDir = path.join(getConfigDir(), 'mcp', 'oauth')
this.config = {
serverUrlHash: options.serverUrlHash,
callbackPort: options.callbackPort || 12346,
callbackPath: options.callbackPath || '/oauth/callback',
configDir: options.configDir || configDir,
clientName: options.clientName || 'Cherry Studio',
clientUri: options.clientUri || 'https://github.com/CherryHQ/cherry-studio'
}
this.storage = new JsonFileStorage(this.config.serverUrlHash, this.config.configDir)
}
get redirectUrl(): string {
return `http://localhost:${this.config.callbackPort}${this.config.callbackPath}`
}
get clientMetadata() {
return {
redirect_uris: [this.redirectUrl],
token_endpoint_auth_method: 'none',
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
client_name: this.config.clientName,
client_uri: this.config.clientUri
}
}
async clientInformation(): Promise<OAuthClientInformation | undefined> {
return this.storage.getClientInformation()
}
async saveClientInformation(info: OAuthClientInformationFull): Promise<void> {
await this.storage.saveClientInformation(info)
}
async tokens(): Promise<OAuthTokens | undefined> {
return this.storage.getTokens()
}
async saveTokens(tokens: OAuthTokens): Promise<void> {
await this.storage.saveTokens(tokens)
}
async redirectToAuthorization(authorizationUrl: URL): Promise<void> {
try {
// Open the browser to the authorization URL
await open(authorizationUrl.toString())
Logger.info('Browser opened automatically.')
} catch (error) {
Logger.error('Could not open browser automatically.')
throw error // Let caller handle the error
}
}
async saveCodeVerifier(codeVerifier: string): Promise<void> {
await this.storage.saveCodeVerifier(codeVerifier)
}
async codeVerifier(): Promise<string> {
return this.storage.getCodeVerifier()
}
}

View File

@@ -0,0 +1,120 @@
import {
OAuthClientInformation,
OAuthClientInformationFull,
OAuthTokens
} from '@modelcontextprotocol/sdk/shared/auth.js'
import Logger from 'electron-log'
import fs from 'fs/promises'
import path from 'path'
import { IOAuthStorage, OAuthStorageData, OAuthStorageSchema } from './types'
export class JsonFileStorage implements IOAuthStorage {
private readonly filePath: string
private cache: OAuthStorageData | null = null
constructor(
readonly serverUrlHash: string,
configDir: string
) {
this.filePath = path.join(configDir, `${serverUrlHash}_oauth.json`)
}
private async readStorage(): Promise<OAuthStorageData> {
if (this.cache) {
return this.cache
}
try {
const data = await fs.readFile(this.filePath, 'utf-8')
const parsed = JSON.parse(data)
const validated = OAuthStorageSchema.parse(parsed)
this.cache = validated
return validated
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
// File doesn't exist, return initial state
const initial: OAuthStorageData = { lastUpdated: Date.now() }
await this.writeStorage(initial)
return initial
}
Logger.error('Error reading OAuth storage:', error)
throw new Error(`Failed to read OAuth storage: ${error instanceof Error ? error.message : String(error)}`)
}
}
private async writeStorage(data: OAuthStorageData): Promise<void> {
try {
// Ensure directory exists
await fs.mkdir(path.dirname(this.filePath), { recursive: true })
// Update timestamp
data.lastUpdated = Date.now()
// Write file atomically
const tempPath = `${this.filePath}.tmp`
await fs.writeFile(tempPath, JSON.stringify(data, null, 2))
await fs.rename(tempPath, this.filePath)
// Update cache
this.cache = data
} catch (error) {
Logger.error('Error writing OAuth storage:', error)
throw new Error(`Failed to write OAuth storage: ${error instanceof Error ? error.message : String(error)}`)
}
}
async getClientInformation(): Promise<OAuthClientInformation | undefined> {
const data = await this.readStorage()
return data.clientInfo
}
async saveClientInformation(info: OAuthClientInformationFull): Promise<void> {
const data = await this.readStorage()
await this.writeStorage({
...data,
clientInfo: info
})
}
async getTokens(): Promise<OAuthTokens | undefined> {
const data = await this.readStorage()
return data.tokens
}
async saveTokens(tokens: OAuthTokens): Promise<void> {
const data = await this.readStorage()
await this.writeStorage({
...data,
tokens
})
}
async getCodeVerifier(): Promise<string> {
const data = await this.readStorage()
if (!data.codeVerifier) {
throw new Error('No code verifier saved for session')
}
return data.codeVerifier
}
async saveCodeVerifier(codeVerifier: string): Promise<void> {
const data = await this.readStorage()
await this.writeStorage({
...data,
codeVerifier
})
}
async clear(): Promise<void> {
try {
await fs.unlink(this.filePath)
this.cache = null
} catch (error) {
if (error instanceof Error && 'code' in error && error.code !== 'ENOENT') {
Logger.error('Error clearing OAuth storage:', error)
throw new Error(`Failed to clear OAuth storage: ${error instanceof Error ? error.message : String(error)}`)
}
}
}
}

View File

@@ -0,0 +1,61 @@
import {
OAuthClientInformation,
OAuthClientInformationFull,
OAuthTokens
} from '@modelcontextprotocol/sdk/shared/auth.js'
import EventEmitter from 'events'
import { z } from 'zod'
export interface OAuthStorageData {
clientInfo?: OAuthClientInformation
tokens?: OAuthTokens
codeVerifier?: string
lastUpdated: number
}
export const OAuthStorageSchema = z.object({
clientInfo: z.any().optional(),
tokens: z.any().optional(),
codeVerifier: z.string().optional(),
lastUpdated: z.number()
})
export interface IOAuthStorage {
getClientInformation(): Promise<OAuthClientInformation | undefined>
saveClientInformation(info: OAuthClientInformationFull): Promise<void>
getTokens(): Promise<OAuthTokens | undefined>
saveTokens(tokens: OAuthTokens): Promise<void>
getCodeVerifier(): Promise<string>
saveCodeVerifier(codeVerifier: string): Promise<void>
clear(): Promise<void>
}
/**
* OAuth callback server setup options
*/
export interface OAuthCallbackServerOptions {
/** Port for the callback server */
port: number
/** Path for the callback endpoint */
path: string
/** Event emitter to signal when auth code is received */
events: EventEmitter
}
/**
* Options for creating an OAuth client provider
*/
export interface OAuthProviderOptions {
/** Server URL to connect to */
serverUrlHash: string
/** Port for the OAuth callback server */
callbackPort?: number
/** Path for the OAuth callback endpoint */
callbackPath?: string
/** Directory to store OAuth credentials */
configDir?: string
/** Client name to use for OAuth registration */
clientName?: string
/** Client URI to use for OAuth registration */
clientUri?: string
}

View File

@@ -2,6 +2,7 @@ import * as fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { isPortable } from '@main/constant'
import { audioExts, documentExts, imageExts, textExts, videoExts } from '@shared/config/constant'
import { FileType, FileTypes } from '@types'
import { app } from 'electron'
@@ -79,3 +80,16 @@ export function getFilesDir() {
export function getConfigDir() {
return path.join(os.homedir(), '.cherrystudio', 'config')
}
export function getAppConfigDir(name: string) {
return path.join(getConfigDir(), name)
}
export function setAppDataDir() {
if (isPortable) {
const dir = path.join(path.dirname(app.getPath('exe')), 'data')
if (fs.existsSync(dir)) {
app.setPath('appData', dir)
}
}
}

View File

@@ -1,7 +1,7 @@
import { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
import { ElectronAPI } from '@electron-toolkit/preload'
import type { FileMetadataResponse, ListFilesResponse, UploadFileResponse } from '@google/generative-ai/server'
import type { MCPServer, MCPTool } from '@renderer/types'
import type { File } from '@google/genai'
import type { GetMCPPromptResponse, MCPPrompt, MCPResource, MCPServer, MCPTool } from '@renderer/types'
import { AppInfo, FileType, KnowledgeBaseParams, KnowledgeItem, LanguageVarious, WebDavConfig } from '@renderer/types'
import type { LoaderReturn } from '@shared/config/types'
import type { OpenDialogOptions } from 'electron'
@@ -29,10 +29,13 @@ declare global {
setTrayOnClose: (isActive: boolean) => void
restartTray: () => void
setTheme: (theme: 'light' | 'dark') => void
setCustomCss: (css: string) => void
setAutoUpdate: (isActive: boolean) => void
reload: () => void
clearCache: () => Promise<{ success: boolean; error?: string }>
system: {
getDeviceType: () => Promise<'mac' | 'windows' | 'linux'>
getHostname: () => Promise<string>
}
zip: {
compress: (text: string) => Promise<Buffer>
@@ -46,6 +49,7 @@ declare global {
listWebdavFiles: (webdavConfig: WebDavConfig) => Promise<BackupFile[]>
checkConnection: (webdavConfig: WebDavConfig) => Promise<boolean>
createDirectory: (webdavConfig: WebDavConfig, path: string, options?: CreateDirectoryOptions) => Promise<void>
deleteWebdavFile: (fileName: string, webdavConfig: WebDavConfig) => Promise<boolean>
}
file: {
select: (options?: OpenDialogOptions) => Promise<FileType[] | null>
@@ -118,11 +122,11 @@ declare global {
resetMinimumSize: () => Promise<void>
}
gemini: {
uploadFile: (file: FileType, apiKey: string) => Promise<UploadFileResponse>
retrieveFile: (file: FileType, apiKey: string) => Promise<FileMetadataResponse | undefined>
uploadFile: (file: FileType, apiKey: string) => Promise<File>
retrieveFile: (file: FileType, apiKey: string) => Promise<File | undefined>
base64File: (file: FileType) => Promise<{ data: string; mimeType: string }>
listFiles: (apiKey: string) => Promise<ListFilesResponse>
deleteFile: (apiKey: string, fileId: string) => Promise<void>
listFiles: (apiKey: string) => Promise<File[]>
deleteFile: (fileId: string, apiKey: string) => Promise<void>
}
selectionMenu: {
action: (action: string) => Promise<void>
@@ -150,7 +154,27 @@ declare global {
restartServer: (server: MCPServer) => Promise<void>
stopServer: (server: MCPServer) => Promise<void>
listTools: (server: MCPServer) => Promise<MCPTool[]>
callTool: ({ server, name, args }: { server: MCPServer; name: string; args: any }) => Promise<any>
callTool: ({
server,
name,
args
}: {
server: MCPServer
name: string
args: any
}) => Promise<MCPCallToolResponse>
listPrompts: (server: MCPServer) => Promise<MCPPrompt[]>
getPrompt: ({
server,
name,
args
}: {
server: MCPServer
name: string
args?: Record<string, any>
}) => Promise<GetMCPPromptResponse>
listResources: (server: MCPServer) => Promise<MCPResource[]>
getResource: ({ server, uri }: { server: MCPServer; uri: string }) => Promise<GetResourceResponse>
getInstallInfo: () => Promise<{ dir: string; uvPath: string; bunPath: string }>
}
copilot: {
@@ -175,6 +199,14 @@ declare global {
decryptToken: (token: string) => Promise<{ username: string; access_token: string }>
getDirectoryContents: (token: string, path: string) => Promise<any>
}
searchService: {
openSearchWindow: (uid: string) => Promise<string>
closeSearchWindow: (uid: string) => Promise<string>
openUrlInSearchWindow: (uid: string, url: string) => Promise<string>
}
webview: {
setOpenLinkExternal: (webviewId: number, isExternal: boolean) => Promise<void>
}
}
}
}

View File

@@ -19,10 +19,13 @@ const api = {
setTrayOnClose: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetTrayOnClose, isActive),
restartTray: () => ipcRenderer.invoke(IpcChannel.App_RestartTray),
setTheme: (theme: 'light' | 'dark') => ipcRenderer.invoke(IpcChannel.App_SetTheme, theme),
setCustomCss: (css: string) => ipcRenderer.invoke(IpcChannel.App_SetCustomCss, css),
setAutoUpdate: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetAutoUpdate, isActive),
openWebsite: (url: string) => ipcRenderer.invoke(IpcChannel.Open_Website, url),
clearCache: () => ipcRenderer.invoke(IpcChannel.App_ClearCache),
system: {
getDeviceType: () => ipcRenderer.invoke(IpcChannel.System_GetDeviceType)
getDeviceType: () => ipcRenderer.invoke(IpcChannel.System_GetDeviceType),
getHostname: () => ipcRenderer.invoke(IpcChannel.System_GetHostname)
},
zip: {
compress: (text: string) => ipcRenderer.invoke(IpcChannel.Zip_Compress, text),
@@ -41,7 +44,9 @@ const api = {
checkConnection: (webdavConfig: WebDavConfig) =>
ipcRenderer.invoke(IpcChannel.Backup_CheckConnection, webdavConfig),
createDirectory: (webdavConfig: WebDavConfig, path: string, options?: CreateDirectoryOptions) =>
ipcRenderer.invoke(IpcChannel.Backup_CreateDirectory, webdavConfig, path, options)
ipcRenderer.invoke(IpcChannel.Backup_CreateDirectory, webdavConfig, path, options),
deleteWebdavFile: (fileName: string, webdavConfig: WebDavConfig) =>
ipcRenderer.invoke(IpcChannel.Backup_DeleteWebdavFile, fileName, webdavConfig)
},
file: {
select: (options?: OpenDialogOptions) => ipcRenderer.invoke(IpcChannel.File_Select, options),
@@ -130,8 +135,14 @@ const api = {
restartServer: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_RestartServer, server),
stopServer: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_StopServer, server),
listTools: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_ListTools, server),
callTool: ({ server, name, args }: { server: MCPServer; name: string; args: any }) =>
callTool: ({ server, name, args }: { server: MCPServer; name: string; args?: Record<string, any> }) =>
ipcRenderer.invoke(IpcChannel.Mcp_CallTool, { server, name, args }),
listPrompts: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_ListPrompts, server),
getPrompt: ({ server, name, args }: { server: MCPServer; name: string; args?: Record<string, any> }) =>
ipcRenderer.invoke(IpcChannel.Mcp_GetPrompt, { server, name, args }),
listResources: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_ListResources, server),
getResource: ({ server, uri }: { server: MCPServer; uri: string }) =>
ipcRenderer.invoke(IpcChannel.Mcp_GetResource, { server, uri }),
getInstallInfo: () => ipcRenderer.invoke(IpcChannel.Mcp_GetInstallInfo)
},
shell: {
@@ -169,6 +180,15 @@ const api = {
decryptToken: (token: string) => ipcRenderer.invoke(IpcChannel.Nutstore_DecryptToken, token),
getDirectoryContents: (token: string, path: string) =>
ipcRenderer.invoke(IpcChannel.Nutstore_GetDirectoryContents, token, path)
},
searchService: {
openSearchWindow: (uid: string) => ipcRenderer.invoke(IpcChannel.SearchWindow_Open, uid),
closeSearchWindow: (uid: string) => ipcRenderer.invoke(IpcChannel.SearchWindow_Close, uid),
openUrlInSearchWindow: (uid: string, url: string) => ipcRenderer.invoke(IpcChannel.SearchWindow_OpenUrl, uid, url)
},
webview: {
setOpenLinkExternal: (webviewId: number, isExternal: boolean) =>
ipcRenderer.invoke(IpcChannel.Webview_SetOpenLinkExternal, webviewId, isExternal)
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="_图层_2" data-name="图层_2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 198.45 66.73">
<defs>
<style>
.cls-1 {
fill: #ea5e5d;
}
.cls-2 {
fill: #23af69;
}
.cls-3 {
fill: #ea5756;
}
</style>
</defs>
<g id="_图层_1-2" data-name="图层_1">
<g>
<g>
<g>
<path class="cls-1" d="M16.72,51.21c-4.45,0-8.64-1.78-11.81-5.01-3.17-3.23-4.91-7.51-4.91-12.04s1.74-8.81,4.91-12.04,7.36-5.01,11.81-5.01,8.71,1.82,11.82,4.99c2.32,2.36,2.32,6.2,0,8.56-2.32,2.36-6.08,2.36-8.4,0-.9-.92-2.15-1.45-3.43-1.45-2.63,0-4.85,2.26-4.85,4.94s2.22,4.94,4.85,4.94c1.28,0,2.52-.53,3.43-1.45,2.32-2.36,6.08-2.36,8.4,0,2.32,2.36,2.32,6.2,0,8.56-3.11,3.17-7.42,4.99-11.82,4.99Z"/>
<path class="cls-1" d="M32.05,66.73c-4.45,0-8.64-1.78-11.81-5.01s-4.91-7.51-4.91-12.04,1.79-8.88,4.9-12.06c2.32-2.36,6.08-2.36,8.4,0,2.32,2.36,2.32,6.2,0,8.56-.9.92-1.42,2.19-1.42,3.49,0,2.68,2.22,4.94,4.85,4.94s4.85-2.26,4.85-4.94c0-.95-.23-2.31-1.32-3.43-3.13-3.19-4.92-7.6-4.92-12.09s1.74-8.81,4.91-12.04,7.36-5.01,11.81-5.01,8.64,1.78,11.81,5.01,4.91,7.51,4.91,12.04-1.79,8.88-4.9,12.06c-2.32,2.36-6.08,2.36-8.4,0-2.32-2.36-2.32-6.2,0-8.56.9-.92,1.42-2.19,1.42-3.49,0-2.68-2.22-4.94-4.85-4.94s-4.85,2.26-4.85,4.94c0,1.31.53,2.6,1.45,3.53,3.1,3.16,4.8,7.42,4.8,11.99s-1.74,8.81-4.91,12.04c-3.17,3.23-7.36,5.01-11.81,5.01Z"/>
</g>
<path class="cls-2" d="M32.05,19.09l-9.72-9.12c-1.5-1.4-1.57-3.75-.17-5.25,1.4-1.49,3.75-1.57,5.25-.17l3.89,3.65,5.53-6.83c1.29-1.59,3.63-1.84,5.22-.55,1.59,1.29,1.84,3.63.55,5.22l-10.56,13.05Z"/>
</g>
<g>
<path class="cls-3" d="M93.93,24.6l.55-.39c.69-.4,1.17-.61,1.46-.61.63,0,1.3.57,2.03,1.7.44.71.67,1.27.67,1.7s-.14.78-.41,1.06c-.27.28-.59.54-.96.76-.36.22-.71.43-1.05.64-.33.2-1.02.47-2.05.79-1.03.32-2.03.49-2.99.49s-1.93-.13-2.91-.38c-.98-.25-1.99-.68-3.03-1.27-1.04-.6-1.98-1.32-2.81-2.18-.83-.86-1.51-1.96-2.05-3.31-.54-1.35-.8-2.81-.8-4.38s.26-3.01.79-4.29c.53-1.28,1.2-2.35,2.02-3.19.82-.84,1.75-1.54,2.81-2.11,1.98-1.09,3.97-1.64,5.98-1.64.95,0,1.92.15,2.9.44.98.29,1.72.59,2.23.9l.73.42c.36.22.65.4.85.55.53.42.79.91.79,1.44s-.21,1.1-.64,1.68c-.79,1.09-1.5,1.64-2.12,1.64-.36,0-.88-.22-1.55-.67-.85-.69-1.98-1.03-3.4-1.03-1.31,0-2.61.46-3.88,1.36-.61.44-1.11,1.07-1.52,1.88-.4.81-.61,1.72-.61,2.75s.2,1.94.61,2.75c.4.81.92,1.45,1.55,1.91,1.23.89,2.52,1.34,3.85,1.34.63,0,1.22-.08,1.77-.24.56-.16.96-.32,1.2-.49Z"/>
<path class="cls-3" d="M114.38,9.07c.16-.3.43-.52.82-.64.38-.12.87-.18,1.46-.18s1.05.05,1.4.15c.34.1.61.22.79.36.18.14.32.34.42.61.1.34.15.87.15,1.58v16.84c0,.47-.02.81-.05,1.05-.03.23-.13.5-.29.8-.28.55-1.07.82-2.37.82-1.42,0-2.25-.37-2.49-1.12-.12-.34-.18-.87-.18-1.58v-6.16h-8.04v6.19c0,.47-.02.81-.05,1.05-.03.23-.13.5-.29.8-.28.55-1.07.82-2.37.82-1.42,0-2.25-.37-2.49-1.12-.12-.34-.18-.87-.18-1.58V10.92c0-.46.02-.81.05-1.05.03-.23.13-.5.29-.8.28-.55,1.07-.82,2.37-.82,1.42,0,2.25.37,2.52,1.12.1.34.15.87.15,1.58v6.19h8.04v-6.22c0-.46.02-.81.05-1.05.03-.23.13-.5.29-.8Z"/>
<path class="cls-3" d="M127.21,25.1h9.34c.47,0,.81.02,1.05.05.23.03.5.13.8.29.55.28.82,1.07.82,2.37,0,1.42-.37,2.25-1.12,2.49-.34.12-.87.18-1.58.18h-12.01c-1.42,0-2.25-.38-2.49-1.15-.12-.32-.18-.84-.18-1.55V10.9c0-1.03.19-1.73.58-2.11.38-.37,1.11-.56,2.18-.56h11.95c.47,0,.81.02,1.05.05.23.03.5.13.8.29.55.28.82,1.07.82,2.37,0,1.42-.37,2.25-1.12,2.49-.34.12-.87.18-1.58.18h-9.31v3.06h6.01c.46,0,.81.02,1.05.05.23.03.5.13.8.29.55.28.82,1.07.82,2.37,0,1.42-.38,2.25-1.15,2.49-.34.12-.87.18-1.58.18h-5.95v3.06Z"/>
<path class="cls-3" d="M196.96,8.79c.99.69,1.49,1.35,1.49,2,0,.38-.23.92-.7,1.61l-6.55,9.8v5.79c0,.47-.02.81-.05,1.05-.03.23-.13.5-.29.8-.16.3-.43.52-.82.64-.38.12-.9.18-1.55.18s-1.16-.06-1.55-.18c-.38-.12-.66-.34-.82-.65-.16-.31-.26-.59-.29-.82-.03-.23-.05-.59-.05-1.08v-5.73l-6.55-9.8c-.47-.69-.7-1.22-.7-1.61,0-.65.44-1.27,1.33-1.87.89-.6,1.53-.9,1.91-.9s.69.08.91.24c.34.22.71.64,1.09,1.24l4.7,7.52,4.7-7.52c.38-.61.72-1.01,1-1.2s.61-.29.99-.29.97.25,1.77.76Z"/>
<g>
<path class="cls-3" d="M81.93,56.63c-.53-.65-.79-1.23-.79-1.74s.43-1.2,1.3-2.05c.51-.49,1.04-.73,1.61-.73s1.36.51,2.37,1.52c.28.34.69.67,1.21.99.53.31,1.01.47,1.46.47,1.88,0,2.82-.77,2.82-2.31,0-.46-.26-.85-.77-1.17-.52-.31-1.16-.54-1.93-.68-.77-.14-1.6-.37-2.49-.68-.89-.31-1.72-.68-2.49-1.11-.77-.42-1.41-1.1-1.93-2.02-.52-.92-.77-2.03-.77-3.32,0-1.78.66-3.33,1.99-4.66s3.13-1.99,5.42-1.99c1.21,0,2.32.16,3.32.47,1,.31,1.69.63,2.08.96l.76.58c.63.59.94,1.08.94,1.49s-.24.96-.73,1.67c-.69,1.01-1.4,1.52-2.12,1.52-.42,0-.95-.2-1.58-.61-.06-.04-.18-.14-.35-.3-.17-.16-.33-.29-.47-.39-.42-.26-.97-.39-1.62-.39s-1.2.16-1.64.47c-.43.31-.65.75-.65,1.3s.26,1.01.77,1.35c.52.34,1.16.58,1.93.7.77.12,1.61.31,2.52.56.91.25,1.75.56,2.52.93.77.36,1.41,1,1.93,1.9.52.9.77,2.01.77,3.32s-.26,2.47-.79,3.47c-.53,1-1.21,1.77-2.06,2.32-1.64,1.07-3.39,1.61-5.25,1.61-.95,0-1.85-.12-2.7-.35-.85-.23-1.54-.52-2.06-.86-1.07-.65-1.82-1.27-2.24-1.88l-.27-.33Z"/>
<path class="cls-3" d="M100.74,37.49h16.87c.65,0,1.12.08,1.43.23.3.15.51.39.61.71.1.32.15.75.15,1.27s-.05.95-.15,1.26c-.1.31-.27.53-.52.65-.36.18-.88.27-1.55.27h-5.79v15.26c0,.47-.02.81-.05,1.03s-.12.48-.27.77c-.15.29-.42.5-.8.62-.38.12-.89.18-1.52.18s-1.13-.06-1.5-.18c-.37-.12-.64-.33-.79-.62-.15-.29-.24-.56-.27-.79-.03-.23-.05-.58-.05-1.05v-15.23h-5.82c-.65,0-1.12-.08-1.43-.23-.3-.15-.51-.39-.61-.71-.1-.32-.15-.75-.15-1.27s.05-.95.15-1.26c.1-.31.27-.53.52-.65.36-.18.88-.27,1.55-.27Z"/>
<path class="cls-3" d="M135.99,38.34c.2-.32.5-.55.88-.67.38-.12.86-.18,1.44-.18s1.04.05,1.38.15c.34.1.61.22.79.36.18.14.31.35.39.64.12.34.18.87.18,1.58v9.16c0,2.67-.83,5.1-2.49,7.28-.81,1.03-1.85,1.87-3.12,2.5s-2.68.96-4.23.96-2.95-.32-4.22-.97c-1.26-.65-2.29-1.5-3.08-2.55-1.64-2.14-2.46-4.57-2.46-7.28v-9.13c0-.49.02-.84.05-1.08.03-.23.13-.5.29-.8.16-.3.43-.52.82-.64.38-.12.9-.18,1.55-.18s1.16.06,1.55.18c.38.12.65.33.79.64.24.47.36,1.1.36,1.91v9.1c0,1.23.3,2.41.91,3.52.3.57.76,1.02,1.37,1.36.61.34,1.32.52,2.15.52,1.48,0,2.58-.55,3.31-1.64.73-1.09,1.09-2.36,1.09-3.79v-9.28c0-.79.1-1.34.3-1.67Z"/>
<path class="cls-3" d="M146.18,37.49l5.61.03c2.93,0,5.51,1.06,7.74,3.17,2.22,2.11,3.34,4.71,3.34,7.8s-1.09,5.73-3.26,7.93c-2.17,2.2-4.81,3.31-7.9,3.31h-5.55c-1.23,0-2-.25-2.31-.76-.24-.42-.36-1.07-.36-1.94v-16.87c0-.49.02-.84.05-1.06s.13-.49.29-.79c.28-.55,1.07-.82,2.37-.82ZM151.79,54.35c1.46,0,2.77-.54,3.94-1.62,1.17-1.08,1.76-2.44,1.76-4.08s-.57-3.01-1.71-4.11c-1.14-1.1-2.48-1.65-4.02-1.65h-2.91v11.47h2.94Z"/>
<path class="cls-3" d="M164.84,40.19c0-.46.02-.81.05-1.05.03-.23.13-.5.29-.8.28-.55,1.07-.82,2.37-.82,1.42,0,2.25.37,2.52,1.12.1.34.15.87.15,1.58v16.87c0,.49-.02.84-.05,1.06s-.13.49-.29.79c-.28.55-1.07.82-2.37.82-1.42,0-2.25-.38-2.49-1.15-.12-.32-.18-.84-.18-1.55v-16.87Z"/>
<path class="cls-3" d="M183.07,37.24c2.99,0,5.59,1.08,7.8,3.25,2.2,2.16,3.31,4.85,3.31,8.05s-1.05,5.94-3.16,8.19c-2.1,2.26-4.69,3.38-7.77,3.38s-5.69-1.11-7.84-3.34c-2.15-2.22-3.23-4.87-3.23-7.95,0-1.68.3-3.25.91-4.72.61-1.47,1.42-2.7,2.43-3.69,1.01-.99,2.17-1.77,3.49-2.34,1.31-.57,2.67-.85,4.07-.85ZM177.55,48.68c0,1.8.58,3.26,1.74,4.38,1.16,1.12,2.46,1.68,3.9,1.68s2.73-.55,3.88-1.64c1.15-1.09,1.73-2.56,1.73-4.4s-.58-3.32-1.74-4.43c-1.16-1.11-2.46-1.67-3.9-1.67s-2.73.56-3.88,1.68c-1.15,1.12-1.73,2.58-1.73,4.38Z"/>
</g>
<g>
<path class="cls-3" d="M176.92,11.06c-.03-.23-.13-.5-.29-.8-.28-.55-1.07-.82-2.37-.82h-6.55c-1.78,0-3.51.65-5.19,1.94-.81.63-1.48,1.48-2,2.55-.53,1.07-.79,2.27-.79,3.58,0,2.29.76,4.17,2.28,5.64-.44,1.07-1.13,2.66-2.06,4.76-.3.73-.45,1.25-.45,1.58,0,.77.63,1.42,1.88,1.94.65.28,1.17.43,1.56.43s.72-.1.97-.29c.25-.19.44-.39.56-.59.2-.38.99-2.21,2.37-5.49l.94.06h3.82v3.43c0,.47.02.81.05,1.05.03.23.13.5.29.8.28.55,1.07.82,2.37.82,1.42,0,2.25-.37,2.49-1.12.12-.34.18-.87.18-1.58V12.11c0-.46-.02-.81-.05-1.05ZM172.81,19.44c-.09.14-.48.77-1.24.91-.2.04-.37.03-.48.02-.02.14-.04.26-.06.38-.16.83-.38,1.05-.57,1.07-.29.05-.51-.35-.93-.9-.23.01-.46.02-.69.02-.51,0-1.01-.03-1.49-.09-.25-.03-.5-.07-.74-.11-1.18-.32-2.03-1.27-2.03-2.4v-1.37c0-1.13.86-2.08,2.03-2.4.24-.04.49-.08.74-.11.48-.06.98-.09,1.49-.09s1.01.03,1.49.09c.25.03.5.07.74.11.6.16,1.12.49,1.49.93.34.41.55.92.55,1.47v1.37c0,.23-.01.66-.29,1.1Z"/>
<circle class="cls-2" cx="167.24" cy="17.67" r=".49"/>
<circle class="cls-2" cx="168.88" cy="17.71" r=".49"/>
<circle class="cls-2" cx="170.59" cy="17.71" r=".49"/>
</g>
<g>
<path class="cls-3" d="M141.01,8.24c.03-.23.13-.5.29-.8.28-.55,1.07-.82,2.37-.82h6.55c1.78,0,3.51.65,5.19,1.94.81.63,1.48,1.48,2,2.55.53,1.07.79,2.27.79,3.58,0,2.29-.76,4.17-2.28,5.64.44,1.07,1.13,2.66,2.06,4.76.3.73.45,1.25.45,1.58,0,.77-.63,1.42-1.88,1.94-.65.28-1.17.43-1.56.43s-.72-.1-.97-.29c-.25-.19-.44-.39-.56-.59-.2-.38-.99-2.21-2.37-5.49l-.94.06h-3.82v3.43c0,.47-.02.81-.05,1.05-.03.23-.13.5-.29.8-.28.55-1.07.82-2.37.82-1.42,0-2.25-.37-2.49-1.12-.12-.34-.18-.87-.18-1.58V9.28c0-.46.02-.81.05-1.05ZM145.12,16.62c.09.14.48.77,1.24.91.2.04.37.03.48.02.02.14.04.26.06.38.16.83.38,1.05.57,1.07.29.05.51-.35.93-.9.23.01.46.02.69.02.51,0,1.01-.03,1.49-.09.25-.03.5-.07.74-.11,1.18-.32,2.03-1.27,2.03-2.4v-1.37c0-1.13-.86-2.08-2.03-2.4-.24-.04-.49-.08-.74-.11-.48-.06-.98-.09-1.49-.09s-1.01.03-1.49.09c-.25.03-.5.07-.74.11-.6.16-1.12.49-1.49.93-.34.41-.55.92-.55,1.47v1.37c0,.23.01.66.29,1.1Z"/>
<circle class="cls-2" cx="150.69" cy="14.84" r=".49"/>
<circle class="cls-2" cx="149.05" cy="14.89" r=".49"/>
<circle class="cls-2" cx="147.35" cy="14.89" r=".49"/>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

View File

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

View File

@@ -16,6 +16,10 @@
outline: none;
}
.ant-tabs-tab-btn {
outline: none;
}
.ant-segmented-group {
gap: 4px;
}
@@ -199,3 +203,11 @@
overflow-y: auto;
overflow-x: hidden;
}
.ant-collapse {
border: 1px solid var(--color-border);
}
.ant-collapse-content {
border-top: 1px solid var(--color-border) !important;
}

View File

@@ -40,7 +40,7 @@
--color-border-soft: #ffffff10;
--color-border-mute: #ffffff05;
--color-error: #f44336;
--color-link: #1677ff;
--color-link: #338cff;
--color-code-background: #323232;
--color-hover: rgba(40, 40, 40, 1);
--color-active: rgba(55, 55, 55, 1);
@@ -260,6 +260,7 @@ body,
.markdown,
.anticon,
.iconfont,
.lucide,
.message-tokens {
color: var(--chat-text-user) !important;
}
@@ -281,3 +282,7 @@ body,
color: var(--color-text);
}
}
.lucide {
color: var(--color-icon);
}

View File

@@ -1,4 +1,5 @@
import { Collapse } from 'antd'
import { merge } from 'lodash'
import { FC, memo } from 'react'
interface CustomCollapseProps {
@@ -9,6 +10,11 @@ interface CustomCollapseProps {
defaultActiveKey?: string[]
activeKey?: string[]
collapsible?: 'header' | 'icon' | 'disabled'
style?: React.CSSProperties
styles?: {
header?: React.CSSProperties
body?: React.CSSProperties
}
}
const CustomCollapse: FC<CustomCollapseProps> = ({
@@ -18,14 +24,17 @@ const CustomCollapse: FC<CustomCollapseProps> = ({
destroyInactivePanel = false,
defaultActiveKey = ['1'],
activeKey,
collapsible = undefined
collapsible = undefined,
style,
styles
}) => {
const CollapseStyle = {
const defaultCollapseStyle = {
width: '100%',
background: 'transparent',
border: '0.5px solid var(--color-border)'
}
const CollapseItemStyles = {
const defaultCollapseItemStyles = {
header: {
padding: '8px 16px',
alignItems: 'center',
@@ -35,20 +44,24 @@ const CustomCollapse: FC<CustomCollapseProps> = ({
borderTopRightRadius: '8px'
},
body: {
borderTop: '0.5px solid var(--color-border)'
borderTop: 'none'
}
}
const collapseStyle = merge({}, defaultCollapseStyle, style)
const collapseItemStyles = merge({}, defaultCollapseItemStyles, styles)
return (
<Collapse
bordered={false}
style={CollapseStyle}
style={collapseStyle}
defaultActiveKey={defaultActiveKey}
activeKey={activeKey}
destroyInactivePanel={destroyInactivePanel}
collapsible={collapsible}
items={[
{
styles: CollapseItemStyles,
styles: collapseItemStyles,
key: '1',
label,
extra,

View File

@@ -0,0 +1,49 @@
import { getLeadingEmoji } from '@renderer/utils'
import { FC } from 'react'
import styled from 'styled-components'
interface EmojiIconProps {
emoji: string
className?: string
}
const EmojiIcon: FC<EmojiIconProps> = ({ emoji, className }) => {
const _emoji = getLeadingEmoji(emoji || '⭐️') || '⭐️'
return (
<Container className={className}>
<EmojiBackground>{_emoji}</EmojiBackground>
{_emoji}
</Container>
)
}
const Container = styled.div`
width: 26px;
height: 26px;
border-radius: 13px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 15px;
position: relative;
overflow: hidden;
margin-right: 3px;
`
const EmojiBackground = styled.div`
width: 100%;
height: 100%;
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 200%;
transform: scale(1.5);
filter: blur(5px);
opacity: 0.4;
`
export default EmojiIcon

View File

@@ -5,11 +5,12 @@ import styled from 'styled-components'
interface Props {
app: MinAppType
sidebar?: boolean
size?: number
style?: React.CSSProperties
}
const MinAppIcon: FC<Props> = ({ app, size = 48, style }) => {
const MinAppIcon: FC<Props> = ({ app, size = 48, style, sidebar = false }) => {
const _app = DEFAULT_MIN_APPS.find((item) => item.id === app.id)
if (!_app) {
@@ -24,7 +25,7 @@ const MinAppIcon: FC<Props> = ({ app, size = 48, style }) => {
width: `${size}px`,
height: `${size}px`,
backgroundColor: _app.background,
...app.style,
...(sidebar ? {} : app.style),
...style
}}
/>

View File

@@ -1,5 +1,5 @@
import { EyeOutlined } from '@ant-design/icons'
import { Tooltip } from 'antd'
import { ImageIcon } from 'lucide-react'
import React, { FC } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@@ -10,7 +10,7 @@ const VisionIcon: FC<React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>,
return (
<Container>
<Tooltip title={t('models.type.vision')} placement="top">
<Icon {...(props as any)} />
<Icon size={15} {...(props as any)} />
</Tooltip>
</Container>
)
@@ -22,9 +22,8 @@ const Container = styled.div`
align-items: center;
`
const Icon = styled(EyeOutlined)`
const Icon = styled(ImageIcon)`
color: var(--color-primary);
font-size: 15px;
margin-right: 6px;
`

View File

@@ -4,7 +4,7 @@ import styled from 'styled-components'
interface ListItemProps {
active?: boolean
icon?: ReactNode
title: string
title: ReactNode
subtitle?: string
titleStyle?: React.CSSProperties
onClick?: () => void
@@ -52,7 +52,7 @@ const ListItemContainer = styled.div`
const ListItemContent = styled.div`
display: flex;
align-items: center;
gap: 5px;
gap: 2px;
overflow: hidden;
font-size: 13px;
`
@@ -65,6 +65,7 @@ const IconWrapper = styled.span`
`
const TextContainer = styled.div`
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;

View File

@@ -3,6 +3,7 @@ import {
CodeOutlined,
CopyOutlined,
ExportOutlined,
LinkOutlined,
MinusOutlined,
PushpinOutlined,
ReloadOutlined
@@ -12,7 +13,11 @@ import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
import { useBridge } from '@renderer/hooks/useBridge'
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
import { useMinapps } from '@renderer/hooks/useMinapps'
import useNavBackgroundColor from '@renderer/hooks/useNavBackgroundColor'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings'
import { useAppDispatch } from '@renderer/store'
import { setMinappsOpenLinkExternal } from '@renderer/store/settings'
import { MinAppType } from '@renderer/types'
import { delay } from '@renderer/utils'
import { Avatar, Drawer, Tooltip } from 'antd'
@@ -38,6 +43,8 @@ const MinappPopupContainer: React.FC = () => {
const { closeMinapp, hideMinappPopup } = useMinappPopup()
const { pinned, updatePinnedMinapps } = useMinapps()
const { t } = useTranslation()
const backgroundColor = useNavBackgroundColor()
const dispatch = useAppDispatch()
/** control the drawer open or close */
const [isPopupShow, setIsPopupShow] = useState(true)
@@ -55,6 +62,8 @@ const MinappPopupContainer: React.FC = () => {
const webviewRefs = useRef<Map<string, WebviewTag | null>>(new Map())
/** indicate whether the webview has loaded */
const webviewLoadedRefs = useRef<Map<string, boolean>>(new Map())
/** whether the minapps open link external is enabled */
const { minappsOpenLinkExternal } = useSettings()
const isInDevelopment = process.env.NODE_ENV === 'development'
@@ -105,9 +114,14 @@ const MinappPopupContainer: React.FC = () => {
webviewLoadedRefs.current.forEach((_, appid) => {
if (!webviewRefs.current.has(appid)) {
webviewLoadedRefs.current.delete(appid)
} else if (appid === currentMinappId) {
const webviewId = webviewRefs.current.get(appid)?.getWebContentsId()
if (webviewId) {
window.api.webview.setOpenLinkExternal(webviewId, minappsOpenLinkExternal)
}
}
})
}, [currentMinappId])
}, [currentMinappId, minappsOpenLinkExternal])
/** only the keepalive minapp can be minimized */
const canMinimize = !(openedOneOffMinapp && openedOneOffMinapp.id == currentMinappId)
@@ -173,6 +187,10 @@ const MinappPopupContainer: React.FC = () => {
/** the callback function to set the webviews loaded indicator */
const handleWebviewLoaded = (appid: string) => {
webviewLoadedRefs.current.set(appid, true)
const webviewId = webviewRefs.current.get(appid)?.getWebContentsId()
if (webviewId) {
window.api.webview.setOpenLinkExternal(webviewId, minappsOpenLinkExternal)
}
if (appid == currentMinappId) {
setTimeout(() => setIsReady(true), 200)
}
@@ -218,6 +236,11 @@ const MinappPopupContainer: React.FC = () => {
updatePinnedMinapps(newPinned)
}
/** set the open external status */
const handleToggleOpenExternal = () => {
dispatch(setMinappsOpenLinkExternal(!minappsOpenLinkExternal))
}
/** Title bar of the popup */
const Title = ({ appInfo, url }: { appInfo: AppInfo | null; url: string | null }) => {
if (!appInfo) return null
@@ -236,7 +259,7 @@ const MinappPopupContainer: React.FC = () => {
}
return (
<TitleContainer style={{ justifyContent: 'space-between' }}>
<TitleContainer style={{ backgroundColor: backgroundColor }}>
<Tooltip
title={
<TitleTextTooltip>
@@ -254,6 +277,14 @@ const MinappPopupContainer: React.FC = () => {
}}>
<TitleText onContextMenu={(e) => handleCopyUrl(e, url ?? appInfo.url)}>{appInfo.name}</TitleText>
</Tooltip>
{appInfo.canOpenExternalLink && (
<Tooltip title={t('minapp.popup.openExternal')} mouseEnterDelay={0.8} placement="bottom">
<Button onClick={() => handleOpenLink(url ?? appInfo.url)}>
<ExportOutlined />
</Button>
</Tooltip>
)}
<Spacer />
<ButtonsGroup className={isWindows ? 'windows' : ''}>
<Tooltip title={t('minapp.popup.refresh')} mouseEnterDelay={0.8} placement="bottom">
<Button onClick={() => handleReload(appInfo.id)}>
@@ -270,13 +301,18 @@ const MinappPopupContainer: React.FC = () => {
</Button>
</Tooltip>
)}
{appInfo.canOpenExternalLink && (
<Tooltip title={t('minapp.popup.openExternal')} mouseEnterDelay={0.8} placement="bottom">
<Button onClick={() => handleOpenLink(url ?? appInfo.url)}>
<ExportOutlined />
</Button>
</Tooltip>
)}
<Tooltip
title={
minappsOpenLinkExternal
? t('minapp.popup.open_link_external_on')
: t('minapp.popup.open_link_external_off')
}
mouseEnterDelay={0.8}
placement="bottom">
<Button onClick={handleToggleOpenExternal} className={minappsOpenLinkExternal ? 'open-external' : ''}>
<LinkOutlined />
</Button>
</Tooltip>
{isInDevelopment && (
<Tooltip title={t('minapp.popup.devtools')} mouseEnterDelay={0.8} placement="bottom">
<Button onClick={() => handleOpenDevTools(appInfo.id)}>
@@ -331,7 +367,7 @@ const MinappPopupContainer: React.FC = () => {
height={'100%'}
maskClosable={false}
closeIcon={null}
style={{ marginLeft: 'var(--sidebar-width)' }}>
style={{ marginLeft: 'var(--sidebar-width)', backgroundColor: 'var(--color-background)' }}>
{!isReady && (
<EmptyView>
<Avatar
@@ -365,8 +401,8 @@ const TitleText = styled.div`
font-weight: bold;
font-size: 14px;
color: var(--color-text-1);
margin-right: 10px;
-webkit-app-region: no-drag;
margin-right: 5px;
`
const TitleTextTooltip = styled.span`
@@ -405,6 +441,7 @@ const Button = styled.div`
color: var(--color-text-2);
transition: all 0.2s ease;
font-size: 14px;
-webkit-app-region: no-drag;
&:hover {
color: var(--color-text-1);
background-color: var(--color-background-mute);
@@ -413,6 +450,10 @@ const Button = styled.div`
color: var(--color-primary);
background-color: var(--color-primary-bg);
}
&.open-external {
color: var(--color-primary);
background-color: var(--color-primary-bg);
}
`
const EmptyView = styled.div`
@@ -426,4 +467,8 @@ const EmptyView = styled.div`
background-color: var(--color-background);
`
const Spacer = styled.div`
flex: 1;
`
export default MinappPopupContainer

View File

@@ -75,7 +75,7 @@ const WebviewContainer = memo(
const WebviewStyle: React.CSSProperties = {
width: 'calc(100vw - var(--sidebar-width))',
height: 'calc(100vh - var(--navbar-height))',
backgroundColor: 'white',
backgroundColor: 'var(--color-background)',
display: 'inline-flex'
}

View File

@@ -11,6 +11,7 @@ interface Props extends ButtonProps {
const OAuthButton: FC<Props> = ({ provider, onSuccess, ...buttonProps }) => {
const { t } = useTranslation()
const onAuth = () => {
const handleSuccess = (key: string) => {
if (key.trim()) {
@@ -29,8 +30,8 @@ const OAuthButton: FC<Props> = ({ provider, onSuccess, ...buttonProps }) => {
}
return (
<Button onClick={onAuth} {...buttonProps}>
{t('auth.get_key')}
<Button type="primary" onClick={onAuth} shape="round" {...buttonProps}>
{t('settings.provider.oauth.button', { provider: t(`provider.${provider.id}`) })}
</Button>
)
}

View File

@@ -131,6 +131,8 @@ const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
folder: ''
})
// 是否手动编辑过标题
const [hasTitleBeenManuallyEdited, setHasTitleBeenManuallyEdited] = useState(false)
const [vaults, setVaults] = useState<Array<{ path: string; name: string }>>([])
const [files, setFiles] = useState<FileInfo[]>([])
const [fileTreeData, setFileTreeData] = useState<any[]>([])
@@ -255,6 +257,12 @@ const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
setState((prevState) => ({ ...prevState, [key]: value }))
}
// 处理title输入变化
const handleTitleInputChange = (newTitle: string) => {
handleChange('title', newTitle)
setHasTitleBeenManuallyEdited(true)
}
const handleVaultChange = (value: string) => {
setSelectedVault(value)
// 文件夹会通过useEffect自动获取
@@ -278,11 +286,17 @@ const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
const fileName = selectedFile.name
const titleWithoutExt = fileName.endsWith('.md') ? fileName.substring(0, fileName.length - 3) : fileName
handleChange('title', titleWithoutExt)
// 重置手动编辑标记因为这是非用户设置的title
setHasTitleBeenManuallyEdited(false)
handleChange('processingMethod', '1')
} else {
// 如果是文件夹自动设置标题为话题名并设置处理方式为3(新建)
handleChange('processingMethod', '3')
handleChange('title', title)
// 仅当用户未手动编辑过 title 时,才将其重置为 props.title
if (!hasTitleBeenManuallyEdited) {
// title 是 props.title
handleChange('title', title)
}
}
}
}
@@ -309,7 +323,7 @@ const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
<Form.Item label={i18n.t('chat.topics.export.obsidian_title')}>
<Input
value={state.title}
onChange={(e) => handleChange('title', e.target.value)}
onChange={(e) => handleTitleInputChange(e.target.value)}
placeholder={i18n.t('chat.topics.export.obsidian_title_placeholder')}
/>
</Form.Item>

View File

@@ -1,4 +1,3 @@
import { SearchOutlined } from '@ant-design/icons'
import { TopView } from '@renderer/components/TopView'
import { useAgents } from '@renderer/hooks/useAgents'
import { useAssistants, useDefaultAssistant } from '@renderer/hooks/useAssistant'
@@ -9,10 +8,12 @@ import { Agent, Assistant } from '@renderer/types'
import { uuid } from '@renderer/utils'
import { Divider, Input, InputRef, Modal, Tag } from 'antd'
import { take } from 'lodash'
import { Search } from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import EmojiIcon from '../EmojiIcon'
import { HStack } from '../Layout'
import Scrollbar from '../Scrollbar'
@@ -98,6 +99,7 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
setSelectedIndex((prev) => (prev <= 0 ? displayedAgents.length - 1 : prev - 1))
break
case 'Enter':
case 'NumpadEnter':
// 如果焦点在输入框且有搜索内容,则默认选择第一项
if (document.activeElement === inputRef.current?.input && searchText.trim()) {
e.preventDefault()
@@ -163,7 +165,7 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
<Input
prefix={
<SearchIcon>
<SearchOutlined />
<Search size={14} />
</SearchIcon>
}
ref={inputRef}
@@ -177,7 +179,7 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
size="middle"
/>
</HStack>
<Divider style={{ margin: 0, borderBlockStartWidth: 0.5 }} />
<Divider style={{ margin: 0, marginTop: 4, borderBlockStartWidth: 0.5 }} />
<Container ref={containerRef}>
{take(agents, 100).map((agent, index) => (
<AgentItem
@@ -185,12 +187,9 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
onClick={() => onCreateAssistant(agent)}
className={`agent-item ${agent.id === 'default' ? 'default' : ''} ${index === selectedIndex ? 'keyboard-selected' : ''}`}
onMouseEnter={() => setSelectedIndex(index)}>
<HStack
alignItems="center"
gap={5}
style={{ overflow: 'hidden', maxWidth: '100%' }}
className="text-nowrap">
{agent.emoji} {agent.name}
<HStack alignItems="center" gap={5} style={{ overflow: 'hidden', maxWidth: '100%' }}>
<EmojiIcon emoji={agent.emoji || ''} />
<span className="text-nowrap">{agent.name}</span>
</HStack>
{agent.id === 'default' && <Tag color="green">{t('agents.tag.system')}</Tag>}
{agent.type === 'agent' && <Tag color="orange">{t('agents.tag.agent')}</Tag>}
@@ -219,13 +218,11 @@ const AgentItem = styled.div`
margin-bottom: 8px;
cursor: pointer;
overflow: hidden;
border: 1px solid transparent;
&.default {
background-color: var(--color-background-mute);
}
&.keyboard-selected {
background-color: var(--color-background-mute);
border: 1px solid var(--color-primary);
}
.anticon {
font-size: 16px;
@@ -237,8 +234,8 @@ const AgentItem = styled.div`
`
const SearchIcon = styled.div`
width: 36px;
height: 36px;
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
flex-direction: row;

View File

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

View File

@@ -48,7 +48,7 @@ const MinAppsPopover: FC<Props> = ({ children }) => {
))}
{isEmpty(minapps) && (
<Center>
<Empty />
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
</Center>
)}
</AppsContainer>

View File

@@ -1,257 +0,0 @@
import { DownOutlined, UpOutlined } from '@ant-design/icons'
import CopyIcon from '@renderer/components/Icons/CopyIcon'
import { TopView } from '@renderer/components/TopView'
import {
isEmbeddingModel,
isFunctionCallingModel,
isReasoningModel,
isVisionModel,
isWebSearchModel
} from '@renderer/config/models'
import { useProvider } from '@renderer/hooks/useProvider'
import { Model, ModelType } from '@renderer/types'
import { getDefaultGroupName } from '@renderer/utils'
import { Button, Checkbox, Divider, Flex, Form, Input, message, Modal } from 'antd'
import { CheckboxProps } from 'antd/lib/checkbox'
import { FC, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface ModelEditPopupProps {
model: Model
resolve: (updatedModel?: Model) => void
}
const PopupContainer: FC<ModelEditPopupProps> = ({ model, resolve }) => {
const [open, setOpen] = useState(true)
const [form] = Form.useForm()
const { t } = useTranslation()
const [showModelTypes, setShowModelTypes] = useState(false)
const { updateModel } = useProvider(model.provider)
const onFinish = (values: any) => {
const updatedModel = {
...model,
id: values.id || model.id,
name: values.name || model.name,
group: values.group || model.group
}
updateModel(updatedModel)
setShowModelTypes(false)
setOpen(false)
resolve(updatedModel)
}
const handleClose = () => {
setShowModelTypes(false)
setOpen(false)
resolve()
}
const onUpdateModel = (updatedModel: Model) => {
updateModel(updatedModel)
// 只更新模型数据,不关闭弹窗,不返回结果
}
return (
<Modal
title={t('models.edit')}
open={open}
onCancel={handleClose}
footer={null}
maskClosable={false}
centered
width={600} // 增加宽度
styles={{
content: {
padding: '20px', // 增加内边距
borderRadius: 15 // 增加圆角
}
}}
afterOpenChange={(visible) => {
if (visible) {
form.getFieldInstance('id')?.focus()
} else {
setShowModelTypes(false)
}
}}>
<Form
form={form}
labelCol={{ flex: '120px' }} // 增加标签宽度
labelAlign="left"
colon={false}
style={{ marginTop: 15 }}
size="large" // 使表单控件更大
initialValues={{
id: model.id,
name: model.name,
group: model.group
}}
onFinish={onFinish}>
<Form.Item
name="id"
label={t('settings.models.add.model_id')}
tooltip={t('settings.models.add.model_id.tooltip')}
rules={[{ required: true }]}>
<Flex justify="space-between" gap={5}>
<Input
placeholder={t('settings.models.add.model_id.placeholder')}
spellCheck={false}
maxLength={200}
disabled={true}
value={model.id}
onChange={(e) => {
const value = e.target.value
form.setFieldValue('name', value)
form.setFieldValue('group', getDefaultGroupName(value))
}}
/>
<Button
type="text"
icon={<CopyIcon />}
onClick={() => {
navigator.clipboard.writeText(model.id)
message.success(t('message.copy.success'))
}}
/>
</Flex>
</Form.Item>
<Form.Item
name="name"
label={t('settings.models.add.model_name')}
tooltip={t('settings.models.add.model_name.tooltip')}
rules={[{ required: true }]}>
<Input placeholder={t('settings.models.add.model_name.placeholder')} spellCheck={false} maxLength={200} />
</Form.Item>
<Form.Item
name="group"
label={t('settings.models.add.model_group')}
tooltip={t('settings.models.add.model_group.tooltip')}>
<Input placeholder={t('settings.models.add.model_group.placeholder')} spellCheck={false} maxLength={200} />
</Form.Item>
<Form.Item style={{ marginBottom: 20, textAlign: 'center', marginTop: 10 }}>
<Flex justify="space-between" align="center" style={{ position: 'relative' }}>
<MoreSettingsRow onClick={() => setShowModelTypes(!showModelTypes)}>
{t('settings.moresetting')}
<ExpandIcon>{showModelTypes ? <UpOutlined /> : <DownOutlined />}</ExpandIcon>
</MoreSettingsRow>
<Button type="primary" htmlType="submit" size="large">
{t('common.save')}
</Button>
</Flex>
</Form.Item>
{showModelTypes && (
<div>
<Divider style={{ margin: '0 0 15px 0' }} />
<TypeTitle>{t('models.type.select')}:</TypeTitle>
{(() => {
const defaultTypes = [
...(isVisionModel(model) ? ['vision'] : []),
...(isEmbeddingModel(model) ? ['embedding'] : []),
...(isReasoningModel(model) ? ['reasoning'] : []),
...(isFunctionCallingModel(model) ? ['function_calling'] : []),
...(isWebSearchModel(model) ? ['web_search'] : [])
] as ModelType[]
// 合并现有选择和默认类型
const selectedTypes = [...new Set([...(model.type || []), ...defaultTypes])]
const showTypeConfirmModal = (type: string) => {
window.modal.confirm({
title: t('settings.moresetting.warn'),
content: t('settings.moresetting.check.warn'),
okText: t('settings.moresetting.check.confirm'),
cancelText: t('common.cancel'),
okButtonProps: { danger: true },
cancelButtonProps: { type: 'primary' },
onOk: () => {
const updatedModel = { ...model, type: [...selectedTypes, type] as ModelType[] }
onUpdateModel(updatedModel)
},
onCancel: () => {},
centered: true
})
}
const handleTypeChange = (types: string[]) => {
const newType = types.find((type) => !selectedTypes.includes(type as ModelType))
if (newType) {
// 如果有新类型被添加,显示确认对话框
showTypeConfirmModal(newType)
} else {
// 如果没有新类型,只是取消选择了某些类型,直接更新
const updatedModel = { ...model, type: types as ModelType[] }
onUpdateModel(updatedModel)
}
}
return (
<Checkbox.Group
value={selectedTypes}
onChange={handleTypeChange}
style={{ display: 'flex', flexDirection: 'column', gap: 15 }}>
<StyledCheckbox value="vision">{t('models.type.vision')}</StyledCheckbox>
<StyledCheckbox value="web_search">{t('models.type.websearch')}</StyledCheckbox>
<StyledCheckbox value="reasoning">{t('models.type.reasoning')}</StyledCheckbox>
<StyledCheckbox value="function_calling">{t('models.type.function_calling')}</StyledCheckbox>
<StyledCheckbox value="embedding">{t('models.type.embedding')}</StyledCheckbox>
<StyledCheckbox value="rerank">{t('models.type.rerank')}</StyledCheckbox>
</Checkbox.Group>
)
})()}
</div>
)}
</Form>
</Modal>
)
}
const MoreSettingsRow = styled.div`
cursor: pointer;
display: flex;
align-items: center;
gap: 8px; // 增加间距
color: var(--color-text-secondary);
font-size: 14px; // 增加字体大小
&:hover {
color: var(--color-text-primary);
}
`
const ExpandIcon = styled.span`
font-size: 12px; // 增加图标大小
display: flex;
align-items: center;
`
const TypeTitle = styled.div`
font-size: 16px; // 增加字体大小
margin-bottom: 15px; // 增加下边距
font-weight: 500;
`
const StyledCheckbox = styled(Checkbox)<CheckboxProps>`
font-size: 14px; // 增加字体大小
padding: 5px 0; // 增加内边距
.ant-checkbox-inner {
width: 18px; // 增加复选框大小
height: 18px; // 增加复选框大小
}
.ant-checkbox + span {
padding-left: 12px; // 增加文字与复选框的间距
}
`
export default class ModelEditPopup {
static hide() {
TopView.hide('ModelEditPopup')
}
static show(model: Model) {
return new Promise<Model | undefined>((resolve) => {
TopView.show(<PopupContainer model={model} resolve={resolve} />, 'ModelEditPopup')
})
}
}

View File

@@ -1,64 +0,0 @@
import { SettingOutlined } from '@ant-design/icons'
import { useProvider } from '@renderer/hooks/useProvider'
import { Model } from '@renderer/types'
import { Button, Tooltip } from 'antd'
import { FC, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import ModelEditPopup from './ModelEditPopup'
interface ModelSettingsButtonProps {
model: Model
size?: number
className?: string
}
const ModelSettingsButton: FC<ModelSettingsButtonProps> = ({ model, size = 16, className }) => {
const { t } = useTranslation()
const { updateModel } = useProvider(model.provider)
const handleClick = useCallback(
async (e: React.MouseEvent) => {
e.stopPropagation() // 防止触发父元素的点击事件
const updatedModel = await ModelEditPopup.show(model)
if (updatedModel) {
updateModel(updatedModel)
}
},
[model, updateModel]
)
return (
<Tooltip title={t('models.edit')} placement="top">
<StyledButton
type="text"
icon={<SettingOutlined style={{ fontSize: size }} />}
onClick={handleClick}
className={className}
/>
</Tooltip>
)
}
const StyledButton = styled(Button)`
display: flex;
align-items: center;
justify-content: center;
padding: 6px; // 增加内边距
margin: 0;
height: auto;
width: auto;
min-width: auto;
background: transparent;
border: none;
opacity: 0.5;
transition: opacity 0.2s;
&:hover {
opacity: 1;
background: transparent;
}
`
export default ModelSettingsButton

View File

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

View File

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

View File

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

View File

@@ -1,485 +1,549 @@
import { PushpinOutlined, SearchOutlined } from '@ant-design/icons'
import { PushpinOutlined } from '@ant-design/icons'
import { TopView } from '@renderer/components/TopView'
import { getModelLogo, isEmbeddingModel, isRerankModel } from '@renderer/config/models'
import db from '@renderer/databases'
import { useProviders } from '@renderer/hooks/useProvider'
import { getModelUniqId } from '@renderer/services/ModelService'
import { Model } from '@renderer/types' // Removed unused 'Provider' import
import { Avatar, Divider, Empty, Input, InputRef, Modal, Tooltip } from 'antd'
import { Model } from '@renderer/types'
import { Avatar, Divider, Empty, Input, InputRef, Menu, MenuProps, Modal } from 'antd'
import { first, sortBy } from 'lodash'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' // Added useMemo here
import { Search } from 'lucide-react'
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import { HStack } from '../Layout'
import ModelTags from '../ModelTags'
import ModelTagsWithLabel from '../ModelTagsWithLabel'
import Scrollbar from '../Scrollbar'
import ModelSettingsButton from './ModelSettingsButton'
type MenuItem = Required<MenuProps>['items'][number]
interface Props {
model?: Model // The currently active model, for highlighting
model?: Model
}
interface PopupContainerProps extends Props {
resolve: (value: Model | undefined) => void
}
const PINNED_PROVIDER_ID = '__pinned__' // Special ID for pinned section
const PopupContainer: React.FC<PopupContainerProps> = ({ model: activeModel, resolve }) => {
const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
const [open, setOpen] = useState(true)
const { t } = useTranslation()
const [searchText, setSearchText] = useState('')
const inputRef = useRef<InputRef>(null)
const { providers } = useProviders()
const [pinnedModels, setPinnedModels] = useState<string[]>([])
const [selectedProviderId, setSelectedProviderId] = useState<string>('all')
// 移除未使用的状态
const scrollContainerRef = useRef<HTMLDivElement>(null)
const [keyboardSelectedId, setKeyboardSelectedId] = useState<string>('')
const menuItemRefs = useRef<Record<string, HTMLElement | null>>({})
const setMenuItemRef = useCallback(
(key: string) => (el: HTMLElement | null) => {
if (el) {
menuItemRefs.current[key] = el
}
},
[]
)
// --- Load Pinned Models ---
useEffect(() => {
const loadPinnedModels = async () => {
const setting = await db.settings.get('pinned:models')
const savedPinnedModels = setting?.value || []
// Filter out invalid pinned models
const allModelIds = providers.flatMap((p) => p.models || []).map((m) => getModelUniqId(m))
const validPinnedModels = savedPinnedModels.filter((id: string) => allModelIds.includes(id))
const validPinnedModels = savedPinnedModels.filter((id) => allModelIds.includes(id))
// Update storage if there were invalid models
if (validPinnedModels.length !== savedPinnedModels.length) {
await db.settings.put({ id: 'pinned:models', value: validPinnedModels })
}
setPinnedModels(sortBy(validPinnedModels)) // Keep pinned models sorted if needed
// Set initial selected provider
if (activeModel) {
const activeModelId = getModelUniqId(activeModel)
if (validPinnedModels.includes(activeModelId)) {
setSelectedProviderId(PINNED_PROVIDER_ID)
} else {
setSelectedProviderId(activeModel.provider)
}
} else if (validPinnedModels.length > 0) {
setSelectedProviderId(PINNED_PROVIDER_ID)
} else if (providers.length > 0) {
setSelectedProviderId(providers[0].id)
}
setPinnedModels(sortBy(validPinnedModels, ['group', 'name']))
}
loadPinnedModels()
}, [providers, activeModel]) // Depend on providers and activeModel
// --- Pin/Unpin Logic ---
const togglePin = useCallback(
async (modelId: string) => {
const newPinnedModels = pinnedModels.includes(modelId)
? pinnedModels.filter((id) => id !== modelId)
: [...pinnedModels, modelId]
await db.settings.put({ id: 'pinned:models', value: newPinnedModels })
setPinnedModels(sortBy(newPinnedModels)) // Keep sorted
// If unpinning the last pinned model and currently viewing pinned, switch provider
if (newPinnedModels.length === 0 && selectedProviderId === PINNED_PROVIDER_ID) {
setSelectedProviderId(providers[0]?.id || 'all')
}
// If pinning a model while viewing its provider, maybe switch to pinned? (Optional UX decision)
// else if (!pinnedModels.includes(modelId) && selectedProviderId !== PINNED_PROVIDER_ID) {
// setSelectedProviderId(PINNED_PROVIDER_ID);
// }
},
[pinnedModels, selectedProviderId, providers]
)
// 缓存所有模型列表只在providers变化时重新计算
const allModels = useMemo(() => {
return providers.flatMap((p) => p.models || [])
.filter((m) => !isEmbeddingModel(m) && !isRerankModel(m))
}, [providers])
// --- Filter Models for Right Column ---
const displayedModels = useMemo(() => {
let modelsToShow: Model[] = []
const togglePin = async (modelId: string) => {
const newPinnedModels = pinnedModels.includes(modelId)
? pinnedModels.filter((id) => id !== modelId)
: [...pinnedModels, modelId]
// 如果有搜索文本,在所有模型中搜索
if (searchText.trim()) {
const keywords = searchText.toLowerCase().split(/\s+/).filter(Boolean)
modelsToShow = allModels.filter((m) => {
const provider = providers.find((p) => p.id === m.provider)
const providerName = provider ? (provider.isSystem ? t(`provider.${provider.id}`) : provider.name) : ''
const fullName = `${m.name} ${providerName}`.toLowerCase()
return keywords.every((keyword) => fullName.includes(keyword))
})
} else {
// 没有搜索文本时,根据选择的供应商筛选
if (selectedProviderId === 'all') {
// 显示所有模型
modelsToShow = allModels
} else if (selectedProviderId === PINNED_PROVIDER_ID) {
// 显示固定的模型
modelsToShow = allModels.filter((m) => pinnedModels.includes(getModelUniqId(m)))
} else if (selectedProviderId) {
// 显示选中供应商的模型
const provider = providers.find((p) => p.id === selectedProviderId)
if (provider && provider.models) {
modelsToShow = provider.models.filter((m) => !isEmbeddingModel(m) && !isRerankModel(m))
await db.settings.put({ id: 'pinned:models', value: newPinnedModels })
setPinnedModels(sortBy(newPinnedModels, ['group', 'name']))
}
// 根据输入的文本筛选模型
const getFilteredModels = useCallback(
(provider) => {
let models = provider.models.filter((m) => !isEmbeddingModel(m) && !isRerankModel(m))
if (searchText.trim()) {
const keywords = searchText.toLowerCase().split(/\s+/).filter(Boolean)
models = models.filter((m) => {
const fullName = provider.isSystem
? `${m.name} ${provider.name} ${t('provider.' + provider.id)}`
: `${m.name} ${provider.name}`
const lowerFullName = fullName.toLowerCase()
return keywords.every((keyword) => lowerFullName.includes(keyword))
})
} else {
// 如果不是搜索状态,过滤掉已固定的模型
models = models.filter((m) => !pinnedModels.includes(getModelUniqId(m)))
}
return sortBy(models, ['group', 'name'])
},
[searchText, t, pinnedModels]
)
// 递归处理菜单项为每个项添加ref
const processMenuItems = useCallback(
(items: MenuItem[]) => {
// 内部定义 renderMenuItem 函数
const renderMenuItem = (item: any) => {
return {
...item,
label: <div ref={setMenuItemRef(item.key)}>{item.label}</div>
}
}
return items.map((item) => {
if (item && 'children' in item && item.children) {
return {
...item,
children: (item.children as MenuItem[]).map(renderMenuItem)
}
}
return item
})
},
[setMenuItemRef]
)
const filteredItems: MenuItem[] = providers
.filter((p) => p.models && p.models.length > 0)
.map((p) => {
const filteredModels = getFilteredModels(p).map((m) => ({
key: getModelUniqId(m),
label: (
<ModelItem>
<ModelNameRow>
<span>{m?.name}</span> <ModelTagsWithLabel model={m} size={11} showLabel={false} />
</ModelNameRow>
<PinIcon
onClick={(e) => {
e.stopPropagation()
togglePin(getModelUniqId(m))
}}
isPinned={pinnedModels.includes(getModelUniqId(m))}>
<PushpinOutlined />
</PinIcon>
</ModelItem>
),
icon: (
<Avatar src={getModelLogo(m?.id || '')} size={24}>
{first(m?.name)}
</Avatar>
),
onClick: () => {
resolve(m)
setOpen(false)
}
}))
// Only return the group if it has filtered models
return filteredModels.length > 0
? {
key: p.id,
label: p.isSystem ? t(`provider.${p.id}`) : p.name,
type: 'group',
children: filteredModels
}
: null
})
.filter(Boolean) as MenuItem[] // Filter out null items
if (pinnedModels.length > 0 && searchText.length === 0) {
const pinnedItems = providers
.flatMap((p) =>
p.models
.filter((m) => pinnedModels.includes(getModelUniqId(m)))
.map((m) => ({
key: getModelUniqId(m),
model: m,
provider: p
}))
)
.map((m) => ({
key: getModelUniqId(m.model) + '_pinned',
label: (
<ModelItem>
<ModelNameRow>
<span>
{m.model?.name} | {m.provider.isSystem ? t(`provider.${m.provider.id}`) : m.provider.name}
</span>{' '}
<ModelTagsWithLabel model={m.model} size={11} showLabel={false} />
</ModelNameRow>
<PinIcon
onClick={(e) => {
e.stopPropagation()
togglePin(getModelUniqId(m.model))
}}
isPinned={true}>
<PushpinOutlined />
</PinIcon>
</ModelItem>
),
icon: (
<Avatar src={getModelLogo(m.model?.id || '')} size={24}>
{first(m.model?.name)}
</Avatar>
),
onClick: () => {
resolve(m.model)
setOpen(false)
}
}))
if (pinnedItems.length > 0) {
filteredItems.unshift({
key: 'pinned',
label: t('models.pinned'),
type: 'group',
children: pinnedItems
} as MenuItem)
}
}
return sortBy(modelsToShow, ['group', 'name'])
}, [selectedProviderId, pinnedModels, searchText, allModels, providers, t])
// 处理菜单项添加ref
const processedItems = processMenuItems(filteredItems)
// --- Event Handlers ---
const handleProviderSelect = useCallback((providerId: string) => {
setSelectedProviderId(providerId)
}, [])
const handleModelSelect = useCallback((model: Model) => {
resolve(model)
const onCancel = () => {
setKeyboardSelectedId('')
setOpen(false)
}, [resolve, setOpen])
}
const onCancel = useCallback(() => {
setOpen(false)
}, [])
const onClose = useCallback(async () => {
const onClose = async () => {
setKeyboardSelectedId('')
resolve(undefined)
SelectModelPopup.hide()
}, [resolve])
}
// --- Focus Input on Open ---
useEffect(() => {
open && setTimeout(() => inputRef.current?.focus(), 0)
}, [open])
// --- Provider List for Left Column ---
const providerListItems = useMemo(() => {
const items: { id: string; name: string }[] = [
{ id: 'all', name: t('models.all') || '全部' } // 添加“全部”选项
]
if (pinnedModels.length > 0) {
items.push({ id: PINNED_PROVIDER_ID, name: t('models.pinned') })
useEffect(() => {
if (open && model) {
setTimeout(() => {
const modelId = getModelUniqId(model)
if (menuItemRefs.current[modelId]) {
menuItemRefs.current[modelId]?.scrollIntoView({ block: 'center', behavior: 'auto' })
}
}, 100) // Small delay to ensure menu is rendered
}
}, [open, model])
// 获取所有可见的模型项
const getVisibleModelItems = useCallback(() => {
const items: { key: string; model: Model }[] = []
// 如果有置顶模型且没有搜索文本,添加置顶模型
if (pinnedModels.length > 0 && searchText.length === 0) {
providers
.flatMap((p) => p.models || [])
.filter((m) => pinnedModels.includes(getModelUniqId(m)))
.forEach((m) => items.push({ key: getModelUniqId(m) + '_pinned', model: m }))
}
// 添加其他过滤后的模型
providers.forEach((p) => {
// Only add provider if it has non-embedding/rerank models
if (p.models?.some((m) => !isEmbeddingModel(m) && !isRerankModel(m))) {
items.push({ id: p.id, name: p.isSystem ? t(`provider.${p.id}`) : p.name })
if (p.models) {
getFilteredModels(p).forEach((m) => {
const modelId = getModelUniqId(m)
const isPinned = pinnedModels.includes(modelId)
// 搜索状态下,所有匹配的模型都应该可以被选中,包括固定的模型
// 非搜索状态下,只添加非固定模型(固定模型已在上面添加)
if (searchText.length > 0 || !isPinned) {
items.push({
key: modelId,
model: m
})
}
})
}
})
return items
}, [providers, pinnedModels, t])
// --- Render ---
return items
}, [pinnedModels, searchText, providers, getFilteredModels])
// 添加一个useLayoutEffect来处理滚动
useLayoutEffect(() => {
if (open && keyboardSelectedId && menuItemRefs.current[keyboardSelectedId]) {
// 获取当前选中元素和容器
const selectedElement = menuItemRefs.current[keyboardSelectedId]
const scrollContainer = scrollContainerRef.current
if (!scrollContainer) return
const selectedRect = selectedElement.getBoundingClientRect()
const containerRect = scrollContainer.getBoundingClientRect()
// 计算元素相对于容器的位置
const currentScrollTop = scrollContainer.scrollTop
const elementTop = selectedRect.top - containerRect.top + currentScrollTop
const groupTitleHeight = 30
// 确定滚动位置
if (selectedRect.top < containerRect.top + groupTitleHeight) {
// 元素被组标题遮挡,向上滚动
scrollContainer.scrollTo({
top: elementTop - groupTitleHeight,
behavior: 'smooth'
})
} else if (selectedRect.bottom > containerRect.bottom) {
// 元素在视口下方,向下滚动
scrollContainer.scrollTo({
top: elementTop - containerRect.height + selectedRect.height,
behavior: 'smooth'
})
}
}
}, [open, keyboardSelectedId])
// 处理键盘导航
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
const items = getVisibleModelItems()
if (items.length === 0) return
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault()
const currentIndex = items.findIndex((item) => item.key === keyboardSelectedId)
let nextIndex
if (currentIndex === -1) {
nextIndex = e.key === 'ArrowDown' ? 0 : items.length - 1
} else {
nextIndex =
e.key === 'ArrowDown' ? (currentIndex + 1) % items.length : (currentIndex - 1 + items.length) % items.length
}
const nextItem = items[nextIndex]
setKeyboardSelectedId(nextItem.key)
} else if (e.key === 'Enter') {
e.preventDefault() // 阻止回车的默认行为
if (keyboardSelectedId) {
const selectedItem = items.find((item) => item.key === keyboardSelectedId)
if (selectedItem) {
resolve(selectedItem.model)
setOpen(false)
}
}
}
},
[keyboardSelectedId, getVisibleModelItems, resolve, setOpen]
)
useEffect(() => {
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [handleKeyDown])
// 搜索文本改变时重置键盘选中状态
useEffect(() => {
setKeyboardSelectedId('')
}, [searchText])
const selectedKeys = keyboardSelectedId ? [keyboardSelectedId] : model ? [getModelUniqId(model)] : []
return (
<Modal
centered
open={open}
onCancel={onCancel}
afterClose={onClose}
transitionName=""
width={600}
transitionName="animation-move-down"
styles={{
content: {
borderRadius: 15, // Adjusted border radius
borderRadius: 20,
padding: 0,
overflow: 'hidden',
paddingBottom: 20,
border: '1px solid var(--color-border)'
},
body: {
padding: 0 // Remove default body padding
}
}}
closeIcon={null}
footer={null}
width={900} // 进一步增加宽度,使界面更宽敞
>
{/* Search Input */}
<SearchContainer onClick={() => inputRef.current?.focus()}>
<SearchInputContainer>
<Input
prefix={
<SearchIcon>
<SearchOutlined />
</SearchIcon>
footer={null}>
<HStack style={{ padding: '0 12px', marginTop: 5 }}>
<Input
prefix={
<SearchIcon>
<Search size={15} />
</SearchIcon>
}
ref={inputRef}
placeholder={t('models.search')}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
allowClear
autoFocus
style={{ paddingLeft: 0 }}
variant="borderless"
size="middle"
onKeyDown={(e) => {
// 防止上下键移动光标
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault()
}
ref={inputRef}
placeholder={t('models.search')}
value={searchText}
onChange={useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value
setSearchText(value)
// 当搜索时,自动选择"all"供应商,以显示所有匹配的模型
if (value.trim() && selectedProviderId !== 'all') {
setSelectedProviderId('all')
}
}, [selectedProviderId, t])}
// 移除焦点事件处理
allowClear
autoFocus
style={{
paddingLeft: 0,
height: '32px',
fontSize: '14px'
}}
variant="borderless"
size="middle"
/>
</SearchInputContainer>
</SearchContainer>
<Divider style={{ margin: 0, borderBlockStartWidth: 0.5, marginTop: -5 }} />
{/* Two Column Layout */}
<TwoColumnContainer>
{/* Left Column: Providers */}
<ProviderListColumn>
<Scrollbar style={{ height: '60vh', paddingRight: '5px' }}>
{providerListItems.map((provider, index) => (
<React.Fragment key={provider.id}>
<Tooltip title={provider.name} placement="right" mouseEnterDelay={0.5}>
<ProviderListItem
$selected={selectedProviderId === provider.id}
onClick={() => handleProviderSelect(provider.id)}>
<ProviderName>{provider.name}</ProviderName>
{provider.id === PINNED_PROVIDER_ID && <PinnedIcon />}
</ProviderListItem>
</Tooltip>
{/* 在每个供应商之后添加分割线,除了最后一个 */}
{index < providerListItems.length - 1 && <ProviderDivider />}
</React.Fragment>
))}
</Scrollbar>
</ProviderListColumn>
{/* Right Column: Models */}
<ModelListColumn>
<Scrollbar style={{ height: '60vh', paddingRight: '5px' }}>
{displayedModels.length > 0 ? (
displayedModels.map((m) => (
<ModelListItem
key={getModelUniqId(m)}
$selected={activeModel ? getModelUniqId(activeModel) === getModelUniqId(m) : false}
onClick={() => handleModelSelect(m)}>
<Avatar src={getModelLogo(m?.id || '')} size={24}>
{first(m?.name)}
</Avatar>
<ModelDetails>
<ModelNameRow>
<Tooltip title={m?.name} mouseEnterDelay={0.5}>
<span className="model-name">{m?.name}</span>
</Tooltip>
{/* Show provider only if not in pinned view or if search is active */}
{(selectedProviderId !== PINNED_PROVIDER_ID || searchText) && (
<Tooltip title={providers.find((p) => p.id === m.provider)?.name ?? m.provider} mouseEnterDelay={0.5}>
<span className="provider-name">
| {providers.find((p) => p.id === m.provider)?.name ?? m.provider}
</span>
</Tooltip>
)}
<ModelTags model={m} />
</ModelNameRow>
</ModelDetails>
<ActionButtons>
<ModelSettingsButton model={m} size={14} className="settings-button" />
<PinButton
$isPinned={pinnedModels.includes(getModelUniqId(m))}
onClick={(e) => {
e.stopPropagation() // Prevent model selection when clicking pin
togglePin(getModelUniqId(m))
}}>
<PushpinOutlined />
</PinButton>
</ActionButtons>
</ModelListItem>
))
) : (
<EmptyState>
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t('models.no_matches')} />
</EmptyState>
)}
</Scrollbar>
</ModelListColumn>
</TwoColumnContainer>
}}
/>
</HStack>
<Divider style={{ margin: 0, marginTop: 4, borderBlockStartWidth: 0.5 }} />
<Scrollbar style={{ height: '50vh' }} ref={scrollContainerRef}>
<Container>
{processedItems.length > 0 ? (
<StyledMenu
items={processedItems}
selectedKeys={selectedKeys}
mode="inline"
inlineIndent={6}
onSelect={({ key }) => {
setKeyboardSelectedId(key as string)
}}
/>
) : (
<EmptyState>
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
</EmptyState>
)}
</Container>
</Scrollbar>
</Modal>
)
}
// --- Styled Components ---
const SearchContainer = styled(HStack)`
padding: 8px 15px;
cursor: pointer;
const Container = styled.div`
margin-top: 10px;
`
const SearchInputContainer = styled.div`
width: 100%;
display: flex;
align-items: center;
const StyledMenu = styled(Menu)`
background-color: transparent;
padding: 5px;
margin-top: -10px;
max-height: calc(60vh - 50px);
.ant-menu-item-group-title {
position: sticky;
top: 0;
z-index: 1;
margin: 0 -5px;
padding: 5px 10px;
padding-left: 18px;
font-size: 12px;
font-weight: 500;
/* Scroll-driven animation for sticky header */
animation: background-change linear both;
animation-timeline: scroll();
animation-range: entry 0% entry 1%;
}
/* Simple animation that changes background color when sticky */
@keyframes background-change {
to {
background-color: var(--color-background);
}
}
.ant-menu-item {
height: 36px;
line-height: 36px;
&.ant-menu-item-selected {
background-color: var(--color-background-mute) !important;
color: var(--color-text-primary) !important;
}
&:not([data-menu-id^='pinned-']) {
.pin-icon {
opacity: 0;
}
&:hover {
.pin-icon {
opacity: 0.3;
}
}
}
.anticon {
min-width: auto;
}
}
`
const SearchIcon = styled.div`
width: 30px;
height: 30px;
border-radius: 50%;
const ModelItem = styled.div`
display: flex;
justify-content: center;
align-items: center;
background-color: var(--color-background-soft);
margin-right: 5px;
color: var(--color-icon);
font-size: 14px;
flex-shrink: 0;
&:hover {
background-color: var(--color-background-mute);
}
`
const TwoColumnContainer = styled.div`
display: flex;
height: 60vh; // 增加高度
`
const ProviderListColumn = styled.div`
width: 200px; // 减小宽度到200px
border-right: 0.5px solid var(--color-border);
padding: 15px 10px; // 减小内边距
box-sizing: border-box;
background-color: var(--color-background-soft); // Slight background difference
`
const ProviderListItem = styled.div<{ $selected: boolean }>`
padding: 10px 12px; // 增加上下内边距
cursor: pointer;
border-radius: 8px; // 减小圆角
margin-bottom: 8px; // 增加下边距
font-size: 14px; // 减小字体大小
font-weight: ${(props) => (props.$selected ? '600' : '400')};
background-color: ${(props) => (props.$selected ? 'var(--color-background-mute)' : 'transparent')};
color: ${(props) => (props.$selected ? 'var(--color-text-primary)' : 'var(--color-text)')};
display: flex;
align-items: center;
justify-content: space-between; // To push pin icon to the right for "Pinned"
overflow: hidden; // 防止文本溢出
text-overflow: ellipsis; // 溢出显示省略号
white-space: nowrap; // 不换行
&:hover {
background-color: var(--color-background-mute);
}
`
const ModelListColumn = styled.div`
flex: 1;
padding: 12px; // 减小内边距
box-sizing: border-box;
`
const ModelListItem = styled.div<{ $selected: boolean }>`
display: flex;
align-items: center;
padding: 8px 12px; // 进一步减小内边距
margin-bottom: 6px; // 进一步减小下边距
border-radius: 6px; // 进一步减小圆角
cursor: pointer;
background-color: ${(props) => (props.$selected ? 'var(--color-background-mute)' : 'transparent')};
&:hover {
background-color: var(--color-background-mute);
.pin-button, .settings-button {
opacity: 0.5; // Show buttons on hover
}
}
.pin-button, .settings-button {
opacity: ${(props) => (props.$selected ? 0.5 : 0)}; // Show if selected or hovered
transition: opacity 0.2s;
&:hover {
opacity: 1 !important; // Full opacity on direct hover
}
}
`
const ModelDetails = styled.div`
margin-left: 10px; // 进一步减小左边距
flex: 1;
overflow: hidden; // Prevent long names from breaking layout
position: relative;
width: 100%;
`
const ModelNameRow = styled.div`
display: flex;
flex-direction: row;
align-items: center;
gap: 6px; // 进一步减小间距
font-size: 13px; // 进一步减小字体大小
.model-name {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 160px; // 进一步减小最大宽度
}
.provider-name {
color: var(--color-text-secondary);
font-size: 11px; // 进一步减小字体大小
white-space: nowrap;
overflow: hidden; // 防止文本溢出
text-overflow: ellipsis; // 溢出显示省略号
max-width: 120px; // 增加最大宽度
}
`
const ActionButtons = styled.div`
display: flex;
align-items: center;
gap: 4px; // 进一步减小间距
margin-left: auto; // Push to the right
`
const PinButton = styled.button<{ $isPinned: boolean }>`
background: none;
border: none;
cursor: pointer;
padding: 4px; // 进一步减小内边距
color: ${(props) => (props.$isPinned ? 'var(--color-primary)' : 'var(--color-icon)')};
transform: ${(props) => (props.$isPinned ? 'rotate(-45deg)' : 'none')};
font-size: 14px; // 进一步减小图标大小
line-height: 1; // Ensure icon aligns well
&:hover {
color: ${(props) => (props.$isPinned ? 'var(--color-primary)' : 'var(--color-text-primary)')};
}
gap: 8px;
`
const EmptyState = styled.div`
display: flex;
justify-content: center;
align-items: center;
height: 100%;
color: var(--color-text-secondary);
height: 200px;
`
const ProviderName = styled.span`
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
const SearchIcon = styled.div`
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
background-color: var(--color-background-soft);
margin-right: 2px;
`
const PinnedIcon = styled(PushpinOutlined)`
const PinIcon = styled.span.attrs({ className: 'pin-icon' })<{ isPinned: boolean }>`
margin-left: auto;
flex-shrink: 0;
padding: 0 8px;
opacity: ${(props) => (props.isPinned ? 1 : 'inherit')};
transition: opacity 0.2s;
position: absolute;
right: 0;
color: ${(props) => (props.isPinned ? 'var(--color-primary)' : 'inherit')};
transform: ${(props) => (props.isPinned ? 'rotate(-45deg)' : 'none')};
&:hover {
opacity: 1 !important;
color: ${(props) => (props.isPinned ? 'var(--color-primary)' : 'inherit')};
}
`
const ProviderDivider = styled.div`
height: 1px;
background-color: var(--color-border);
margin: 8px 0;
opacity: 0.5;
`
// --- Export Class ---
export default class SelectModelPopup {
static hide() {
TopView.hide('SelectModelPopup')
}
static show(params: Props) {
return new Promise<Model | undefined>((resolve) => {
// 直接显示新的弹窗不使用setTimeout
TopView.show(<PopupContainer {...params} resolve={resolve} />, 'SelectModelPopup')
})
}

View File

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

View File

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

View File

@@ -7,7 +7,7 @@ import { setAvatar } from '@renderer/store/runtime'
import { setUserName } from '@renderer/store/settings'
import { compressImage, isEmoji } from '@renderer/utils'
import { Avatar, Dropdown, Input, Modal, Popover, Upload } from 'antd'
import { useState } from 'react'
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@@ -127,7 +127,7 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
onOk={onOk}
onCancel={onCancel}
afterClose={onClose}
transitionName="ant-move-down"
transitionName="animation-move-down"
centered>
<Center mt="30px">
<VStack alignItems="center" gap="10px">

View File

@@ -1,10 +1,11 @@
import { CheckOutlined, RightOutlined } from '@ant-design/icons'
import { RightOutlined } from '@ant-design/icons'
import { isMac } from '@renderer/config/constant'
import { classNames } from '@renderer/utils'
import { Flex } from 'antd'
import { theme } from 'antd'
import Color from 'color'
import { t } from 'i18next'
import { Check } from 'lucide-react'
import React, { use, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
import styled from 'styled-components'
import * as tinyPinyin from 'tiny-pinyin'
@@ -81,14 +82,19 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
return true
}
const pattern = lowerSearchText.split('').join('.*')
if (tinyPinyin.isSupported() && /[\u4e00-\u9fa5]/.test(filterText)) {
const pinyinText = tinyPinyin.convertToPinyin(filterText, '', true)
if (pinyinText.toLowerCase().includes(lowerSearchText)) {
try {
const pinyinText = tinyPinyin.convertToPinyin(filterText, '', true).toLowerCase()
const regex = new RegExp(pattern, 'ig')
return regex.test(pinyinText)
} catch (error) {
return true
}
} else {
const regex = new RegExp(pattern, 'ig')
return regex.test(filterText.toLowerCase())
}
return false
})
setIndex(newList.length > 0 ? ctx.defaultIndex || 0 : -1)
@@ -205,6 +211,8 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement
const handleInput = (e: Event) => {
if (isComposing.current) return
const target = e.target as HTMLTextAreaElement
const cursorPosition = target.selectionStart
const textBeforeCursor = target.value.slice(0, cursorPosition)
@@ -224,8 +232,9 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
isComposing.current = true
}
const handleCompositionEnd = () => {
const handleCompositionEnd = (e: CompositionEvent) => {
isComposing.current = false
handleInput(e)
}
textArea.addEventListener('input', handleInput)
@@ -350,6 +359,7 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
break
case 'Enter':
case 'NumpadEnter':
if (isComposing.current) return
if (list?.[index]) {
@@ -443,7 +453,7 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
{item.suffix ? (
item.suffix
) : item.isSelected ? (
<CheckOutlined />
<Check />
) : (
item.isMenu && !item.disabled && <RightOutlined />
)}
@@ -545,6 +555,7 @@ const QuickPanelBody = styled.div`
background-color: rgba(240, 240, 240, 0.5);
backdrop-filter: blur(35px) saturate(150%);
z-index: -1;
border-radius: inherit;
body[theme-mode='dark'] & {
background-color: rgba(40, 40, 40, 0.4);
@@ -567,12 +578,12 @@ const QuickPanelFooterTips = styled.div<{ $footerWidth: number }>`
justify-content: flex-end;
flex-shrink: 0;
gap: 16px;
font-size: 10px;
font-size: 12px;
color: var(--color-text-3);
`
const QuickPanelFooterTitle = styled.div`
font-size: 11px;
font-size: 12px;
color: var(--color-text-3);
overflow: hidden;
text-overflow: ellipsis;
@@ -603,6 +614,7 @@ const QuickPanelItem = styled.div`
cursor: pointer;
transition: background-color 0.1s ease;
margin-bottom: 1px;
font-family: Ubuntu;
&.selected {
background-color: var(--selected-color);
&.focused {
@@ -629,13 +641,22 @@ const QuickPanelItemLeft = styled.div`
`
const QuickPanelItemIcon = styled.span`
font-size: 12px;
font-size: 13px;
color: var(--color-text-3);
display: flex;
align-items: center;
justify-content: center;
> svg {
width: 1em;
height: 1em;
color: var(--color-text-3);
}
`
const QuickPanelItemLabel = styled.span`
flex: 1;
font-size: 12px;
font-size: 13px;
line-height: 16px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -665,4 +686,9 @@ const QuickPanelItemSuffixIcon = styled.span`
align-items: center;
justify-content: flex-end;
gap: 3px;
> svg {
width: 1em;
height: 1em;
color: var(--color-text-3);
}
`

View File

@@ -1,10 +1,11 @@
import { LoadingOutlined, TranslationOutlined } from '@ant-design/icons'
import { LoadingOutlined } from '@ant-design/icons'
import { useDefaultModel } from '@renderer/hooks/useAssistant'
import { useSettings } from '@renderer/hooks/useSettings'
import { fetchTranslate } from '@renderer/services/ApiService'
import { getDefaultTopic, getDefaultTranslateAssistant } from '@renderer/services/AssistantService'
import { getUserMessage } from '@renderer/services/MessagesService'
import { Button, Tooltip } from 'antd'
import { Languages } from 'lucide-react'
import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@@ -82,7 +83,7 @@ const TranslateButton: FC<Props> = ({ text, onTranslated, disabled, style, isLoa
title={t('chat.input.translate', { target_language: t(`languages.${targetLanguage.toString()}`) })}
arrow>
<ToolbarButton onClick={handleTranslate} disabled={disabled || isTranslating} style={style} type="text">
{isTranslating ? <LoadingOutlined spin /> : <TranslationOutlined />}
{isTranslating ? <LoadingOutlined spin /> : <Languages size={18} />}
</ToolbarButton>
</Tooltip>
)

View File

@@ -0,0 +1,286 @@
import { DeleteOutlined, ExclamationCircleOutlined, ReloadOutlined } from '@ant-design/icons'
import { restoreFromWebdav } from '@renderer/services/BackupService'
import { formatFileSize } from '@renderer/utils'
import { Button, message, Modal, Table, Tooltip } from 'antd'
import dayjs from 'dayjs'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
interface BackupFile {
fileName: string
modifiedTime: string
size: number
}
interface WebdavConfig {
webdavHost: string
webdavUser: string
webdavPass: string
webdavPath: string
}
interface WebdavBackupManagerProps {
visible: boolean
onClose: () => void
webdavConfig: {
webdavHost?: string
webdavUser?: string
webdavPass?: string
webdavPath?: string
}
restoreMethod?: (fileName: string) => Promise<void>
}
export function WebdavBackupManager({ visible, onClose, webdavConfig, restoreMethod }: WebdavBackupManagerProps) {
const { t } = useTranslation()
const [backupFiles, setBackupFiles] = useState<BackupFile[]>([])
const [loading, setLoading] = useState(false)
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([])
const [deleting, setDeleting] = useState(false)
const [restoring, setRestoring] = useState(false)
const [pagination, setPagination] = useState({
current: 1,
pageSize: 5,
total: 0
})
const { webdavHost, webdavUser, webdavPass, webdavPath } = webdavConfig
const fetchBackupFiles = useCallback(async () => {
if (!webdavHost || !webdavUser || !webdavPass || !webdavPath) {
message.error(t('message.error.invalid.webdav'))
return
}
setLoading(true)
try {
const files = await window.api.backup.listWebdavFiles({
webdavHost,
webdavUser,
webdavPass,
webdavPath
} as WebdavConfig)
setBackupFiles(files)
setPagination((prev) => ({
...prev,
total: files.length
}))
} catch (error: any) {
message.error(`${t('settings.data.webdav.backup.manager.fetch.error')}: ${error.message}`)
} finally {
setLoading(false)
}
}, [webdavHost, webdavUser, webdavPass, webdavPath, t])
useEffect(() => {
if (visible) {
fetchBackupFiles()
setSelectedRowKeys([])
setPagination((prev) => ({
...prev,
current: 1
}))
}
}, [visible, fetchBackupFiles])
const handleTableChange = (pagination: any) => {
setPagination(pagination)
}
const handleDeleteSelected = async () => {
if (selectedRowKeys.length === 0) {
message.warning(t('settings.data.webdav.backup.manager.select.files.delete'))
return
}
if (!webdavHost || !webdavUser || !webdavPass || !webdavPath) {
message.error(t('message.error.invalid.webdav'))
return
}
window.modal.confirm({
title: t('settings.data.webdav.backup.manager.delete.confirm.title'),
icon: <ExclamationCircleOutlined />,
content: t('settings.data.webdav.backup.manager.delete.confirm.multiple', { count: selectedRowKeys.length }),
okText: t('common.confirm'),
cancelText: t('common.cancel'),
centered: true,
onOk: async () => {
setDeleting(true)
try {
// 依次删除选中的文件
for (const key of selectedRowKeys) {
await window.api.backup.deleteWebdavFile(key.toString(), {
webdavHost,
webdavUser,
webdavPass,
webdavPath
} as WebdavConfig)
}
message.success(
t('settings.data.webdav.backup.manager.delete.success.multiple', { count: selectedRowKeys.length })
)
setSelectedRowKeys([])
await fetchBackupFiles()
} catch (error: any) {
message.error(`${t('settings.data.webdav.backup.manager.delete.error')}: ${error.message}`)
} finally {
setDeleting(false)
}
}
})
}
const handleDeleteSingle = async (fileName: string) => {
if (!webdavHost || !webdavUser || !webdavPass || !webdavPath) {
message.error(t('message.error.invalid.webdav'))
return
}
window.modal.confirm({
title: t('settings.data.webdav.backup.manager.delete.confirm.title'),
icon: <ExclamationCircleOutlined />,
content: t('settings.data.webdav.backup.manager.delete.confirm.single', { fileName }),
okText: t('common.confirm'),
cancelText: t('common.cancel'),
centered: true,
onOk: async () => {
setDeleting(true)
try {
await window.api.backup.deleteWebdavFile(fileName, {
webdavHost,
webdavUser,
webdavPass,
webdavPath
} as WebdavConfig)
message.success(t('settings.data.webdav.backup.manager.delete.success.single'))
await fetchBackupFiles()
} catch (error: any) {
message.error(`${t('settings.data.webdav.backup.manager.delete.error')}: ${error.message}`)
} finally {
setDeleting(false)
}
}
})
}
const handleRestore = async (fileName: string) => {
if (!webdavHost || !webdavUser || !webdavPass || !webdavPath) {
message.error(t('message.error.invalid.webdav'))
return
}
window.modal.confirm({
title: t('settings.data.webdav.restore.confirm.title'),
icon: <ExclamationCircleOutlined />,
content: t('settings.data.webdav.restore.confirm.content'),
okText: t('common.confirm'),
cancelText: t('common.cancel'),
centered: true,
onOk: async () => {
setRestoring(true)
try {
await (restoreMethod || restoreFromWebdav)(fileName)
message.success(t('settings.data.webdav.backup.manager.restore.success'))
onClose() // 关闭模态框
} catch (error: any) {
message.error(`${t('settings.data.webdav.backup.manager.restore.error')}: ${error.message}`)
} finally {
setRestoring(false)
}
}
})
}
const columns = [
{
title: t('settings.data.webdav.backup.manager.columns.fileName'),
dataIndex: 'fileName',
key: 'fileName',
ellipsis: {
showTitle: false
},
render: (fileName: string) => (
<Tooltip placement="topLeft" title={fileName}>
{fileName}
</Tooltip>
)
},
{
title: t('settings.data.webdav.backup.manager.columns.modifiedTime'),
dataIndex: 'modifiedTime',
key: 'modifiedTime',
width: 180,
render: (time: string) => dayjs(time).format('YYYY-MM-DD HH:mm:ss')
},
{
title: t('settings.data.webdav.backup.manager.columns.size'),
dataIndex: 'size',
key: 'size',
width: 120,
render: (size: number) => formatFileSize(size)
},
{
title: t('settings.data.webdav.backup.manager.columns.actions'),
key: 'action',
width: 160,
render: (_: any, record: BackupFile) => (
<>
<Button type="link" onClick={() => handleRestore(record.fileName)} disabled={restoring || deleting}>
{t('settings.data.webdav.backup.manager.restore.text')}
</Button>
<Button
type="link"
danger
onClick={() => handleDeleteSingle(record.fileName)}
disabled={deleting || restoring}>
{t('settings.data.webdav.backup.manager.delete.text')}
</Button>
</>
)
}
]
const rowSelection = {
selectedRowKeys,
onChange: (selectedRowKeys: React.Key[]) => {
setSelectedRowKeys(selectedRowKeys)
}
}
return (
<Modal
title={t('settings.data.webdav.backup.manager.title')}
open={visible}
onCancel={onClose}
width={800}
footer={[
<Button key="refresh" icon={<ReloadOutlined />} onClick={fetchBackupFiles} disabled={loading}>
{t('settings.data.webdav.backup.manager.refresh')}
</Button>,
<Button
key="delete"
danger
icon={<DeleteOutlined />}
onClick={handleDeleteSelected}
disabled={selectedRowKeys.length === 0 || deleting}
loading={deleting}>
{t('settings.data.webdav.backup.manager.delete.selected')} ({selectedRowKeys.length})
</Button>,
<Button key="close" onClick={onClose}>
{t('common.close')}
</Button>
]}>
<Table
rowKey="fileName"
columns={columns}
dataSource={backupFiles}
rowSelection={rowSelection}
pagination={pagination}
loading={loading}
onChange={handleTableChange}
size="middle"
/>
</Modal>
)
}

View File

@@ -42,8 +42,9 @@ export function useWebdavBackupModal({ backupMethod }: { backupMethod?: typeof b
const showBackupModal = useCallback(async () => {
// 获取默认文件名
const deviceType = await window.api.system.getDeviceType()
const hostname = await window.api.system.getHostname()
const timestamp = dayjs().format('YYYYMMDDHHmmss')
const defaultFileName = `cherry-studio.${timestamp}.${deviceType}.zip`
const defaultFileName = `cherry-studio.${timestamp}.${hostname}.${deviceType}.zip`
setCustomFileName(defaultFileName)
setIsModalVisible(true)
}, [])

View File

@@ -1,10 +1,3 @@
import {
FileSearchOutlined,
FolderOutlined,
PictureOutlined,
QuestionCircleOutlined,
TranslationOutlined
} from '@ant-design/icons'
import { isMac } from '@renderer/config/constant'
import { AppLogo, UserAvatar } from '@renderer/config/env'
import { useTheme } from '@renderer/context/ThemeProvider'
@@ -17,6 +10,19 @@ import { useSettings } from '@renderer/hooks/useSettings'
import { isEmoji } from '@renderer/utils'
import type { MenuProps } from 'antd'
import { Avatar, Dropdown, Tooltip } from 'antd'
import {
CircleHelp,
FileSearch,
Folder,
Languages,
LayoutGrid,
MessageSquareQuote,
Moon,
Palette,
Settings,
Sparkle,
Sun
} from 'lucide-react'
import { FC, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useLocation, useNavigate } from 'react-router-dom'
@@ -84,7 +90,7 @@ const Sidebar: FC = () => {
<Menus>
<Tooltip title={t('docs.title')} mouseEnterDelay={0.8} placement="right">
<Icon theme={theme} onClick={onOpenDocs} className={minappShow && currentMinappId === docsId ? 'active' : ''}>
<QuestionCircleOutlined />
<CircleHelp size={20} className="icon" />
</Icon>
</Tooltip>
<Tooltip
@@ -92,22 +98,17 @@ const Sidebar: FC = () => {
mouseEnterDelay={0.8}
placement="right">
<Icon theme={theme} onClick={() => toggleTheme()}>
{theme === 'dark' ? (
<i className="iconfont icon-theme icon-dark1" />
) : (
<i className="iconfont icon-theme icon-theme-light" />
)}
{theme === 'dark' ? <Moon size={20} className="icon" /> : <Sun size={20} className="icon" />}
</Icon>
</Tooltip>
<Tooltip title={t('settings.title')} mouseEnterDelay={0.8} placement="right">
<StyledLink
onClick={async () => {
hideMinappPopup()
await modelGenerating()
await to('/settings/provider')
}}>
<Icon theme={theme} className={pathname.startsWith('/settings') && !minappShow ? 'active' : ''}>
<i className="iconfont icon-setting" />
<Settings size={20} className="icon" />
</Icon>
</StyledLink>
</Tooltip>
@@ -129,13 +130,13 @@ const MainMenus: FC = () => {
const isRoutes = (path: string): string => (pathname.startsWith(path) && !minappShow ? 'active' : '')
const iconMap = {
assistants: <i className="iconfont icon-chat" />,
agents: <i className="iconfont icon-business-smart-assistant" />,
paintings: <PictureOutlined style={{ fontSize: 16 }} />,
translate: <TranslationOutlined />,
minapp: <i className="iconfont icon-appstore" />,
knowledge: <FileSearchOutlined />,
files: <FolderOutlined />
assistants: <MessageSquareQuote size={18} className="icon" />,
agents: <Sparkle size={18} className="icon" />,
paintings: <Palette size={18} className="icon" />,
translate: <Languages size={18} className="icon" />,
minapp: <LayoutGrid size={18} className="icon" />,
knowledge: <FileSearch size={18} className="icon" />,
files: <Folder size={17} className="icon" />
}
const pathMap = {
@@ -247,7 +248,7 @@ const SidebarOpenedMinappTabs: FC = () => {
theme={theme}
onClick={() => handleOnClick(app)}
className={`${isActive ? 'opened-active' : ''}`}>
<MinAppIcon size={20} app={app} style={{ borderRadius: 6 }} />
<MinAppIcon size={20} app={app} style={{ borderRadius: 6 }} sidebar />
</Icon>
</Dropdown>
</StyledLink>
@@ -289,7 +290,7 @@ const PinnedApps: FC = () => {
theme={theme}
onClick={() => openMinappKeepAlive(app)}
className={`${isActive ? 'active' : ''} ${openedKeepAliveMinapps.some((item) => item.id === app.id) ? 'opened-minapp' : ''}`}>
<MinAppIcon size={20} app={app} style={{ borderRadius: 6 }} />
<MinAppIcon size={20} app={app} style={{ borderRadius: 6 }} sidebar />
</Icon>
</Dropdown>
</StyledLink>
@@ -364,30 +365,19 @@ const Icon = styled.div<{ theme: string }>`
box-sizing: border-box;
-webkit-app-region: none;
border: 0.5px solid transparent;
.iconfont,
.anticon {
color: var(--color-icon);
font-size: 20px;
text-decoration: none;
}
.anticon {
font-size: 17px;
}
&:hover {
background-color: ${({ theme }) => (theme === 'dark' ? 'var(--color-black)' : 'var(--color-white)')};
opacity: 0.8;
cursor: pointer;
.iconfont,
.anticon {
.icon {
color: var(--color-icon-white);
}
}
&.active {
background-color: ${({ theme }) => (theme === 'dark' ? 'var(--color-black)' : 'var(--color-white)')};
border: 0.5px solid var(--color-border);
.iconfont,
.anticon {
color: var(--color-icon-white);
.icon {
color: var(--color-primary);
}
}

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