Compare commits

...

65 Commits

Author SHA1 Message Date
kangfenmao
a3e10dd116 chore(version): 1.2.0 2025-04-08 20:29:54 +08:00
SuYao
ab1a5f18c9 feat(websearch): add overwrite functionality for search service (#4530)
* feat(websearch): add overwrite functionality for search service

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

* feat(websearch): enhance web search model integration

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

* compressed logo file

---------

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

* style(ProviderSettings): Refactor ProviderSettings UI

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

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

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

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

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

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

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

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

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

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

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

* chore: remove hover models color

---------

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

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

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

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

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

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

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

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

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

* chore: remove debug logging from ProxyManager

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

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

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

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

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

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

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

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

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

* feat(export): optimize reasoning style

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

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

---------

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

* refactor: use requestIdleCallback rather than observer

* refactor: simplify setting expanded and unwrapped

* refactor: simplify logic

* refactor: revert to observer

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

* feat: add lru cache for syntax highlighting

* refactor: adjust cache options

* feat: add highlighter cache

* fix: highlighter should be loaded before highlighting

* refactor: reduce cache time

* refactor: adjust cache size and hash

* refactor: decrease cache size

* fix: restore the behaviour of ShowExpandButton

* fix: check streaming status

* fix: empty code

* refactor: improve streaming check

* fix: optimizeDeps excludes

* refactor: adjust cache policy

* feat: add a setting for code caching

* feat: add more settings for code cache

* fix: initialize service

* refactor: prevent accident cache reset, update settings

* refactor: update code cache service

* fix: revert unecessary changes

* refactor: adjust cache settings

* fix: update migrate version

* chore: update to shiki v3

* fix: import path

* refactor: remove highlighter cache, improve fallbacks

* fix: revert path changes

* style: fix lint errors

* style: improve readability

* style: improve readability

* chore: update migrate version

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

- 在 HomeWindow.tsx 和 useAppInit.ts 文件中移除了重复的 defaultLanguage导入语句
- 这个改动简化了代码结构,提高了代码的可读性和维护性
2025-04-04 19:07:23 +08:00
lizhixuan
ef8250ab72 refactor(MCPSettings): replace MainContent callback with useMemo for performance optimization 2025-04-04 18:59:54 +08:00
kangfenmao
773c0da9ef chore(version): 1.1.18 2025-04-04 12:07:05 +08:00
kangfenmao
a3d124b9fd feat(migrate): update migration logic to remove specific mini app from state and increment version to 89 2025-04-04 11:54:24 +08:00
kangfenmao
c11adc01dc fix(SelectModelPopup): set popup width to 600 for improved layout 2025-04-04 11:49:59 +08:00
kangfenmao
d8baf378ea refactor(Navbar): replace FormOutlined with SearchOutlined and update tooltip functionality 2025-04-04 10:54:21 +08:00
kangfenmao
3768c135d8 fix(Inputbar): enhance message usage estimation and clean up code structure 2025-04-04 10:34:08 +08:00
LiuVaayne
10848f7a45 feat: mcp tool permissions (#4348)
* feat(MCPSettings): add tool toggle functionality and update server configuration

* fix(McpSettings): improve server type handling and tool fetching logic
2025-04-04 09:57:54 +08:00
fullex
4d5cfe06f5 feat: render markdown when show assistant prompt (#4365)
* feat: render markdown when show assistant prompt

* fix: polish user experience
2025-04-04 09:37:29 +08:00
George·Dong
fb5ddaf9d5 fix(ChatNavigation): improve navigation button collapse functionality 2025-04-04 00:07:36 +08:00
fullex
8cb11e6d55 fix: webdav backup resume and modal problem 2025-04-03 23:09:59 +08:00
ousugo
d0cb333f3c fix(TopicsTab): Topic prompt word modification does not take effect immediately 2025-04-03 23:03:32 +08:00
MyPrototypeWhat
23de48ecbd feat(mcp): add registryUrl to initialState for improved package manag… (#4279)
* feat(mcp): add registryUrl to initialState for improved package management

* feat(mcp): initialize registryUrl in MCP server state for future enhancements

* fix(inputbar):mcp server list

* refactor(Inputbar): remove unused MCP server variable and console log for cleaner code

* fix(Inputbar): 还原

* fix(Inputbar): Add activedMcpServers to Inputbar component props
2025-04-03 18:51:29 +08:00
BlBana
06c730aaf6 fix(Inputbar): Solve the problem that the initial state of assistant mcpServers is empty, and can not get enable mcp servers. 2025-04-03 17:45:14 +08:00
ousugo
aed9c04c20 fix(minapps): remove AI Studio entry from default mini apps list 2025-04-03 17:41:13 +08:00
Asurada
d067d21561 feat(Anthropic): Enable Anthropic 128k context beta feature (#2887) 2025-04-03 17:16:36 +08:00
Camol
515721239f fix(nutstore): restore from nutstore #4318 (#4334)
Co-authored-by: kanweiwei <kanweiwei@nutstore.net>
2025-04-03 10:56:05 +08:00
178 changed files with 144031 additions and 3520 deletions

View File

@@ -6,8 +6,8 @@ body:
- type: markdown
attributes:
value: |
Thank you for taking the time to fill out this bug report!
Before submitting this issue, please make sure that you have understood the [FAQ](https://docs.cherry-ai.com/question-contact/questions) and [Knowledge Science](https://docs.cherry-ai.com/question-contact/knowledge)
Thank you for taking the time to fill out this bug report!
Before submitting this issue, please make sure that you have understood the [FAQ](https://docs.cherry-ai.com/question-contact/questions) and [Knowledge Science](https://docs.cherry-ai.com/question-contact/knowledge)
- type: checkboxes
id: checklist

View File

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

View File

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

1
.gitignore vendored
View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

@@ -84,7 +84,7 @@ https://docs.cherry-ai.com
# 🌈 Theme
- Theme Gallery: https://cherrycss.com
- Theme Gallery: https://cherrycss.com
- Aero Theme: https://github.com/hakadao/CherryStudio-Aero
- PaperMaterial Theme: https://github.com/rainoffallingstar/CherryStudio-PaperMaterial

BIN
build/icons/1024x1024.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

BIN
build/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

BIN
build/icons/16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 621 B

BIN
build/icons/24x24.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

BIN
build/icons/256x256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
build/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
build/icons/48x48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

BIN
build/icons/512x512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

BIN
build/icons/64x64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@@ -32,18 +32,20 @@ asarUnpack:
- '**/*.{node,dll,metal,exp,lib}'
win:
executableName: Cherry Studio
artifactName: ${productName}-${version}-portable.${ext}
artifactName: ${productName}-${version}-${arch}-setup.${ext}
target:
- target: nsis
- target: portable
nsis:
artifactName: ${productName}-${version}-setup.${ext}
artifactName: ${productName}-${version}-${arch}-setup.${ext}
shortcutName: ${productName}
uninstallDisplayName: ${productName}
createDesktopShortcut: always
allowToChangeInstallationDirectory: true
oneClick: false
include: build/nsis-installer.nsh
portable:
artifactName: ${productName}-${version}-${arch}-portable.${ext}
mac:
entitlementsInherit: build/entitlements.mac.plist
notarize: false
@@ -83,7 +85,7 @@ afterPack: scripts/after-pack.js
afterSign: scripts/notarize.js
releaseInfo:
releaseNotes: |
小程序支持多开
支持 GPT-4o 图像生成
修复 MCP 服务器无法使用问题
修复升级导致旧版本数据丢失问题
知识库和服务商界面更新
增加 Dangbei 小程序
可以强制使用搜索引擎覆盖模型自带搜索能力
修复部分公式无法正常渲染问题

View File

@@ -42,7 +42,12 @@ export default defineConfig({
}
},
preload: {
plugins: [externalizeDepsPlugin()]
plugins: [externalizeDepsPlugin()],
resolve: {
alias: {
'@shared': resolve('packages/shared')
}
}
},
renderer: {
plugins: [
@@ -70,7 +75,7 @@ export default defineConfig({
}
},
optimizeDeps: {
exclude: ['chunk-PZ64DZKH.js', 'chunk-JMKENWIY.js', 'chunk-UXYB6GHG.js', 'chunk-ALDIEZMG.js', 'chunk-4X6ZJEXY.js']
exclude: []
}
}
})

View File

@@ -1,6 +1,6 @@
{
"name": "CherryStudio",
"version": "1.1.17",
"version": "1.2.0",
"private": true,
"description": "A powerful AI assistant for producer.",
"main": "./out/main/index.js",
@@ -25,6 +25,7 @@
"build:unpack": "dotenv npm run build && electron-builder --dir",
"build:win": "dotenv npm run build && electron-builder --win",
"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:arm64": "dotenv electron-vite build && electron-builder --mac --arm64",
"build:mac:x64": "dotenv electron-vite build && electron-builder --mac --x64",
@@ -66,10 +67,12 @@
"@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",
"diff": "^7.0.0",
"docx": "^9.0.2",
"electron-log": "^5.1.5",
"electron-store": "^8.2.0",
@@ -79,10 +82,14 @@
"fast-xml-parser": "^5.0.9",
"fetch-socks": "^1.3.2",
"fs-extra": "^11.2.0",
"got-scraping": "^4.1.1",
"jsdom": "^26.0.0",
"markdown-it": "^14.1.0",
"officeparser": "^4.1.1",
"proxy-agent": "^6.5.0",
"tar": "^7.4.3",
"turndown": "^7.2.0",
"turndown-plugin-gfm": "^1.0.2",
"undici": "^7.4.0",
"webdav": "^5.8.0",
"zipread": "^1.3.3"
@@ -91,6 +98,7 @@
"@agentic/exa": "^7.3.3",
"@agentic/searxng": "^7.3.3",
"@agentic/tavily": "^7.3.3",
"@analytics/google-analytics": "^1.1.0",
"@ant-design/v5-patch-for-react-19": "^1.0.3",
"@anthropic-ai/sdk": "^0.38.0",
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
@@ -109,6 +117,7 @@
"@tavily/core": "patch:@tavily/core@npm%3A0.3.1#~/.yarn/patches/@tavily-core-npm-0.3.1-fe69bf2bea.patch",
"@tryfabric/martian": "^1.2.4",
"@types/adm-zip": "^0",
"@types/diff": "^7",
"@types/fs-extra": "^11",
"@types/lodash": "^4.17.5",
"@types/markdown-it": "^14",
@@ -120,6 +129,7 @@
"@types/react-infinite-scroll-component": "^5.0.0",
"@types/tinycolor2": "^1",
"@vitejs/plugin-react": "^4.2.1",
"analytics": "^0.8.16",
"antd": "^5.22.5",
"applescript": "^1.0.0",
"axios": "^1.7.3",
@@ -145,9 +155,10 @@
"i18next": "^23.11.5",
"lint-staged": "^15.5.0",
"lodash": "^4.17.21",
"lru-cache": "^11.1.0",
"mime": "^4.0.4",
"npx-scope-finder": "^1.2.0",
"openai": "patch:openai@npm%3A4.77.3#~/.yarn/patches/openai-npm-4.77.3-59c6d42e7a.patch",
"openai": "patch:openai@npm%3A4.87.3#~/.yarn/patches/openai-npm-4.87.3-2b30a7685f.patch",
"p-queue": "^8.1.0",
"prettier": "^3.5.3",
"rc-virtual-list": "^3.18.5",
@@ -171,7 +182,7 @@
"remark-math": "^6.0.0",
"rollup-plugin-visualizer": "^5.12.0",
"sass": "^1.77.2",
"shiki": "^1.22.2",
"shiki": "^3.2.1",
"string-width": "^7.2.0",
"styled-components": "^6.1.11",
"tinycolor2": "^1.6.0",
@@ -184,7 +195,9 @@
"pdf-parse@npm:1.1.1": "patch:pdf-parse@npm%3A1.1.1#~/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch",
"@langchain/openai@npm:^0.3.16": "patch:@langchain/openai@npm%3A0.3.16#~/.yarn/patches/@langchain-openai-npm-0.3.16-e525b59526.patch",
"@langchain/openai@npm:>=0.1.0 <0.4.0": "patch:@langchain/openai@npm%3A0.3.16#~/.yarn/patches/@langchain-openai-npm-0.3.16-e525b59526.patch",
"openai@npm:^4.77.0": "patch:openai@npm%3A4.77.3#~/.yarn/patches/openai-npm-4.77.3-59c6d42e7a.patch",
"node-gyp": "^9.1.0",
"libsql@npm:^0.4.4": "patch:libsql@npm%3A0.4.7#~/.yarn/patches/libsql-npm-0.4.7-444e260fb1.patch",
"openai@npm:^4.77.0": "patch:openai@npm%3A4.87.3#~/.yarn/patches/openai-npm-4.87.3-2b30a7685f.patch",
"pkce-challenge@npm:^4.1.0": "patch:pkce-challenge@npm%3A4.1.0#~/.yarn/patches/pkce-challenge-npm-4.1.0-fbc51695a3.patch"
},
"packageManager": "yarn@4.6.0",

View File

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

View File

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

View File

@@ -1,118 +1,111 @@
<!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>
<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 时还应遵守以下附加条款:
</p>
<h3 class="text-xl font-semibold mb-2">一. 商用许可</h3>
<ol class="list-decimal list-inside mb-4">
<li><strong>免费商用</strong>:用户在不修改代码的情况下,可以免费用于商业目的</li>
<li>
<strong>商业授权</strong>:如果您满足以下任意条件之一,需取得商业授权:
<ol class="list-decimal list-inside ml-4">
<li>对本软件进行二次修改、开发包括但不限于修改应用名称、logo、代码以及功能</li>
<li>为企业客户提供多租户服务,且该服务支持 10 人或以上的使用。</li>
<li>预装或集成到硬件设备或产品中进行捆绑销售</li>
<li>政府或教育机构的大规模采购项目,特别是涉及安全、数据隐私等敏感需求时</li>
</ol>
</li>
</ol>
<h3 class="text-xl font-semibold mb-2">二. 贡献者协议</h3>
<ol class="list-decimal list-inside mb-4">
<li><strong>许可调整</strong>:生产者有权根据需要对开源协议进行调整,使其更加严格或宽松。</li>
<li><strong>商业用途</strong>:您贡献的代码可能会被用于商业用途,包括但不限于云业务运营</li>
</ol>
<h3 class="text-xl font-semibold mb-2">三. 其他条款</h3>
<ol class="list-decimal list-inside mb-4">
<li>本协议条款的解释权归 Cherry Studio 开发者所有。</li>
<li>本协议可能根据实际情况进行更新,更新时将通过本软件通知用户。</li>
</ol>
<p class="mb-4">如有任何问题或需申请商业授权,请联系 Cherry Studio 开发团队。</p>
<p>
除上述特定条件外,其他所有权利和限制均遵循 Apache License 2.0。有关 Apache License 2.0 的详细信息,请访问
<a href="http://www.apache.org/licenses/LICENSE-2.0"
class="text-blue-500 underline">http://www.apache.org/licenses/LICENSE-2.0</a>
</p>
<body class="bg-gray-100 p-8">
<div class="container mx-auto bg-white p-6 rounded shadow-lg">
<h1 class="text-3xl font-bold mb-6 text-center">Cherry Studio 许可协议</h1>
<div class="mb-8">
<h2 class="text-2xl font-semibold mb-4">许可协议</h2>
<p class="mb-4">
本软件采用 <strong>Apache License 2.0</strong> 许可。除 Apache License 2.0 规定的条款外,您在使用 Cherry
Studio 时还应遵守以下附加条款:
</p>
<h3 class="text-xl font-semibold mb-2">一. 商用许可</h3>
<ol class="list-decimal list-inside mb-4">
<li><strong>免费商用</strong>:用户在不修改代码的情况下,可以免费用于商业目的。</li>
<li>
<strong>商业授权</strong>:如果您满足以下任意条件之一,需取得商业授权:
<ol class="list-decimal list-inside ml-4">
<li>对本软件进行二次修改、开发包括但不限于修改应用名称、logo、代码以及功能</li>
<li>为企业客户提供多租户服务,且该服务支持 10 人或以上的使用。</li>
<li>预装或集成到硬件设备或产品中进行捆绑销售。</li>
<li>政府或教育机构的大规模采购项目,特别是涉及安全、数据隐私等敏感需求时</li>
</ol>
</li>
</ol>
<h3 class="text-xl font-semibold mb-2">二. 贡献者协议</h3>
<ol class="list-decimal list-inside mb-4">
<li><strong>许可调整</strong>:生产者有权根据需要对开源协议进行调整,使其更加严格或宽松</li>
<li><strong>商业用途</strong>:您贡献的代码可能会被用于商业用途,包括但不限于云业务运营</li>
</ol>
<h3 class="text-xl font-semibold mb-2">三. 其他条款</h3>
<ol class="list-decimal list-inside mb-4">
<li>本协议条款的解释权归 Cherry Studio 开发者所有。</li>
<li>本协议可能根据实际情况进行更新,更新时将通过本软件通知用户。</li>
</ol>
<p class="mb-4">如有任何问题或需申请商业授权,请联系 Cherry Studio 开发团队</p>
<p>
除上述特定条件外,其他所有权利和限制均遵循 Apache License 2.0。有关 Apache License 2.0 的详细信息,请访问
<a href="http://www.apache.org/licenses/LICENSE-2.0" class="text-blue-500 underline"
>http://www.apache.org/licenses/LICENSE-2.0</a
>
</p>
</div>
<h1 class="text-3xl font-bold mb-6 text-center">Cherry Studio License</h1>
<div class="mb-8">
<h2 class="text-2xl font-semibold mb-4">License Agreement</h2>
<p class="mb-4">
This software is licensed under the <strong>Apache License 2.0</strong>. In addition to the terms of the
Apache License 2.0, the following additional terms apply to the use of Cherry Studio:
</p>
<h3 class="text-xl font-semibold mb-2">I. Commercial Use License</h3>
<ol class="list-decimal list-inside mb-4">
<li>
<strong>Free Commercial Use</strong>: Users can use the software for commercial purposes without modifying
the code.
</li>
<li>
<strong>Commercial License Required</strong>: A commercial license is required if any of the following
conditions are met:
<ol class="list-decimal list-inside ml-4">
<li>
You modify, develop, or alter the software, including but not limited to changes to the application
name, logo, code, or functionality.
</li>
<li>You provide multi-tenant services to enterprise customers with 10 or more users.</li>
<li>
You pre-install or integrate the software into hardware devices or products and bundle it for sale.
</li>
<li>
You are engaging in large-scale procurement for government or educational institutions, especially
involving security, data privacy, or other sensitive requirements.
</li>
</ol>
</li>
</ol>
<h3 class="text-xl font-semibold mb-2">II. Contributor Agreement</h3>
<ol class="list-decimal list-inside mb-4">
<li>
<strong>License Adjustment</strong>: The producer reserves the right to adjust the open-source license as
needed, making it stricter or more lenient.
</li>
<li>
<strong>Commercial Use</strong>: Any code you contribute may be used for commercial purposes, including but
not limited to cloud business operations.
</li>
</ol>
<h3 class="text-xl font-semibold mb-2">III. Other Terms</h3>
<ol class="list-decimal list-inside mb-4">
<li>The interpretation of these terms is subject to the discretion of Cherry Studio developers.</li>
<li>These terms may be updated, and users will be notified through the software when changes occur.</li>
</ol>
<p class="mb-4">
For any questions or to request a commercial license, please contact the Cherry Studio development team.
</p>
<p>
Apart from the specific conditions mentioned above, all other rights and restrictions follow the Apache
License 2.0. Detailed information about the Apache License 2.0 can be found at
<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>
</div>
<h1 class="text-3xl font-bold mb-6 text-center">Cherry Studio License</h1>
<div class="mb-8">
<h2 class="text-2xl font-semibold mb-4">License Agreement</h2>
<p class="mb-4">
This software is licensed under the <strong>Apache License 2.0</strong>. In addition to the terms of the
Apache License 2.0, the following additional terms apply to the use of Cherry Studio:
</p>
<h3 class="text-xl font-semibold mb-2">I. Commercial Use License</h3>
<ol class="list-decimal list-inside mb-4">
<li>
<strong>Free Commercial Use</strong>: Users can use the software for commercial purposes without
modifying
the code.
</li>
<li>
<strong>Commercial License Required</strong>: A commercial license is required if any of the
following
conditions are met:
<ol class="list-decimal list-inside ml-4">
<li>
You modify, develop, or alter the software, including but not limited to changes to the
application
name, logo, code, or functionality.
</li>
<li>You provide multi-tenant services to enterprise customers with 10 or more users.</li>
<li>
You pre-install or integrate the software into hardware devices or products and bundle it
for sale.
</li>
<li>
You are engaging in large-scale procurement for government or educational institutions,
especially
involving security, data privacy, or other sensitive requirements.
</li>
</ol>
</li>
</ol>
<h3 class="text-xl font-semibold mb-2">II. Contributor Agreement</h3>
<ol class="list-decimal list-inside mb-4">
<li>
<strong>License Adjustment</strong>: The producer reserves the right to adjust the open-source
license as
needed, making it stricter or more lenient.
</li>
<li>
<strong>Commercial Use</strong>: Any code you contribute may be used for commercial purposes,
including but
not limited to cloud business operations.
</li>
</ol>
<h3 class="text-xl font-semibold mb-2">III. Other Terms</h3>
<ol class="list-decimal list-inside mb-4">
<li>The interpretation of these terms is subject to the discretion of Cherry Studio developers.</li>
<li>These terms may be updated, and users will be notified through the software when changes occur.</li>
</ol>
<p class="mb-4">
For any questions or to request a commercial license, please contact the Cherry Studio development team.
</p>
<p>
Apart from the specific conditions mentioned above, all other rights and restrictions follow the Apache
License 2.0. Detailed information about the Apache License 2.0 can be found at
<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>
</div>
</body>
</html>
</body>
</html>

View File

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

View File

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

View File

@@ -29,7 +29,14 @@ exports.default = async function (context) {
if (platform === 'windows') {
const node_modules_path = path.join(context.appOutDir, 'resources', 'app.asar.unpacked', 'node_modules')
removeDifferentArchNodeFiles(node_modules_path, '@libsql', ['win32-x64-msvc'])
if (arch === Arch.arm64) {
removeDifferentArchNodeFiles(node_modules_path, '@strongtz', ['win32-arm64-msvc'])
removeDifferentArchNodeFiles(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'])
}
}
}

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import { electronApp, optimizer } from '@electron-toolkit/utils'
import { replaceDevtoolsFont } from '@main/utils/windowUtil'
import { IpcChannel } from '@shared/IpcChannel'
import { app, ipcMain } from 'electron'
import installExtension, { REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS } from 'electron-devtools-installer'
@@ -52,7 +53,7 @@ if (!app.requestSingleInstanceLock()) {
.then((name) => console.log(`Added Extension: ${name}`))
.catch((err) => console.log('An error occurred: ', err))
}
ipcMain.handle('system:getDeviceType', () => {
ipcMain.handle(IpcChannel.System_GetDeviceType, () => {
return process.platform === 'darwin' ? 'mac' : process.platform === 'win32' ? 'windows' : 'linux'
})
})

View File

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

File diff suppressed because one or more lines are too long

View File

@@ -2,6 +2,7 @@ import fs from 'node:fs'
import { isMac, isWin } from '@main/constant'
import { getBinaryPath, isBinaryExists, runInstallScript } from '@main/utils/process'
import { IpcChannel } from '@shared/IpcChannel'
import { Shortcut, ThemeMode } from '@types'
import { BrowserWindow, ipcMain, session, shell } from 'electron'
import log from 'electron-log'
@@ -25,7 +26,7 @@ import { TrayService } from './services/TrayService'
import { windowService } from './services/WindowService'
import { getResourcePath } from './utils'
import { decrypt, encrypt } from './utils/aes'
import { getFilesDir } from './utils/file'
import { getConfigDir, getFilesDir } from './utils/file'
import { compress, decompress } from './utils/zip'
const fileManager = new FileStorage()
@@ -36,17 +37,18 @@ const obsidianVaultService = new ObsidianVaultService()
export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
const appUpdater = new AppUpdater(mainWindow)
ipcMain.handle('app:info', () => ({
ipcMain.handle(IpcChannel.App_Info, () => ({
version: app.getVersion(),
isPackaged: app.isPackaged,
appPath: app.getAppPath(),
filesPath: getFilesDir(),
configPath: getConfigDir(),
appDataPath: app.getPath('userData'),
resourcesPath: getResourcePath(),
logsPath: log.transports.file.getFile().path
}))
ipcMain.handle('app:proxy', async (_, proxy: string) => {
ipcMain.handle(IpcChannel.App_Proxy, async (_, proxy: string) => {
let proxyConfig: ProxyConfig
if (proxy === 'system') {
@@ -60,19 +62,19 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
await proxyManager.configureProxy(proxyConfig)
})
ipcMain.handle('app:reload', () => mainWindow.reload())
ipcMain.handle('open:website', (_, url: string) => shell.openExternal(url))
ipcMain.handle(IpcChannel.App_Reload, () => mainWindow.reload())
ipcMain.handle(IpcChannel.Open_Website, (_, url: string) => shell.openExternal(url))
// Update
ipcMain.handle('app:show-update-dialog', () => appUpdater.showUpdateDialog(mainWindow))
ipcMain.handle(IpcChannel.App_ShowUpdateDialog, () => appUpdater.showUpdateDialog(mainWindow))
// language
ipcMain.handle('app:set-language', (_, language) => {
ipcMain.handle(IpcChannel.App_SetLanguage, (_, language) => {
configManager.setLanguage(language)
})
// launch on boot
ipcMain.handle('app:set-launch-on-boot', (_, openAtLogin: boolean) => {
ipcMain.handle(IpcChannel.App_SetLaunchOnBoot, (_, openAtLogin: boolean) => {
// Set login item settings for windows and mac
// linux is not supported because it requires more file operations
if (isWin || isMac) {
@@ -81,32 +83,32 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
})
// launch to tray
ipcMain.handle('app:set-launch-to-tray', (_, isActive: boolean) => {
ipcMain.handle(IpcChannel.App_SetLaunchToTray, (_, isActive: boolean) => {
configManager.setLaunchToTray(isActive)
})
// tray
ipcMain.handle('app:set-tray', (_, isActive: boolean) => {
ipcMain.handle(IpcChannel.App_SetTray, (_, isActive: boolean) => {
configManager.setTray(isActive)
})
// to tray on close
ipcMain.handle('app:set-tray-on-close', (_, isActive: boolean) => {
ipcMain.handle(IpcChannel.App_SetTrayOnClose, (_, isActive: boolean) => {
configManager.setTrayOnClose(isActive)
})
ipcMain.handle('app:restart-tray', () => TrayService.getInstance().restartTray())
ipcMain.handle(IpcChannel.App_RestartTray, () => TrayService.getInstance().restartTray())
ipcMain.handle('config:set', (_, key: string, value: any) => {
ipcMain.handle(IpcChannel.Config_Set, (_, key: string, value: any) => {
configManager.set(key, value)
})
ipcMain.handle('config:get', (_, key: string) => {
ipcMain.handle(IpcChannel.Config_Get, (_, key: string) => {
return configManager.get(key)
})
// theme
ipcMain.handle('app:set-theme', (event, theme: ThemeMode) => {
ipcMain.handle(IpcChannel.App_SetTheme, (event, theme: ThemeMode) => {
if (theme === configManager.getTheme()) return
configManager.setTheme(theme)
@@ -117,7 +119,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
// 向其他窗口广播主题变化
windows.forEach((win) => {
if (win.webContents.id !== senderWindowId) {
win.webContents.send('theme:change', theme)
win.webContents.send(IpcChannel.ThemeChange, theme)
}
})
@@ -126,7 +128,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
})
// clear cache
ipcMain.handle('app:clear-cache', async () => {
ipcMain.handle(IpcChannel.App_ClearCache, async () => {
const sessions = [session.defaultSession, session.fromPartition('persist:webview')]
try {
@@ -148,7 +150,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
})
// check for update
ipcMain.handle('app:check-for-update', async () => {
ipcMain.handle(IpcChannel.App_CheckForUpdate, async () => {
const update = await appUpdater.autoUpdater.checkForUpdates()
return {
currentVersion: appUpdater.autoUpdater.currentVersion,
@@ -157,62 +159,50 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
})
// zip
ipcMain.handle('zip:compress', (_, text: string) => compress(text))
ipcMain.handle('zip:decompress', (_, text: Buffer) => decompress(text))
ipcMain.handle(IpcChannel.Zip_Compress, (_, text: string) => compress(text))
ipcMain.handle(IpcChannel.Zip_Decompress, (_, text: Buffer) => decompress(text))
// backup
ipcMain.handle('backup:backup', backupManager.backup)
ipcMain.handle('backup:restore', backupManager.restore)
ipcMain.handle('backup:backupToWebdav', backupManager.backupToWebdav)
ipcMain.handle('backup:restoreFromWebdav', backupManager.restoreFromWebdav)
ipcMain.handle('backup:listWebdavFiles', backupManager.listWebdavFiles)
ipcMain.handle('backup:checkConnection', backupManager.checkConnection)
ipcMain.handle('backup:createDirectory', backupManager.createDirectory)
ipcMain.handle(IpcChannel.Backup_Backup, backupManager.backup)
ipcMain.handle(IpcChannel.Backup_Restore, backupManager.restore)
ipcMain.handle(IpcChannel.Backup_BackupToWebdav, backupManager.backupToWebdav)
ipcMain.handle(IpcChannel.Backup_RestoreFromWebdav, backupManager.restoreFromWebdav)
ipcMain.handle(IpcChannel.Backup_ListWebdavFiles, backupManager.listWebdavFiles)
ipcMain.handle(IpcChannel.Backup_CheckConnection, backupManager.checkConnection)
ipcMain.handle(IpcChannel.Backup_CreateDirectory, backupManager.createDirectory)
// file
ipcMain.handle('file:open', fileManager.open)
ipcMain.handle('file:openPath', fileManager.openPath)
ipcMain.handle('file:save', fileManager.save)
ipcMain.handle('file:select', fileManager.selectFile)
ipcMain.handle('file:upload', fileManager.uploadFile)
ipcMain.handle('file:clear', fileManager.clear)
ipcMain.handle('file:read', fileManager.readFile)
ipcMain.handle('file:delete', fileManager.deleteFile)
ipcMain.handle('file:get', fileManager.getFile)
ipcMain.handle('file:selectFolder', fileManager.selectFolder)
ipcMain.handle('file:create', fileManager.createTempFile)
ipcMain.handle('file:write', fileManager.writeFile)
ipcMain.handle('file:saveImage', fileManager.saveImage)
ipcMain.handle('file:base64Image', fileManager.base64Image)
ipcMain.handle('file:download', fileManager.downloadFile)
ipcMain.handle('file:copy', fileManager.copyFile)
ipcMain.handle('file:binaryFile', fileManager.binaryFile)
ipcMain.handle(IpcChannel.File_Open, fileManager.open)
ipcMain.handle(IpcChannel.File_OpenPath, fileManager.openPath)
ipcMain.handle(IpcChannel.File_Save, fileManager.save)
ipcMain.handle(IpcChannel.File_Select, fileManager.selectFile)
ipcMain.handle(IpcChannel.File_Upload, fileManager.uploadFile)
ipcMain.handle(IpcChannel.File_Clear, fileManager.clear)
ipcMain.handle(IpcChannel.File_Read, fileManager.readFile)
ipcMain.handle(IpcChannel.File_Delete, fileManager.deleteFile)
ipcMain.handle(IpcChannel.File_Get, fileManager.getFile)
ipcMain.handle(IpcChannel.File_SelectFolder, fileManager.selectFolder)
ipcMain.handle(IpcChannel.File_Create, fileManager.createTempFile)
ipcMain.handle(IpcChannel.File_Write, fileManager.writeFile)
ipcMain.handle(IpcChannel.File_SaveImage, fileManager.saveImage)
ipcMain.handle(IpcChannel.File_Base64Image, fileManager.base64Image)
ipcMain.handle(IpcChannel.File_Download, fileManager.downloadFile)
ipcMain.handle(IpcChannel.File_Copy, fileManager.copyFile)
ipcMain.handle(IpcChannel.File_BinaryFile, fileManager.binaryFile)
// fs
ipcMain.handle('fs:read', FileService.readFile)
// minapp
ipcMain.handle('minapp', (_, args) => {
windowService.createMinappWindow({
url: args.url,
parent: mainWindow,
windowOptions: {
...mainWindow.getBounds(),
...args.windowOptions
}
})
})
ipcMain.handle(IpcChannel.Fs_Read, FileService.readFile)
// export
ipcMain.handle('export:word', exportService.exportToWord)
ipcMain.handle(IpcChannel.Export_Word, exportService.exportToWord)
// open path
ipcMain.handle('open:path', async (_, path: string) => {
ipcMain.handle(IpcChannel.Open_Path, async (_, path: string) => {
await shell.openPath(path)
})
// shortcuts
ipcMain.handle('shortcuts:update', (_, shortcuts: Shortcut[]) => {
ipcMain.handle(IpcChannel.Shortcuts_Update, (_, shortcuts: Shortcut[]) => {
configManager.setShortcuts(shortcuts)
// Refresh shortcuts registration
if (mainWindow) {
@@ -222,20 +212,20 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
})
// knowledge base
ipcMain.handle('knowledge-base:create', KnowledgeService.create)
ipcMain.handle('knowledge-base:reset', KnowledgeService.reset)
ipcMain.handle('knowledge-base:delete', KnowledgeService.delete)
ipcMain.handle('knowledge-base:add', KnowledgeService.add)
ipcMain.handle('knowledge-base:remove', KnowledgeService.remove)
ipcMain.handle('knowledge-base:search', KnowledgeService.search)
ipcMain.handle('knowledge-base:rerank', KnowledgeService.rerank)
ipcMain.handle(IpcChannel.KnowledgeBase_Create, KnowledgeService.create)
ipcMain.handle(IpcChannel.KnowledgeBase_Reset, KnowledgeService.reset)
ipcMain.handle(IpcChannel.KnowledgeBase_Delete, KnowledgeService.delete)
ipcMain.handle(IpcChannel.KnowledgeBase_Add, KnowledgeService.add)
ipcMain.handle(IpcChannel.KnowledgeBase_Remove, KnowledgeService.remove)
ipcMain.handle(IpcChannel.KnowledgeBase_Search, KnowledgeService.search)
ipcMain.handle(IpcChannel.KnowledgeBase_Rerank, KnowledgeService.rerank)
// window
ipcMain.handle('window:set-minimum-size', (_, width: number, height: number) => {
ipcMain.handle(IpcChannel.Windows_SetMinimumSize, (_, width: number, height: number) => {
mainWindow?.setMinimumSize(width, height)
})
ipcMain.handle('window:reset-minimum-size', () => {
ipcMain.handle(IpcChannel.Windows_ResetMinimumSize, () => {
mainWindow?.setMinimumSize(1080, 600)
const [width, height] = mainWindow?.getSize() ?? [1080, 600]
if (width < 1080) {
@@ -244,59 +234,61 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
})
// gemini
ipcMain.handle('gemini:upload-file', GeminiService.uploadFile)
ipcMain.handle('gemini:base64-file', GeminiService.base64File)
ipcMain.handle('gemini:retrieve-file', GeminiService.retrieveFile)
ipcMain.handle('gemini:list-files', GeminiService.listFiles)
ipcMain.handle('gemini:delete-file', GeminiService.deleteFile)
ipcMain.handle(IpcChannel.Gemini_UploadFile, GeminiService.uploadFile)
ipcMain.handle(IpcChannel.Gemini_Base64File, GeminiService.base64File)
ipcMain.handle(IpcChannel.Gemini_RetrieveFile, GeminiService.retrieveFile)
ipcMain.handle(IpcChannel.Gemini_ListFiles, GeminiService.listFiles)
ipcMain.handle(IpcChannel.Gemini_DeleteFile, GeminiService.deleteFile)
// mini window
ipcMain.handle('miniwindow:show', () => windowService.showMiniWindow())
ipcMain.handle('miniwindow:hide', () => windowService.hideMiniWindow())
ipcMain.handle('miniwindow:close', () => windowService.closeMiniWindow())
ipcMain.handle('miniwindow:toggle', () => windowService.toggleMiniWindow())
ipcMain.handle('miniwindow:set-pin', (_, isPinned) => windowService.setPinMiniWindow(isPinned))
ipcMain.handle(IpcChannel.MiniWindow_Show, () => windowService.showMiniWindow())
ipcMain.handle(IpcChannel.MiniWindow_Hide, () => windowService.hideMiniWindow())
ipcMain.handle(IpcChannel.MiniWindow_Close, () => windowService.closeMiniWindow())
ipcMain.handle(IpcChannel.MiniWindow_Toggle, () => windowService.toggleMiniWindow())
ipcMain.handle(IpcChannel.MiniWindow_SetPin, (_, isPinned) => windowService.setPinMiniWindow(isPinned))
// aes
ipcMain.handle('aes:encrypt', (_, text: string, secretKey: string, iv: string) => encrypt(text, secretKey, iv))
ipcMain.handle('aes:decrypt', (_, encryptedData: string, iv: string, secretKey: string) =>
ipcMain.handle(IpcChannel.Aes_Encrypt, (_, text: string, secretKey: string, iv: string) =>
encrypt(text, secretKey, iv)
)
ipcMain.handle(IpcChannel.Aes_Decrypt, (_, encryptedData: string, iv: string, secretKey: string) =>
decrypt(encryptedData, iv, secretKey)
)
// Register MCP handlers
ipcMain.handle('mcp:remove-server', mcpService.removeServer)
ipcMain.handle('mcp:restart-server', mcpService.restartServer)
ipcMain.handle('mcp:stop-server', mcpService.stopServer)
ipcMain.handle('mcp:list-tools', mcpService.listTools)
ipcMain.handle('mcp:call-tool', mcpService.callTool)
ipcMain.handle('mcp:get-install-info', mcpService.getInstallInfo)
ipcMain.handle(IpcChannel.Mcp_RemoveServer, mcpService.removeServer)
ipcMain.handle(IpcChannel.Mcp_RestartServer, mcpService.restartServer)
ipcMain.handle(IpcChannel.Mcp_StopServer, mcpService.stopServer)
ipcMain.handle(IpcChannel.Mcp_ListTools, mcpService.listTools)
ipcMain.handle(IpcChannel.Mcp_CallTool, mcpService.callTool)
ipcMain.handle(IpcChannel.Mcp_GetInstallInfo, mcpService.getInstallInfo)
ipcMain.handle('app:is-binary-exist', (_, name: string) => isBinaryExists(name))
ipcMain.handle('app:get-binary-path', (_, name: string) => getBinaryPath(name))
ipcMain.handle('app:install-uv-binary', () => runInstallScript('install-uv.js'))
ipcMain.handle('app:install-bun-binary', () => runInstallScript('install-bun.js'))
ipcMain.handle(IpcChannel.App_IsBinaryExist, (_, name: string) => isBinaryExists(name))
ipcMain.handle(IpcChannel.App_GetBinaryPath, (_, name: string) => getBinaryPath(name))
ipcMain.handle(IpcChannel.App_InstallUvBinary, () => runInstallScript('install-uv.js'))
ipcMain.handle(IpcChannel.App_InstallBunBinary, () => runInstallScript('install-bun.js'))
//copilot
ipcMain.handle('copilot:get-auth-message', CopilotService.getAuthMessage)
ipcMain.handle('copilot:get-copilot-token', CopilotService.getCopilotToken)
ipcMain.handle('copilot:save-copilot-token', CopilotService.saveCopilotToken)
ipcMain.handle('copilot:get-token', CopilotService.getToken)
ipcMain.handle('copilot:logout', CopilotService.logout)
ipcMain.handle('copilot:get-user', CopilotService.getUser)
ipcMain.handle(IpcChannel.Copilot_GetAuthMessage, CopilotService.getAuthMessage)
ipcMain.handle(IpcChannel.Copilot_GetCopilotToken, CopilotService.getCopilotToken)
ipcMain.handle(IpcChannel.Copilot_SaveCopilotToken, CopilotService.saveCopilotToken)
ipcMain.handle(IpcChannel.Copilot_GetToken, CopilotService.getToken)
ipcMain.handle(IpcChannel.Copilot_Logout, CopilotService.logout)
ipcMain.handle(IpcChannel.Copilot_GetUser, CopilotService.getUser)
// Obsidian service
ipcMain.handle('obsidian:get-vaults', () => {
ipcMain.handle(IpcChannel.Obsidian_GetVaults, () => {
return obsidianVaultService.getVaults()
})
ipcMain.handle('obsidian:get-files', (_event, vaultName) => {
ipcMain.handle(IpcChannel.Obsidian_GetFiles, (_event, vaultName) => {
return obsidianVaultService.getFilesByVaultName(vaultName)
})
// nutstore
ipcMain.handle('nutstore:get-sso-url', NutstoreService.getNutstoreSSOUrl)
ipcMain.handle('nutstore:decrypt-token', (_, token: string) => NutstoreService.decryptToken(token))
ipcMain.handle('nutstore:get-directory-contents', (_, token: string, path: string) =>
ipcMain.handle(IpcChannel.Nutstore_GetSsoUrl, NutstoreService.getNutstoreSSOUrl)
ipcMain.handle(IpcChannel.Nutstore_DecryptToken, (_, token: string) => NutstoreService.decryptToken(token))
ipcMain.handle(IpcChannel.Nutstore_GetDirectoryContents, (_, token: string, path: string) =>
NutstoreService.getDirectoryContents(token, path)
)
}

View File

@@ -0,0 +1,374 @@
// Brave Search MCP Server
// port https://github.com/modelcontextprotocol/servers/blob/main/src/brave-search/index.ts
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { CallToolRequestSchema, ListToolsRequestSchema, Tool } from '@modelcontextprotocol/sdk/types.js'
const WEB_SEARCH_TOOL: Tool = {
name: 'brave_web_search',
description:
'Performs a web search using the Brave Search API, ideal for general queries, news, articles, and online content. ' +
'Use this for broad information gathering, recent events, or when you need diverse web sources. ' +
'Supports pagination, content filtering, and freshness controls. ' +
'Maximum 20 results per request, with offset for pagination. ',
inputSchema: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'Search query (max 400 chars, 50 words)'
},
count: {
type: 'number',
description: 'Number of results (1-20, default 10)',
default: 10
},
offset: {
type: 'number',
description: 'Pagination offset (max 9, default 0)',
default: 0
}
},
required: ['query']
}
}
const LOCAL_SEARCH_TOOL: Tool = {
name: 'brave_local_search',
description:
"Searches for local businesses and places using Brave's Local Search API. " +
'Best for queries related to physical locations, businesses, restaurants, services, etc. ' +
'Returns detailed information including:\n' +
'- Business names and addresses\n' +
'- Ratings and review counts\n' +
'- Phone numbers and opening hours\n' +
"Use this when the query implies 'near me' or mentions specific locations. " +
'Automatically falls back to web search if no local results are found.',
inputSchema: {
type: 'object',
properties: {
query: {
type: 'string',
description: "Local search query (e.g. 'pizza near Central Park')"
},
count: {
type: 'number',
description: 'Number of results (1-20, default 5)',
default: 5
}
},
required: ['query']
}
}
const RATE_LIMIT = {
perSecond: 1,
perMonth: 15000
}
const requestCount = {
second: 0,
month: 0,
lastReset: Date.now()
}
function checkRateLimit() {
const now = Date.now()
if (now - requestCount.lastReset > 1000) {
requestCount.second = 0
requestCount.lastReset = now
}
if (requestCount.second >= RATE_LIMIT.perSecond || requestCount.month >= RATE_LIMIT.perMonth) {
throw new Error('Rate limit exceeded')
}
requestCount.second++
requestCount.month++
}
interface BraveWeb {
web?: {
results?: Array<{
title: string
description: string
url: string
language?: string
published?: string
rank?: number
}>
}
locations?: {
results?: Array<{
id: string // Required by API
title?: string
}>
}
}
interface BraveLocation {
id: string
name: string
address: {
streetAddress?: string
addressLocality?: string
addressRegion?: string
postalCode?: string
}
coordinates?: {
latitude: number
longitude: number
}
phone?: string
rating?: {
ratingValue?: number
ratingCount?: number
}
openingHours?: string[]
priceRange?: string
}
interface BravePoiResponse {
results: BraveLocation[]
}
interface BraveDescription {
descriptions: { [id: string]: string }
}
function isBraveWebSearchArgs(args: unknown): args is { query: string; count?: number } {
return (
typeof args === 'object' &&
args !== null &&
'query' in args &&
typeof (args as { query: string }).query === 'string'
)
}
function isBraveLocalSearchArgs(args: unknown): args is { query: string; count?: number } {
return (
typeof args === 'object' &&
args !== null &&
'query' in args &&
typeof (args as { query: string }).query === 'string'
)
}
async function performWebSearch(apiKey: string, query: string, count: number = 10, offset: number = 0) {
checkRateLimit()
const url = new URL('https://api.search.brave.com/res/v1/web/search')
url.searchParams.set('q', query)
url.searchParams.set('count', Math.min(count, 20).toString()) // API limit
url.searchParams.set('offset', offset.toString())
const response = await fetch(url, {
headers: {
Accept: 'application/json',
'Accept-Encoding': 'gzip',
'X-Subscription-Token': apiKey
}
})
if (!response.ok) {
throw new Error(`Brave API error: ${response.status} ${response.statusText}\n${await response.text()}`)
}
const data = (await response.json()) as BraveWeb
// Extract just web results
const results = (data.web?.results || []).map((result) => ({
title: result.title || '',
description: result.description || '',
url: result.url || ''
}))
return results.map((r) => `Title: ${r.title}\nDescription: ${r.description}\nURL: ${r.url}`).join('\n\n')
}
async function performLocalSearch(apiKey: string, query: string, count: number = 5) {
checkRateLimit()
// Initial search to get location IDs
const webUrl = new URL('https://api.search.brave.com/res/v1/web/search')
webUrl.searchParams.set('q', query)
webUrl.searchParams.set('search_lang', 'en')
webUrl.searchParams.set('result_filter', 'locations')
webUrl.searchParams.set('count', Math.min(count, 20).toString())
const webResponse = await fetch(webUrl, {
headers: {
Accept: 'application/json',
'Accept-Encoding': 'gzip',
'X-Subscription-Token': apiKey
}
})
if (!webResponse.ok) {
throw new Error(`Brave API error: ${webResponse.status} ${webResponse.statusText}\n${await webResponse.text()}`)
}
const webData = (await webResponse.json()) as BraveWeb
const locationIds =
webData.locations?.results?.filter((r): r is { id: string; title?: string } => r.id != null).map((r) => r.id) || []
if (locationIds.length === 0) {
return performWebSearch(apiKey, query, count) // Fallback to web search
}
// Get POI details and descriptions in parallel
const [poisData, descriptionsData] = await Promise.all([
getPoisData(apiKey, locationIds),
getDescriptionsData(apiKey, locationIds)
])
return formatLocalResults(poisData, descriptionsData)
}
async function getPoisData(apiKey: string, ids: string[]): Promise<BravePoiResponse> {
checkRateLimit()
const url = new URL('https://api.search.brave.com/res/v1/local/pois')
ids.filter(Boolean).forEach((id) => url.searchParams.append('ids', id))
const response = await fetch(url, {
headers: {
Accept: 'application/json',
'Accept-Encoding': 'gzip',
'X-Subscription-Token': apiKey
}
})
if (!response.ok) {
throw new Error(`Brave API error: ${response.status} ${response.statusText}\n${await response.text()}`)
}
const poisResponse = (await response.json()) as BravePoiResponse
return poisResponse
}
async function getDescriptionsData(apiKey: string, ids: string[]): Promise<BraveDescription> {
checkRateLimit()
const url = new URL('https://api.search.brave.com/res/v1/local/descriptions')
ids.filter(Boolean).forEach((id) => url.searchParams.append('ids', id))
const response = await fetch(url, {
headers: {
Accept: 'application/json',
'Accept-Encoding': 'gzip',
'X-Subscription-Token': apiKey
}
})
if (!response.ok) {
throw new Error(`Brave API error: ${response.status} ${response.statusText}\n${await response.text()}`)
}
const descriptionsData = (await response.json()) as BraveDescription
return descriptionsData
}
function formatLocalResults(poisData: BravePoiResponse, descData: BraveDescription): string {
return (
(poisData.results || [])
.map((poi) => {
const address =
[
poi.address?.streetAddress ?? '',
poi.address?.addressLocality ?? '',
poi.address?.addressRegion ?? '',
poi.address?.postalCode ?? ''
]
.filter((part) => part !== '')
.join(', ') || 'N/A'
return `Name: ${poi.name}
Address: ${address}
Phone: ${poi.phone || 'N/A'}
Rating: ${poi.rating?.ratingValue ?? 'N/A'} (${poi.rating?.ratingCount ?? 0} reviews)
Price Range: ${poi.priceRange || 'N/A'}
Hours: ${(poi.openingHours || []).join(', ') || 'N/A'}
Description: ${descData.descriptions[poi.id] || 'No description available'}
`
})
.join('\n---\n') || 'No local results found'
)
}
class BraveSearchServer {
public server: Server
private apiKey: string
constructor(apiKey: string) {
if (!apiKey) {
throw new Error('BRAVE_API_KEY is required for Brave Search MCP server')
}
this.apiKey = apiKey
this.server = new Server(
{
name: 'brave-search-server',
version: '0.1.0'
},
{
capabilities: {
tools: {}
}
}
)
this.initialize()
}
initialize() {
// Tool handlers
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [WEB_SEARCH_TOOL, LOCAL_SEARCH_TOOL]
}))
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
try {
const { name, arguments: args } = request.params
if (!args) {
throw new Error('No arguments provided')
}
switch (name) {
case 'brave_web_search': {
if (!isBraveWebSearchArgs(args)) {
throw new Error('Invalid arguments for brave_web_search')
}
const { query, count = 10 } = args
const results = await performWebSearch(this.apiKey, query, count)
return {
content: [{ type: 'text', text: results }],
isError: false
}
}
case 'brave_local_search': {
if (!isBraveLocalSearchArgs(args)) {
throw new Error('Invalid arguments for brave_local_search')
}
const { query, count = 5 } = args
const results = await performLocalSearch(this.apiKey, query, count)
return {
content: [{ type: 'text', text: results }],
isError: false
}
}
default:
return {
content: [{ type: 'text', text: `Unknown tool: ${name}` }],
isError: true
}
}
} catch (error) {
return {
content: [
{
type: 'text',
text: `Error: ${error instanceof Error ? error.message : String(error)}`
}
],
isError: true
}
}
})
}
}
export default BraveSearchServer

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,509 @@
// 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 { promises as fs } from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
// Define memory file path using environment variable with fallback
const defaultMemoryPath = path.join(getConfigDir(), 'memory.json')
// We are storing our memory using entities, relations, and observations in a graph structure
interface Entity {
name: string
entityType: string
observations: string[]
}
interface Relation {
from: string
to: string
relationType: string
}
interface KnowledgeGraph {
entities: Entity[]
relations: Relation[]
}
// The KnowledgeGraphManager class contains all operations to interact with the knowledge graph
class KnowledgeGraphManager {
private memoryPath: string
constructor(memoryPath: string) {
this.memoryPath = memoryPath
this.ensureMemoryPathExists()
}
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, '')
}
} catch (error) {
console.error('Failed to create memory path:', error)
}
}
private async loadGraph(): Promise<KnowledgeGraph> {
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: [] }
)
} catch (error) {
if (error instanceof Error && 'code' in error && (error as any).code === 'ENOENT') {
return { entities: [], relations: [] }
}
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'))
}
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)
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)
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)
if (!entity) {
throw new Error(`Entity with name ${o.entityName} not found`)
}
const newObservations = o.contents.filter((content) => !entity.observations.includes(content))
entity.observations.push(...newObservations)
return { entityName: o.entityName, addedObservations: newObservations }
})
await this.saveGraph(graph)
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)
}
async deleteObservations(deletions: { entityName: string; observations: string[] }[]): Promise<void> {
const graph = await this.loadGraph()
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))
}
})
await this.saveGraph(graph)
}
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)
}
async readGraph(): Promise<KnowledgeGraph> {
return this.loadGraph()
}
// Very basic search function
async searchNodes(query: string): Promise<KnowledgeGraph> {
const graph = await this.loadGraph()
// Filter entities
const filteredEntities = graph.entities.filter(
(e) =>
e.name.toLowerCase().includes(query.toLowerCase()) ||
e.entityType.toLowerCase().includes(query.toLowerCase()) ||
e.observations.some((o) => o.toLowerCase().includes(query.toLowerCase()))
)
// 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 filteredGraph: KnowledgeGraph = {
entities: filteredEntities,
relations: filteredRelations
}
return filteredGraph
}
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 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 filteredGraph: KnowledgeGraph = {
entities: filteredEntities,
relations: filteredRelations
}
return filteredGraph
}
}
class MemoryServer {
public server: Server
private knowledgeGraphManager: KnowledgeGraphManager
constructor(envPath: string = '') {
const memoryPath = envPath
? path.isAbsolute(envPath)
? envPath
: path.join(path.dirname(fileURLToPath(import.meta.url)), envPath)
: defaultMemoryPath
this.knowledgeGraphManager = new KnowledgeGraphManager(memoryPath)
this.server = new Server(
{
name: 'memory-server',
version: '1.0.0'
},
{
capabilities: {
tools: {}
}
}
)
this.initialize()
}
initialize() {
// The server instance and tools exposed to Claude
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: 'create_entities',
description: 'Create multiple new entities in the knowledge graph',
inputSchema: {
type: 'object',
properties: {
entities: {
type: 'array',
items: {
type: 'object',
properties: {
name: { type: 'string', description: 'The name of the entity' },
entityType: { type: 'string', description: 'The type of the entity' },
observations: {
type: 'array',
items: { type: 'string' },
description: 'An array of observation contents associated with the entity'
}
},
required: ['name', 'entityType', 'observations']
}
}
},
required: ['entities']
}
},
{
name: 'create_relations',
description:
'Create multiple new relations between entities in the knowledge graph. Relations should be in active voice',
inputSchema: {
type: 'object',
properties: {
relations: {
type: 'array',
items: {
type: 'object',
properties: {
from: { type: 'string', description: 'The name of the entity where the relation starts' },
to: { type: 'string', description: 'The name of the entity where the relation ends' },
relationType: { type: 'string', description: 'The type of the relation' }
},
required: ['from', 'to', 'relationType']
}
}
},
required: ['relations']
}
},
{
name: 'add_observations',
description: 'Add new observations to existing entities in the knowledge graph',
inputSchema: {
type: 'object',
properties: {
observations: {
type: 'array',
items: {
type: 'object',
properties: {
entityName: { type: 'string', description: 'The name of the entity to add the observations to' },
contents: {
type: 'array',
items: { type: 'string' },
description: 'An array of observation contents to add'
}
},
required: ['entityName', 'contents']
}
}
},
required: ['observations']
}
},
{
name: 'delete_entities',
description: 'Delete multiple entities and their associated relations from the knowledge graph',
inputSchema: {
type: 'object',
properties: {
entityNames: {
type: 'array',
items: { type: 'string' },
description: 'An array of entity names to delete'
}
},
required: ['entityNames']
}
},
{
name: 'delete_observations',
description: 'Delete specific observations from entities in the knowledge graph',
inputSchema: {
type: 'object',
properties: {
deletions: {
type: 'array',
items: {
type: 'object',
properties: {
entityName: { type: 'string', description: 'The name of the entity containing the observations' },
observations: {
type: 'array',
items: { type: 'string' },
description: 'An array of observations to delete'
}
},
required: ['entityName', 'observations']
}
}
},
required: ['deletions']
}
},
{
name: 'delete_relations',
description: 'Delete multiple relations from the knowledge graph',
inputSchema: {
type: 'object',
properties: {
relations: {
type: 'array',
items: {
type: 'object',
properties: {
from: { type: 'string', description: 'The name of the entity where the relation starts' },
to: { type: 'string', description: 'The name of the entity where the relation ends' },
relationType: { type: 'string', description: 'The type of the relation' }
},
required: ['from', 'to', 'relationType']
},
description: 'An array of relations to delete'
}
},
required: ['relations']
}
},
{
name: 'read_graph',
description: 'Read the entire knowledge graph',
inputSchema: {
type: 'object',
properties: {}
}
},
{
name: 'search_nodes',
description: 'Search for nodes in the knowledge graph based on a query',
inputSchema: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'The search query to match against entity names, types, and observation content'
}
},
required: ['query']
}
},
{
name: 'open_nodes',
description: 'Open specific nodes in the knowledge graph by their names',
inputSchema: {
type: 'object',
properties: {
names: {
type: 'array',
items: { type: 'string' },
description: 'An array of entity names to retrieve'
}
},
required: ['names']
}
}
]
}
})
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params
if (!args) {
throw new Error(`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}`)
}
})
}
}
export default MemoryServer

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,15 +10,7 @@ export default class VoyageReranker extends BaseReranker {
}
public rerank = async (query: string, searchResults: ExtractChunkData[]): Promise<ExtractChunkData[]> => {
let baseURL = this.base?.rerankBaseURL?.endsWith('/')
? this.base.rerankBaseURL.slice(0, -1)
: this.base.rerankBaseURL
if (baseURL && !baseURL.endsWith('/v1')) {
baseURL = `${baseURL}/v1`
}
const url = `${baseURL}/rerank`
const url = this.getRerankUrl()
const requestBody = {
model: this.base.rerankModel,
@@ -37,21 +29,7 @@ export default class VoyageReranker extends BaseReranker {
})
const rerankResults = data.data
const resultMap = new Map(rerankResults.map((result: any) => [result.index, result.relevance_score || 0]))
return searchResults
.map((doc: ExtractChunkData, index: number) => {
const score = resultMap.get(index)
if (score === undefined) return undefined
return {
...doc,
score
}
})
.filter((doc): doc is ExtractChunkData => doc !== undefined)
.sort((a, b) => b.score - a.score)
return this.getRerankResult(searchResults, rerankResults)
} catch (error: any) {
const errorDetails = this.formatErrorMessage(url, error, requestBody)

View File

@@ -1,3 +1,4 @@
import { IpcChannel } from '@shared/IpcChannel'
import { UpdateInfo } from 'builder-util-runtime'
import { app, BrowserWindow, dialog } from 'electron'
import logger from 'electron-log'
@@ -24,27 +25,27 @@ export default class AppUpdater {
stack: error.stack,
time: new Date().toISOString()
})
mainWindow.webContents.send('update-error', error)
mainWindow.webContents.send(IpcChannel.UpdateError, error)
})
autoUpdater.on('update-available', (releaseInfo: UpdateInfo) => {
logger.info('检测到新版本', releaseInfo)
mainWindow.webContents.send('update-available', releaseInfo)
mainWindow.webContents.send(IpcChannel.UpdateAvailable, releaseInfo)
})
// 检测到不需要更新时
autoUpdater.on('update-not-available', () => {
mainWindow.webContents.send('update-not-available')
mainWindow.webContents.send(IpcChannel.UpdateNotAvailable)
})
// 更新下载进度
autoUpdater.on('download-progress', (progress) => {
mainWindow.webContents.send('download-progress', progress)
mainWindow.webContents.send(IpcChannel.DownloadProgress, progress)
})
// 当需要更新的内容下载完成后
autoUpdater.on('update-downloaded', (releaseInfo: UpdateInfo) => {
mainWindow.webContents.send('update-downloaded', releaseInfo)
mainWindow.webContents.send(IpcChannel.UpdateDownloaded, releaseInfo)
this.releaseInfo = releaseInfo
logger.info('下载完成', releaseInfo)
})
@@ -73,7 +74,7 @@ export default class AppUpdater {
app.isQuitting = true
setImmediate(() => autoUpdater.quitAndInstall())
} else {
mainWindow.webContents.send('update-downloaded-cancelled')
mainWindow.webContents.send(IpcChannel.UpdateDownloadedCancelled)
}
})
}

View File

@@ -1,3 +1,4 @@
import { IpcChannel } from '@shared/IpcChannel'
import { WebDavConfig } from '@types'
import AdmZip from 'adm-zip'
import { exec } from 'child_process'
@@ -79,7 +80,7 @@ class BackupManager {
const mainWindow = windowService.getMainWindow()
const onProgress = (processData: { stage: string; progress: number; total: number }) => {
mainWindow?.webContents.send('backup-progress', processData)
mainWindow?.webContents.send(IpcChannel.BackupProgress, processData)
Logger.log('[BackupManager] backup progress', processData)
}
@@ -139,7 +140,7 @@ class BackupManager {
const mainWindow = windowService.getMainWindow()
const onProgress = (processData: { stage: string; progress: number; total: number }) => {
mainWindow?.webContents.send('restore-progress', processData)
mainWindow?.webContents.send(IpcChannel.RestoreProgress, processData)
Logger.log('[BackupManager] restore progress', processData)
}

View File

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

View File

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

View File

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

View File

@@ -2,11 +2,13 @@ import os from 'node:os'
import path from 'node:path'
import { isLinux, isMac, isWin } from '@main/constant'
import { createInMemoryMCPServer } from '@main/mcpServers/factory'
import { makeSureDirExists } from '@main/utils'
import { getBinaryName, getBinaryPath } from '@main/utils/process'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
import { getDefaultEnvironment, StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory'
import { nanoid } from '@reduxjs/toolkit'
import { MCPServer, MCPTool } from '@types'
import { app } from 'electron'
@@ -44,28 +46,46 @@ class McpService {
// Check if we already have a client for this server configuration
const existingClient = this.clients.get(serverKey)
if (existingClient) {
// Check if the existing client is still connected
const pingResult = await existingClient.ping()
Logger.info(`[MCP] Ping result for ${server.name}:`, pingResult)
// If the ping fails, remove the client from the cache
// and create a new one
if (!pingResult) {
try {
// Check if the existing client is still connected
const pingResult = await existingClient.ping()
Logger.info(`[MCP] Ping result for ${server.name}:`, pingResult)
// If the ping fails, remove the client from the cache
// and create a new one
if (!pingResult) {
this.clients.delete(serverKey)
} else {
return existingClient
}
} catch (error) {
Logger.error(`[MCP] Error pinging server ${server.name}:`, error)
this.clients.delete(serverKey)
} else {
return existingClient
}
}
// Create new client instance for each connection
const client = new Client({ name: 'Cherry Studio', version: app.getVersion() }, { capabilities: {} })
const args = [...(server.args || [])]
let transport: StdioClientTransport | SSEClientTransport
let transport: StdioClientTransport | SSEClientTransport | InMemoryTransport
try {
// Create appropriate transport based on configuration
if (server.baseUrl) {
if (server.type === 'inMemory') {
Logger.info(`[MCP] Using in-memory transport for server: ${server.name}`)
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
// start the in-memory server with the given name and environment variables
const inMemoryServer = createInMemoryMCPServer(server.name, args, server.env || {})
try {
await inMemoryServer.connect(serverTransport)
Logger.info(`[MCP] In-memory server started: ${server.name}`)
} catch (error) {
Logger.error(`[MCP] Error starting in-memory server: ${error}`)
throw new Error(`Failed to start in-memory server: ${error}`)
}
// set the client transport to the client
transport = clientTransport
} else if (server.baseUrl) {
transport = new SSEClientTransport(new URL(server.baseUrl))
} else if (server.command) {
let cmd = server.command
@@ -90,10 +110,10 @@ class McpService {
}
// if the server name is mcp-auto-install, use the mcp-registry.json file in the bin directory
if (server.name === 'mcp-auto-install') {
if (server.name.includes('mcp-auto-install')) {
const binPath = await getBinaryPath()
makeSureDirExists(binPath)
server.env.MCP_REGISTRY_PATH = path.join(binPath, 'mcp-registry.json')
server.env.MCP_REGISTRY_PATH = path.join(binPath, '..', 'config', 'mcp-registry.json')
}
}
} else if (server.command === 'uvx' || server.command === 'uv') {

View File

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

View File

@@ -1,6 +1,7 @@
import { is } from '@electron-toolkit/utils'
import { isDev, isLinux, isMac, isWin } from '@main/constant'
import { getFilesDir } from '@main/utils/file'
import { IpcChannel } from '@shared/IpcChannel'
import { app, BrowserWindow, ipcMain, Menu, MenuItem, shell } from 'electron'
import Logger from 'electron-log'
import windowStateKeeper from 'electron-window-state'
@@ -82,36 +83,6 @@ export class WindowService {
return this.mainWindow
}
public createMinappWindow({
url,
parent,
windowOptions
}: {
url: string
parent?: BrowserWindow
windowOptions?: Electron.BrowserWindowConstructorOptions
}): BrowserWindow {
const width = windowOptions?.width || 1000
const height = windowOptions?.height || 680
const minappWindow = new BrowserWindow({
width,
height,
autoHideMenuBar: true,
title: 'Cherry Studio',
...windowOptions,
parent,
webPreferences: {
preload: join(__dirname, '../preload/minapp.js'),
sandbox: false,
contextIsolation: false
}
})
minappWindow.loadURL(url)
return minappWindow
}
private setupMainWindow(mainWindow: BrowserWindow, mainWindowState: any) {
mainWindowState.manage(mainWindow)
@@ -168,12 +139,12 @@ export class WindowService {
// 处理全屏相关事件
mainWindow.on('enter-full-screen', () => {
this.wasFullScreen = true
mainWindow.webContents.send('fullscreen-status-changed', true)
mainWindow.webContents.send(IpcChannel.FullscreenStatusChanged, true)
})
mainWindow.on('leave-full-screen', () => {
this.wasFullScreen = false
mainWindow.webContents.send('fullscreen-status-changed', false)
mainWindow.webContents.send(IpcChannel.FullscreenStatusChanged, false)
})
// set the zoom factor again when the window is going to resize
@@ -319,7 +290,9 @@ export class WindowService {
mainWindow.hide()
//for mac users, should hide dock icon if close to tray
app.dock?.hide()
if (isMac && isTrayOnClose) {
app.dock?.hide()
}
})
mainWindow.on('closed', () => {
@@ -434,14 +407,14 @@ export class WindowService {
})
this.miniWindow.on('hide', () => {
this.miniWindow?.webContents.send('hide-mini-window')
this.miniWindow?.webContents.send(IpcChannel.HideMiniWindow)
})
this.miniWindow.on('show', () => {
this.miniWindow?.webContents.send('show-mini-window')
this.miniWindow?.webContents.send(IpcChannel.ShowMiniWindow)
})
ipcMain.on('miniwindow-reload', () => {
ipcMain.on(IpcChannel.MiniWindowReload, () => {
this.miniWindow?.reload()
})
@@ -547,7 +520,7 @@ export class WindowService {
// 点击其他地方时隐藏窗口
this.selectionMenuWindow.on('blur', () => {
this.selectionMenuWindow?.hide()
this.miniWindow?.webContents.send('selection-action', {
this.miniWindow?.webContents.send(IpcChannel.SelectionAction, {
action: 'home',
selectedText: this.lastSelectedText
})
@@ -565,12 +538,12 @@ export class WindowService {
private setupSelectionMenuEvents() {
if (!this.selectionMenuWindow) return
ipcMain.removeHandler('selection-menu:action')
ipcMain.handle('selection-menu:action', (_, action) => {
ipcMain.removeHandler(IpcChannel.SelectionMenu_Action)
ipcMain.handle(IpcChannel.SelectionMenu_Action, (_, action) => {
this.selectionMenuWindow?.hide()
this.showMiniWindow()
setTimeout(() => {
this.miniWindow?.webContents.send('selection-action', {
this.miniWindow?.webContents.send(IpcChannel.SelectionAction, {
action,
selectedText: this.lastSelectedText
})

View File

@@ -1,4 +1,5 @@
import * as fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { audioExts, documentExts, imageExts, textExts, videoExts } from '@shared/config/constant'
@@ -74,3 +75,7 @@ export function getTempDir() {
export function getFilesDir() {
return path.join(app.getPath('userData'), 'Data', 'Files')
}
export function getConfigDir() {
return path.join(os.homedir(), '.cherrystudio', 'config')
}

View File

@@ -29,7 +29,6 @@ declare global {
setTrayOnClose: (isActive: boolean) => void
restartTray: () => void
setTheme: (theme: 'light' | 'dark') => void
minApp: (options: { url: string; windowOptions?: Electron.BrowserWindowConstructorOptions }) => void
reload: () => void
clearCache: () => Promise<{ success: boolean; error?: string }>
system: {

View File

@@ -1,79 +1,82 @@
import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
import { electronAPI } from '@electron-toolkit/preload'
import { IpcChannel } from '@shared/IpcChannel'
import { FileType, KnowledgeBaseParams, KnowledgeItem, MCPServer, Shortcut, WebDavConfig } from '@types'
import { contextBridge, ipcRenderer, OpenDialogOptions, shell } from 'electron'
import { CreateDirectoryOptions } from 'webdav'
// Custom APIs for renderer
const api = {
getAppInfo: () => ipcRenderer.invoke('app:info'),
reload: () => ipcRenderer.invoke('app:reload'),
setProxy: (proxy: string) => ipcRenderer.invoke('app:proxy', proxy),
checkForUpdate: () => ipcRenderer.invoke('app:check-for-update'),
showUpdateDialog: () => ipcRenderer.invoke('app:show-update-dialog'),
setLanguage: (lang: string) => ipcRenderer.invoke('app:set-language', lang),
setLaunchOnBoot: (isActive: boolean) => ipcRenderer.invoke('app:set-launch-on-boot', isActive),
setLaunchToTray: (isActive: boolean) => ipcRenderer.invoke('app:set-launch-to-tray', isActive),
setTray: (isActive: boolean) => ipcRenderer.invoke('app:set-tray', isActive),
setTrayOnClose: (isActive: boolean) => ipcRenderer.invoke('app:set-tray-on-close', isActive),
restartTray: () => ipcRenderer.invoke('app:restart-tray'),
setTheme: (theme: 'light' | 'dark') => ipcRenderer.invoke('app:set-theme', theme),
openWebsite: (url: string) => ipcRenderer.invoke('open:website', url),
minApp: (url: string) => ipcRenderer.invoke('minapp', url),
clearCache: () => ipcRenderer.invoke('app:clear-cache'),
getAppInfo: () => ipcRenderer.invoke(IpcChannel.App_Info),
reload: () => ipcRenderer.invoke(IpcChannel.App_Reload),
setProxy: (proxy: string) => ipcRenderer.invoke(IpcChannel.App_Proxy, proxy),
checkForUpdate: () => ipcRenderer.invoke(IpcChannel.App_CheckForUpdate),
showUpdateDialog: () => ipcRenderer.invoke(IpcChannel.App_ShowUpdateDialog),
setLanguage: (lang: string) => ipcRenderer.invoke(IpcChannel.App_SetLanguage, lang),
setLaunchOnBoot: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetLaunchOnBoot, isActive),
setLaunchToTray: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetLaunchToTray, isActive),
setTray: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetTray, isActive),
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),
openWebsite: (url: string) => ipcRenderer.invoke(IpcChannel.Open_Website, url),
clearCache: () => ipcRenderer.invoke(IpcChannel.App_ClearCache),
system: {
getDeviceType: () => ipcRenderer.invoke('system:getDeviceType')
getDeviceType: () => ipcRenderer.invoke(IpcChannel.System_GetDeviceType)
},
zip: {
compress: (text: string) => ipcRenderer.invoke('zip:compress', text),
decompress: (text: Buffer) => ipcRenderer.invoke('zip:decompress', text)
compress: (text: string) => ipcRenderer.invoke(IpcChannel.Zip_Compress, text),
decompress: (text: Buffer) => ipcRenderer.invoke(IpcChannel.Zip_Decompress, text)
},
backup: {
backup: (fileName: string, data: string, destinationPath?: string) =>
ipcRenderer.invoke('backup:backup', fileName, data, destinationPath),
restore: (backupPath: string) => ipcRenderer.invoke('backup:restore', backupPath),
ipcRenderer.invoke(IpcChannel.Backup_Backup, fileName, data, destinationPath),
restore: (backupPath: string) => ipcRenderer.invoke(IpcChannel.Backup_Restore, backupPath),
backupToWebdav: (data: string, webdavConfig: WebDavConfig) =>
ipcRenderer.invoke('backup:backupToWebdav', data, webdavConfig),
restoreFromWebdav: (webdavConfig: WebDavConfig) => ipcRenderer.invoke('backup:restoreFromWebdav', webdavConfig),
listWebdavFiles: (webdavConfig: WebDavConfig) => ipcRenderer.invoke('backup:listWebdavFiles', webdavConfig),
checkConnection: (webdavConfig: WebDavConfig) => ipcRenderer.invoke('backup:checkConnection', webdavConfig),
ipcRenderer.invoke(IpcChannel.Backup_BackupToWebdav, data, webdavConfig),
restoreFromWebdav: (webdavConfig: WebDavConfig) =>
ipcRenderer.invoke(IpcChannel.Backup_RestoreFromWebdav, webdavConfig),
listWebdavFiles: (webdavConfig: WebDavConfig) =>
ipcRenderer.invoke(IpcChannel.Backup_ListWebdavFiles, webdavConfig),
checkConnection: (webdavConfig: WebDavConfig) =>
ipcRenderer.invoke(IpcChannel.Backup_CheckConnection, webdavConfig),
createDirectory: (webdavConfig: WebDavConfig, path: string, options?: CreateDirectoryOptions) =>
ipcRenderer.invoke('backup:createDirectory', webdavConfig, path, options)
ipcRenderer.invoke(IpcChannel.Backup_CreateDirectory, webdavConfig, path, options)
},
file: {
select: (options?: OpenDialogOptions) => ipcRenderer.invoke('file:select', options),
upload: (filePath: string) => ipcRenderer.invoke('file:upload', filePath),
delete: (fileId: string) => ipcRenderer.invoke('file:delete', fileId),
read: (fileId: string) => ipcRenderer.invoke('file:read', fileId),
clear: () => ipcRenderer.invoke('file:clear'),
get: (filePath: string) => ipcRenderer.invoke('file:get', filePath),
create: (fileName: string) => ipcRenderer.invoke('file:create', fileName),
write: (filePath: string, data: Uint8Array | string) => ipcRenderer.invoke('file:write', filePath, data),
open: (options?: { decompress: boolean }) => ipcRenderer.invoke('file:open', options),
openPath: (path: string) => ipcRenderer.invoke('file:openPath', path),
select: (options?: OpenDialogOptions) => ipcRenderer.invoke(IpcChannel.File_Select, options),
upload: (filePath: string) => ipcRenderer.invoke(IpcChannel.File_Upload, filePath),
delete: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Delete, fileId),
read: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Read, fileId),
clear: () => ipcRenderer.invoke(IpcChannel.File_Clear),
get: (filePath: string) => ipcRenderer.invoke(IpcChannel.File_Get, filePath),
create: (fileName: string) => ipcRenderer.invoke(IpcChannel.File_Create, fileName),
write: (filePath: string, data: Uint8Array | string) => ipcRenderer.invoke(IpcChannel.File_Write, filePath, data),
open: (options?: { decompress: boolean }) => ipcRenderer.invoke(IpcChannel.File_Open, options),
openPath: (path: string) => ipcRenderer.invoke(IpcChannel.File_OpenPath, path),
save: (path: string, content: string, options?: { compress: boolean }) =>
ipcRenderer.invoke('file:save', path, content, options),
selectFolder: () => ipcRenderer.invoke('file:selectFolder'),
saveImage: (name: string, data: string) => ipcRenderer.invoke('file:saveImage', name, data),
base64Image: (fileId: string) => ipcRenderer.invoke('file:base64Image', fileId),
download: (url: string) => ipcRenderer.invoke('file:download', url),
copy: (fileId: string, destPath: string) => ipcRenderer.invoke('file:copy', fileId, destPath),
binaryFile: (fileId: string) => ipcRenderer.invoke('file:binaryFile', fileId)
ipcRenderer.invoke(IpcChannel.File_Save, path, content, options),
selectFolder: () => ipcRenderer.invoke(IpcChannel.File_SelectFolder),
saveImage: (name: string, data: string) => ipcRenderer.invoke(IpcChannel.File_SaveImage, name, data),
base64Image: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Base64Image, fileId),
download: (url: string) => ipcRenderer.invoke(IpcChannel.File_Download, url),
copy: (fileId: string, destPath: string) => ipcRenderer.invoke(IpcChannel.File_Copy, fileId, destPath),
binaryFile: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_BinaryFile, fileId)
},
fs: {
read: (path: string) => ipcRenderer.invoke('fs:read', path)
read: (path: string) => ipcRenderer.invoke(IpcChannel.Fs_Read, path)
},
export: {
toWord: (markdown: string, fileName: string) => ipcRenderer.invoke('export:word', markdown, fileName)
toWord: (markdown: string, fileName: string) => ipcRenderer.invoke(IpcChannel.Export_Word, markdown, fileName)
},
openPath: (path: string) => ipcRenderer.invoke('open:path', path),
openPath: (path: string) => ipcRenderer.invoke(IpcChannel.Open_Path, path),
shortcuts: {
update: (shortcuts: Shortcut[]) => ipcRenderer.invoke('shortcuts:update', shortcuts)
update: (shortcuts: Shortcut[]) => ipcRenderer.invoke(IpcChannel.Shortcuts_Update, shortcuts)
},
knowledgeBase: {
create: (base: KnowledgeBaseParams) => ipcRenderer.invoke('knowledge-base:create', base),
reset: (base: KnowledgeBaseParams) => ipcRenderer.invoke('knowledge-base:reset', base),
delete: (id: string) => ipcRenderer.invoke('knowledge-base:delete', id),
create: (base: KnowledgeBaseParams) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Create, base),
reset: (base: KnowledgeBaseParams) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Reset, base),
delete: (id: string) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Delete, id),
add: ({
base,
item,
@@ -82,71 +85,74 @@ const api = {
base: KnowledgeBaseParams
item: KnowledgeItem
forceReload?: boolean
}) => ipcRenderer.invoke('knowledge-base:add', { base, item, forceReload }),
}) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Add, { base, item, forceReload }),
remove: ({ uniqueId, uniqueIds, base }: { uniqueId: string; uniqueIds: string[]; base: KnowledgeBaseParams }) =>
ipcRenderer.invoke('knowledge-base:remove', { uniqueId, uniqueIds, base }),
ipcRenderer.invoke(IpcChannel.KnowledgeBase_Remove, { uniqueId, uniqueIds, base }),
search: ({ search, base }: { search: string; base: KnowledgeBaseParams }) =>
ipcRenderer.invoke('knowledge-base:search', { search, base }),
ipcRenderer.invoke(IpcChannel.KnowledgeBase_Search, { search, base }),
rerank: ({ search, base, results }: { search: string; base: KnowledgeBaseParams; results: ExtractChunkData[] }) =>
ipcRenderer.invoke('knowledge-base:rerank', { search, base, results })
ipcRenderer.invoke(IpcChannel.KnowledgeBase_Rerank, { search, base, results })
},
window: {
setMinimumSize: (width: number, height: number) => ipcRenderer.invoke('window:set-minimum-size', width, height),
resetMinimumSize: () => ipcRenderer.invoke('window:reset-minimum-size')
setMinimumSize: (width: number, height: number) =>
ipcRenderer.invoke(IpcChannel.Windows_SetMinimumSize, width, height),
resetMinimumSize: () => ipcRenderer.invoke(IpcChannel.Windows_ResetMinimumSize)
},
gemini: {
uploadFile: (file: FileType, apiKey: string) => ipcRenderer.invoke('gemini:upload-file', file, apiKey),
base64File: (file: FileType) => ipcRenderer.invoke('gemini:base64-file', file),
retrieveFile: (file: FileType, apiKey: string) => ipcRenderer.invoke('gemini:retrieve-file', file, apiKey),
listFiles: (apiKey: string) => ipcRenderer.invoke('gemini:list-files', apiKey),
deleteFile: (apiKey: string, fileId: string) => ipcRenderer.invoke('gemini:delete-file', apiKey, fileId)
uploadFile: (file: FileType, apiKey: string) => ipcRenderer.invoke(IpcChannel.Gemini_UploadFile, file, apiKey),
base64File: (file: FileType) => ipcRenderer.invoke(IpcChannel.Gemini_Base64File, file),
retrieveFile: (file: FileType, apiKey: string) => ipcRenderer.invoke(IpcChannel.Gemini_RetrieveFile, file, apiKey),
listFiles: (apiKey: string) => ipcRenderer.invoke(IpcChannel.Gemini_ListFiles, apiKey),
deleteFile: (apiKey: string, fileId: string) => ipcRenderer.invoke(IpcChannel.Gemini_DeleteFile, apiKey, fileId)
},
selectionMenu: {
action: (action: string) => ipcRenderer.invoke('selection-menu:action', action)
action: (action: string) => ipcRenderer.invoke(IpcChannel.SelectionMenu_Action, action)
},
config: {
set: (key: string, value: any) => ipcRenderer.invoke('config:set', key, value),
get: (key: string) => ipcRenderer.invoke('config:get', key)
set: (key: string, value: any) => ipcRenderer.invoke(IpcChannel.Config_Set, key, value),
get: (key: string) => ipcRenderer.invoke(IpcChannel.Config_Get, key)
},
miniWindow: {
show: () => ipcRenderer.invoke('miniwindow:show'),
hide: () => ipcRenderer.invoke('miniwindow:hide'),
close: () => ipcRenderer.invoke('miniwindow:close'),
toggle: () => ipcRenderer.invoke('miniwindow:toggle'),
setPin: (isPinned: boolean) => ipcRenderer.invoke('miniwindow:set-pin', isPinned)
show: () => ipcRenderer.invoke(IpcChannel.MiniWindow_Show),
hide: () => ipcRenderer.invoke(IpcChannel.MiniWindow_Hide),
close: () => ipcRenderer.invoke(IpcChannel.MiniWindow_Close),
toggle: () => ipcRenderer.invoke(IpcChannel.MiniWindow_Toggle),
setPin: (isPinned: boolean) => ipcRenderer.invoke(IpcChannel.MiniWindow_SetPin, isPinned)
},
aes: {
encrypt: (text: string, secretKey: string, iv: string) => ipcRenderer.invoke('aes:encrypt', text, secretKey, iv),
encrypt: (text: string, secretKey: string, iv: string) =>
ipcRenderer.invoke(IpcChannel.Aes_Encrypt, text, secretKey, iv),
decrypt: (encryptedData: string, iv: string, secretKey: string) =>
ipcRenderer.invoke('aes:decrypt', encryptedData, iv, secretKey)
ipcRenderer.invoke(IpcChannel.Aes_Decrypt, encryptedData, iv, secretKey)
},
mcp: {
removeServer: (server: MCPServer) => ipcRenderer.invoke('mcp:remove-server', server),
restartServer: (server: MCPServer) => ipcRenderer.invoke('mcp:restart-server', server),
stopServer: (server: MCPServer) => ipcRenderer.invoke('mcp:stop-server', server),
listTools: (server: MCPServer) => ipcRenderer.invoke('mcp:list-tools', server),
removeServer: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_RemoveServer, server),
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 }) =>
ipcRenderer.invoke('mcp:call-tool', { server, name, args }),
getInstallInfo: () => ipcRenderer.invoke('mcp:get-install-info')
ipcRenderer.invoke(IpcChannel.Mcp_CallTool, { server, name, args }),
getInstallInfo: () => ipcRenderer.invoke(IpcChannel.Mcp_GetInstallInfo)
},
shell: {
openExternal: shell.openExternal
},
copilot: {
getAuthMessage: (headers?: Record<string, string>) => ipcRenderer.invoke('copilot:get-auth-message', headers),
getAuthMessage: (headers?: Record<string, string>) =>
ipcRenderer.invoke(IpcChannel.Copilot_GetAuthMessage, headers),
getCopilotToken: (device_code: string, headers?: Record<string, string>) =>
ipcRenderer.invoke('copilot:get-copilot-token', device_code, headers),
saveCopilotToken: (access_token: string) => ipcRenderer.invoke('copilot:save-copilot-token', access_token),
getToken: (headers?: Record<string, string>) => ipcRenderer.invoke('copilot:get-token', headers),
logout: () => ipcRenderer.invoke('copilot:logout'),
getUser: (token: string) => ipcRenderer.invoke('copilot:get-user', token)
ipcRenderer.invoke(IpcChannel.Copilot_GetCopilotToken, device_code, headers),
saveCopilotToken: (access_token: string) => ipcRenderer.invoke(IpcChannel.Copilot_SaveCopilotToken, access_token),
getToken: (headers?: Record<string, string>) => ipcRenderer.invoke(IpcChannel.Copilot_GetToken, headers),
logout: () => ipcRenderer.invoke(IpcChannel.Copilot_Logout),
getUser: (token: string) => ipcRenderer.invoke(IpcChannel.Copilot_GetUser, token)
},
// Binary related APIs
isBinaryExist: (name: string) => ipcRenderer.invoke('app:is-binary-exist', name),
getBinaryPath: (name: string) => ipcRenderer.invoke('app:get-binary-path', name),
installUVBinary: () => ipcRenderer.invoke('app:install-uv-binary'),
installBunBinary: () => ipcRenderer.invoke('app:install-bun-binary'),
isBinaryExist: (name: string) => ipcRenderer.invoke(IpcChannel.App_IsBinaryExist, name),
getBinaryPath: (name: string) => ipcRenderer.invoke(IpcChannel.App_GetBinaryPath, name),
installUVBinary: () => ipcRenderer.invoke(IpcChannel.App_InstallUvBinary),
installBunBinary: () => ipcRenderer.invoke(IpcChannel.App_InstallBunBinary),
protocol: {
onReceiveData: (callback: (data: { url: string; params: any }) => void) => {
const listener = (_event: Electron.IpcRendererEvent, data: { url: string; params: any }) => {
@@ -159,10 +165,10 @@ const api = {
}
},
nutstore: {
getSSOUrl: () => ipcRenderer.invoke('nutstore:get-sso-url'),
decryptToken: (token: string) => ipcRenderer.invoke('nutstore:decrypt-token', token),
getSSOUrl: () => ipcRenderer.invoke(IpcChannel.Nutstore_GetSsoUrl),
decryptToken: (token: string) => ipcRenderer.invoke(IpcChannel.Nutstore_DecryptToken, token),
getDirectoryContents: (token: string, path: string) =>
ipcRenderer.invoke('nutstore:get-directory-contents', token, path)
ipcRenderer.invoke(IpcChannel.Nutstore_GetDirectoryContents, token, path)
}
}
@@ -174,9 +180,9 @@ if (process.contextIsolated) {
contextBridge.exposeInMainWorld('electron', electronAPI)
contextBridge.exposeInMainWorld('api', api)
contextBridge.exposeInMainWorld('obsidian', {
getVaults: () => ipcRenderer.invoke('obsidian:get-vaults'),
getFolders: (vaultName: string) => ipcRenderer.invoke('obsidian:get-files', vaultName),
getFiles: (vaultName: string) => ipcRenderer.invoke('obsidian:get-files', vaultName)
getVaults: () => ipcRenderer.invoke(IpcChannel.Obsidian_GetVaults),
getFolders: (vaultName: string) => ipcRenderer.invoke(IpcChannel.Obsidian_GetFiles, vaultName),
getFiles: (vaultName: string) => ipcRenderer.invoke(IpcChannel.Obsidian_GetFiles, vaultName)
})
} catch (error) {
console.error(error)

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -36,7 +36,7 @@
--color-text: var(--color-text-1);
--color-icon: #ffffff99;
--color-icon-white: #ffffff;
--color-border: #ffffff15;
--color-border: #ffffff19;
--color-border-soft: #ffffff10;
--color-border-mute: #ffffff05;
--color-error: #f44336;
@@ -80,7 +80,7 @@ body {
body[theme-mode='light'] {
--color-white: #ffffff;
--color-white-soft: #f2f2f2;
--color-white-soft: rgba(0, 0, 0, 0.04);
--color-white-mute: #eee;
--color-black: #1b1b1f;
@@ -108,7 +108,7 @@ body[theme-mode='light'] {
--color-text: var(--color-text-1);
--color-icon: #00000099;
--color-icon-white: #000000;
--color-border: #00000015;
--color-border: #00000019;
--color-border-soft: #00000010;
--color-border-mute: #00000005;
--color-error: #f44336;

View File

@@ -21,8 +21,9 @@
h6 {
margin: 1em 0 1em 0;
font-weight: 800;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans',
'Helvetica Neue', sans-serif;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue',
sans-serif;
}
h1 {
@@ -170,8 +171,9 @@
th {
background-color: var(--color-background-mute);
font-weight: bold;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans',
'Helvetica Neue', sans-serif;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue',
sans-serif;
}
img {
@@ -295,10 +297,10 @@ emoji-picker {
--border-size: 0;
}
.katex-display{
.katex-display {
overflow-x: auto;
overflow-y: hidden;
}
mjx-container{
mjx-container {
overflow-x: auto;
}
}

View File

@@ -5,10 +5,23 @@ interface CustomCollapseProps {
label: React.ReactNode
extra: React.ReactNode
children: React.ReactNode
destroyInactivePanel?: boolean
defaultActiveKey?: string[]
activeKey?: string[]
collapsible?: 'header' | 'icon' | 'disabled'
}
const CustomCollapse: FC<CustomCollapseProps> = ({ label, extra, children }) => {
const CustomCollapse: FC<CustomCollapseProps> = ({
label,
extra,
children,
destroyInactivePanel = false,
defaultActiveKey = ['1'],
activeKey,
collapsible = undefined
}) => {
const CollapseStyle = {
width: '100%',
background: 'transparent',
border: '0.5px solid var(--color-border)'
}
@@ -16,7 +29,10 @@ const CustomCollapse: FC<CustomCollapseProps> = ({ label, extra, children }) =>
header: {
padding: '8px 16px',
alignItems: 'center',
justifyContent: 'space-between'
justifyContent: 'space-between',
background: 'var(--color-background-soft)',
borderTopLeftRadius: '8px',
borderTopRightRadius: '8px'
},
body: {
borderTop: '0.5px solid var(--color-border)'
@@ -26,7 +42,10 @@ const CustomCollapse: FC<CustomCollapseProps> = ({ label, extra, children }) =>
<Collapse
bordered={false}
style={CollapseStyle}
defaultActiveKey={['1']}
defaultActiveKey={defaultActiveKey}
activeKey={activeKey}
destroyInactivePanel={destroyInactivePanel}
collapsible={collapsible}
items={[
{
styles: CollapseItemStyles,

View File

@@ -0,0 +1,40 @@
import { Tooltip } from 'antd'
import { FC } from 'react'
import styled from 'styled-components'
interface CustomTagProps {
icon?: React.ReactNode
children?: React.ReactNode | string
color: string
size?: number
tooltip?: string
}
const CustomTag: FC<CustomTagProps> = ({ children, icon, color, size = 12, tooltip }) => {
return (
<Tooltip title={tooltip} placement="top">
<Tag $color={color} $size={size}>
{icon && icon} {children}
</Tag>
</Tooltip>
)
}
export default CustomTag
const Tag = styled.div<{ $color: string; $size: number }>`
display: inline-flex;
align-items: center;
gap: 4px;
padding: ${({ $size }) => $size / 3}px ${({ $size }) => $size * 0.8}px;
border-radius: 99px;
color: ${({ $color }) => $color};
background-color: ${({ $color }) => $color + '20'};
font-size: ${({ $size }) => $size}px;
line-height: 1;
white-space: nowrap;
.iconfont {
font-size: ${({ $size }) => $size}px;
color: ${({ $color }) => $color};
}
`

View File

@@ -0,0 +1,36 @@
import React, { CSSProperties } from 'react'
import styled from 'styled-components'
interface DividerWithTextProps {
text: string
style?: CSSProperties
}
const DividerWithText: React.FC<DividerWithTextProps> = ({ text, style }) => {
return (
<DividerContainer style={style}>
<DividerText>{text}</DividerText>
<DividerLine />
</DividerContainer>
)
}
const DividerContainer = styled.div`
display: flex;
align-items: center;
margin: 0px 0;
`
const DividerText = styled.span`
font-size: 12px;
color: var(--color-text-2);
margin-right: 8px;
`
const DividerLine = styled.div`
flex: 1;
height: 1px;
background-color: var(--color-border);
`
export default DividerWithText

View File

@@ -1,6 +1,37 @@
import { useEffect, useState } from 'react'
import styled from 'styled-components'
// 记录失败的URL的缓存键前缀
const FAILED_FAVICON_CACHE_PREFIX = 'failed_favicon_'
// 失败URL的缓存时间 (24小时)
const FAILED_FAVICON_CACHE_DURATION = 24 * 60 * 60 * 1000
// 检查URL是否在失败缓存中
const isUrlFailedRecently = (url: string): boolean => {
const cacheKey = `${FAILED_FAVICON_CACHE_PREFIX}${url}`
const cachedTimestamp = localStorage.getItem(cacheKey)
if (!cachedTimestamp) return false
const timestamp = parseInt(cachedTimestamp, 10)
const now = Date.now()
// 如果时间戳在缓存期内则认为URL仍处于失败状态
if (now - timestamp < FAILED_FAVICON_CACHE_DURATION) {
return true
}
// 清除过期的缓存
localStorage.removeItem(cacheKey)
return false
}
// 记录失败的URL到缓存
const markUrlAsFailed = (url: string): void => {
const cacheKey = `${FAILED_FAVICON_CACHE_PREFIX}${url}`
localStorage.setItem(cacheKey, Date.now().toString())
}
// FallbackFavicon component that tries multiple favicon sources
interface FallbackFaviconProps {
hostname: string
@@ -22,20 +53,27 @@ const FallbackFavicon: React.FC<FallbackFaviconProps> = ({ hostname, alt }) => {
// Generate all possible favicon URLs
const faviconUrls = [
`https://favicon.splitbee.io/?url=${hostname}`,
`https://${hostname}/favicon.ico`,
`https://icon.horse/icon/${hostname}`,
`https://favicon.cccyun.cc/${hostname}`,
`https://favicon.splitbee.io/?url=${hostname}`,
`https://favicon.im/${hostname}`,
`https://www.google.com/s2/favicons?domain=${hostname}`
`https://${hostname}/favicon.ico`
]
// 过滤掉最近已失败的URL
const validFaviconUrls = faviconUrls.filter((url) => !isUrlFailedRecently(url))
// 如果所有URL都被缓存为失败使用第一个URL
if (validFaviconUrls.length === 0) {
setFaviconState({ status: 'loaded', src: faviconUrls[0] })
return
}
// Main controller to abort all requests when needed
const controller = new AbortController()
const { signal } = controller
// Create a promise for each favicon URL
const faviconPromises = faviconUrls.map((url) =>
const faviconPromises = validFaviconUrls.map((url) =>
fetch(url, {
method: 'HEAD',
signal,
@@ -45,6 +83,10 @@ const FallbackFavicon: React.FC<FallbackFaviconProps> = ({ hostname, alt }) => {
if (response.ok) {
return url
}
// 记录4xx或5xx失败
if (response.status >= 400) {
markUrlAsFailed(url)
}
throw new Error(`Failed to fetch ${url}`)
})
.catch((error) => {
@@ -89,6 +131,10 @@ const FallbackFavicon: React.FC<FallbackFaviconProps> = ({ hostname, alt }) => {
}, [hostname]) // Only depend on hostname
const handleError = () => {
if (faviconState.status === 'loaded') {
// 记录图片加载失败的URL
markUrlAsFailed(faviconState.src)
}
setFaviconState({ status: 'failed' })
}

View File

@@ -0,0 +1,13 @@
import { SVGProps } from 'react'
export const StreamlineGoodHealthAndWellBeing = (props: SVGProps<SVGSVGElement>) => {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 14 14" {...props}>
{/* Icon from Streamline by Streamline - https://creativecommons.org/licenses/by/4.0/ */}
<g fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
<path d="m10.097 12.468l-2.773-2.52c-1.53-1.522.717-4.423 2.773-2.045c2.104-2.33 4.303.57 2.773 2.045z"></path>
<path d="M.621 6.088h1.367l1.823 3.19l4.101-7.747l1.823 3.646"></path>
</g>
</svg>
)
}

View File

@@ -70,7 +70,7 @@ const TextContainer = styled.div`
overflow: hidden;
`
const TitleText = styled.div`
const TitleText = styled.div<{ $active?: boolean }>`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;

View File

@@ -38,13 +38,6 @@ const WebviewContainer = memo(
useEffect(() => {
if (!webviewRef.current) return
const handleNewWindow = (event: any) => {
event.preventDefault()
if (webviewRef.current?.loadURL) {
webviewRef.current.loadURL(event.url)
}
}
const handleLoaded = () => {
onLoadedCallback(appid)
}
@@ -53,7 +46,6 @@ const WebviewContainer = memo(
onNavigateCallback(appid, event.url)
}
webviewRef.current.addEventListener('new-window', handleNewWindow)
webviewRef.current.addEventListener('did-finish-load', handleLoaded)
webviewRef.current.addEventListener('did-navigate-in-page', handleNavigate)
@@ -61,7 +53,6 @@ const WebviewContainer = memo(
webviewRef.current.src = url
return () => {
webviewRef.current?.removeEventListener('new-window', handleNewWindow)
webviewRef.current?.removeEventListener('did-finish-load', handleLoaded)
webviewRef.current?.removeEventListener('did-navigate-in-page', handleNavigate)
}
@@ -76,7 +67,6 @@ const WebviewContainer = memo(
style={WebviewStyle}
allowpopups={'true' as any}
partition="persist:webview"
nodeintegration={'true' as any}
/>
)
}

View File

@@ -0,0 +1,113 @@
import { EyeOutlined, GlobalOutlined, ToolOutlined } from '@ant-design/icons'
import {
isEmbeddingModel,
isFunctionCallingModel,
isReasoningModel,
isRerankModel,
isVisionModel,
isWebSearchModel
} from '@renderer/config/models'
import i18n from '@renderer/i18n'
import { Model } from '@renderer/types'
import { isFreeModel } from '@renderer/utils'
import { FC, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import CustomTag from './CustomTag'
interface ModelTagsProps {
model: Model
showFree?: boolean
showReasoning?: boolean
showToolsCalling?: boolean
size?: number
showLabel?: boolean
}
const ModelTagsWithLabel: FC<ModelTagsProps> = ({
model,
showFree = true,
showReasoning = true,
showToolsCalling = true,
size = 12,
showLabel = true
}) => {
const { t } = useTranslation()
const [_showLabel, _setShowLabel] = useState(showLabel)
const containerRef = useRef<HTMLDivElement>(null)
const resizeObserver = useRef<ResizeObserver>(null)
useEffect(() => {
if (!showLabel) return
if (containerRef.current) {
const currentElement = containerRef.current
resizeObserver.current = new ResizeObserver((entries) => {
const maxWidth = i18n.language.startsWith('zh') ? 300 : 350
for (const entry of entries) {
const { width } = entry.contentRect
_setShowLabel(width >= maxWidth)
}
})
resizeObserver.current.observe(currentElement)
return () => {
if (resizeObserver.current) {
resizeObserver.current.unobserve(currentElement)
}
}
}
return undefined
}, [showLabel])
return (
<Container ref={containerRef}>
{isVisionModel(model) && (
<CustomTag size={size} color="#00b96b" icon={<EyeOutlined />} tooltip={t('models.type.vision')}>
{_showLabel ? t('models.type.vision') : ''}
</CustomTag>
)}
{isWebSearchModel(model) && (
<CustomTag size={size} color="#1677ff" icon={<GlobalOutlined />} tooltip={t('models.type.websearch')}>
{_showLabel ? t('models.type.websearch') : ''}
</CustomTag>
)}
{showReasoning && isReasoningModel(model) && (
<CustomTag
size={size}
color="#6372bd"
icon={<i className="iconfont icon-thinking" />}
tooltip={t('models.type.reasoning')}>
{_showLabel ? t('models.type.reasoning') : ''}
</CustomTag>
)}
{showToolsCalling && isFunctionCallingModel(model) && (
<CustomTag size={size} color="#f18737" icon={<ToolOutlined />} tooltip={t('models.type.function_calling')}>
{_showLabel ? t('models.type.function_calling') : ''}
</CustomTag>
)}
{isEmbeddingModel(model) && (
<CustomTag size={size} color="#FFA500" icon={t('models.type.embedding')} tooltip={t('models.type.embedding')} />
)}
{showFree && isFreeModel(model) && (
<CustomTag size={size} color="#7cb305" icon={t('models.type.free')} tooltip={t('models.type.free')} />
)}
{isRerankModel(model) && (
<CustomTag size={size} color="#6495ED" icon={t('models.type.rerank')} tooltip={t('models.type.rerank')} />
)}
</Container>
)
}
const Container = styled.div`
display: flex;
flex-direction: row;
align-items: center;
gap: 4px;
flex-wrap: wrap;
`
export default ModelTagsWithLabel

View File

@@ -1,4 +1,5 @@
import { backup } from '@renderer/services/BackupService'
import { IpcChannel } from '@shared/IpcChannel'
import { Modal, Progress } from 'antd'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -21,7 +22,7 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
const { t } = useTranslation()
useEffect(() => {
const removeListener = window.electron.ipcRenderer.on('backup-progress', (_, data: ProgressData) => {
const removeListener = window.electron.ipcRenderer.on(IpcChannel.BackupProgress, (_, data: ProgressData) => {
setProgressData(data)
})

View File

@@ -1,4 +1,5 @@
import { restore } from '@renderer/services/BackupService'
import { IpcChannel } from '@shared/IpcChannel'
import { Modal, Progress } from 'antd'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -21,7 +22,7 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
const { t } = useTranslation()
useEffect(() => {
const removeListener = window.electron.ipcRenderer.on('restore-progress', (_, data: ProgressData) => {
const removeListener = window.electron.ipcRenderer.on(IpcChannel.RestoreProgress, (_, data: ProgressData) => {
setProgressData(data)
})

View File

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

View File

@@ -0,0 +1,11 @@
import { use } from 'react'
import { QuickPanelContext } from './provider'
export const useQuickPanel = () => {
const context = use(QuickPanelContext)
if (!context) {
throw new Error('useQuickPanel must be used within a QuickPanelProvider')
}
return context
}

View File

@@ -0,0 +1,4 @@
export * from './hook'
export * from './provider'
export * from './types'
export * from './view'

View File

@@ -0,0 +1,83 @@
import React, { createContext, useCallback, useMemo, useState } from 'react'
import {
QuickPanelCallBackOptions,
QuickPanelCloseAction,
QuickPanelContextType,
QuickPanelListItem,
QuickPanelOpenOptions
} from './types'
const QuickPanelContext = createContext<QuickPanelContextType | null>(null)
export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children }) => {
const [isVisible, setIsVisible] = useState(false)
const [symbol, setSymbol] = useState<string>('')
const [list, setList] = useState<QuickPanelListItem[]>([])
const [title, setTitle] = useState<string | undefined>()
const [defaultIndex, setDefaultIndex] = useState<number>(0)
const [pageSize, setPageSize] = useState<number>(7)
const [multiple, setMultiple] = useState<boolean>(false)
const [onClose, setOnClose] = useState<
((Options: Pick<QuickPanelCallBackOptions, 'symbol' | 'action'>) => void) | undefined
>()
const [beforeAction, setBeforeAction] = useState<((Options: QuickPanelCallBackOptions) => void) | undefined>()
const [afterAction, setAfterAction] = useState<((Options: QuickPanelCallBackOptions) => void) | undefined>()
const open = useCallback((options: QuickPanelOpenOptions) => {
setTitle(options.title)
setList(options.list)
setDefaultIndex(options.defaultIndex ?? 0)
setPageSize(options.pageSize ?? 7)
setMultiple(options.multiple ?? false)
setSymbol(options.symbol)
setOnClose(() => options.onClose)
setBeforeAction(() => options.beforeAction)
setAfterAction(() => options.afterAction)
setIsVisible(true)
}, [])
const close = useCallback(
(action?: QuickPanelCloseAction) => {
setIsVisible(false)
onClose?.({ symbol, action })
setTimeout(() => {
setList([])
setOnClose(undefined)
setBeforeAction(undefined)
setAfterAction(undefined)
setTitle(undefined)
setSymbol('')
}, 200)
},
[onClose, symbol]
)
const value = useMemo(
() => ({
open,
close,
isVisible,
symbol,
list,
title,
defaultIndex,
pageSize,
multiple,
onClose,
beforeAction,
afterAction
}),
[open, close, isVisible, symbol, list, title, defaultIndex, pageSize, multiple, onClose, beforeAction, afterAction]
)
return <QuickPanelContext value={value}>{children}</QuickPanelContext>
}
export { QuickPanelContext }

View File

@@ -0,0 +1,66 @@
import React from 'react'
export type QuickPanelCloseAction = 'enter' | 'click' | 'esc' | 'outsideclick' | string | undefined
export type QuickPanelCallBackOptions = {
symbol: string
action: QuickPanelCloseAction
item: QuickPanelListItem
searchText?: string
/** 是否处于多选状态 */
multiple?: boolean
}
export type QuickPanelOpenOptions = {
/** 显示在底部左边类似于Placeholder */
title?: string
/** default: [] */
list: QuickPanelListItem[]
/** default: 0 */
defaultIndex?: number
/** default: 7 */
pageSize?: number
/** 是否支持按住cmd/ctrl键多选default: false */
multiple?: boolean
/**
* 用于标识是哪个快捷面板,不是用于触发显示
* 可以是/@#符号,也可以是其他字符串
*/
symbol: string
beforeAction?: (options: QuickPanelCallBackOptions) => void
afterAction?: (options: QuickPanelCallBackOptions) => void
onClose?: (options: QuickPanelCallBackOptions) => void
}
export type QuickPanelListItem = {
label: React.ReactNode | string
description?: React.ReactNode | string
/**
* 由于title跟description可能是ReactNode
* 所以需要单独提供一个用于搜索过滤的文本,
* 这个filterText可以是title跟description的字符串组合
*/
filterText?: string
icon: React.ReactNode | string
suffix?: React.ReactNode | string
isSelected?: boolean
isMenu?: boolean
disabled?: boolean
action?: (options: QuickPanelCallBackOptions) => void
}
// 定义上下文类型
export interface QuickPanelContextType {
readonly open: (options: QuickPanelOpenOptions) => void
readonly close: (action?: QuickPanelCloseAction) => void
readonly isVisible: boolean
readonly symbol: string
readonly list: QuickPanelListItem[]
readonly title?: string
readonly defaultIndex: number
readonly pageSize: number
readonly multiple: boolean
readonly onClose?: (Options: QuickPanelCallBackOptions) => void
readonly beforeAction?: (Options: QuickPanelCallBackOptions) => void
readonly afterAction?: (Options: QuickPanelCallBackOptions) => void
}

View File

@@ -0,0 +1,601 @@
import { CheckOutlined, RightOutlined } from '@ant-design/icons'
import { isMac } from '@renderer/config/constant'
import { classNames } from '@renderer/utils'
import { Flex } from 'antd'
import { t } from 'i18next'
import React, { use, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
import styled from 'styled-components'
import { QuickPanelContext } from './provider'
import { QuickPanelCallBackOptions, QuickPanelCloseAction, QuickPanelListItem, QuickPanelOpenOptions } from './types'
interface Props {
setInputText: React.Dispatch<React.SetStateAction<string>>
}
/**
* @description 快捷面板内容视图;
* 请不要往这里添加入参,避免耦合;
* 这里只读取来自上下文QuickPanelContext的数据
*
* 无奈之举为了清除输入框搜索文本所以传了个setInputText进来
*/
export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
const ctx = use(QuickPanelContext)
if (!ctx) {
throw new Error('QuickPanel must be used within a QuickPanelProvider')
}
const ASSISTIVE_KEY = isMac ? '⌘' : 'Ctrl'
const [isAssistiveKeyPressed, setIsAssistiveKeyPressed] = useState(false)
// 避免上下翻页时,鼠标干扰
const [isMouseOver, setIsMouseOver] = useState(false)
const [index, setIndex] = useState(ctx.defaultIndex)
const [historyPanel, setHistoryPanel] = useState<QuickPanelOpenOptions[]>([])
const bodyRef = useRef<HTMLDivElement>(null)
const contentRef = useRef<HTMLDivElement>(null)
const footerRef = useRef<HTMLDivElement>(null)
const scrollBlock = useRef<ScrollLogicalPosition>('nearest')
const [_searchText, setSearchText] = useState('')
const searchText = useDeferredValue(_searchText)
const searchTextRef = useRef('')
// 解决长按上下键时滚动太慢问题
const keyPressCount = useRef<number>(0)
const scrollBehavior = useRef<'auto' | 'smooth'>('smooth')
// 处理搜索,过滤列表
const list = useMemo(() => {
if (!ctx.isVisible && !ctx.symbol) return []
const newList = ctx.list?.filter((item) => {
const _searchText = searchText.replace(/^[/@]/, '')
if (!_searchText) return true
let filterText = item.filterText || ''
if (typeof item.label === 'string') {
filterText += item.label
}
if (typeof item.description === 'string') {
filterText += item.description
}
return filterText.toLowerCase().includes(_searchText.toLowerCase())
})
setIndex(newList.length > 0 ? ctx.defaultIndex || 0 : -1)
return newList
}, [ctx.defaultIndex, ctx.isVisible, ctx.list, ctx.symbol, searchText])
const canForwardAndBackward = useMemo(() => {
return list.some((item) => item.isMenu) || historyPanel.length > 0
}, [list, historyPanel])
const clearSearchText = useCallback(
(includeSymbol = false) => {
const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement
const cursorPosition = textArea.selectionStart ?? 0
const prevChar = textArea.value[cursorPosition - 1]
if ((prevChar === '/' || prevChar === '@') && !searchTextRef.current) {
searchTextRef.current = prevChar
}
const _searchText = includeSymbol ? searchTextRef.current : searchTextRef.current.replace(/^[/@]/, '')
if (!_searchText) return
const inputText = textArea.value
let newText = inputText
const searchPattern = new RegExp(`${_searchText}$`)
const match = inputText.slice(0, cursorPosition).match(searchPattern)
if (match) {
const start = match.index || 0
const end = start + match[0].length
newText = inputText.slice(0, start) + inputText.slice(end)
setInputText(newText)
setTimeout(() => {
textArea.focus()
textArea.setSelectionRange(start, start)
}, 0)
}
setSearchText('')
},
[setInputText]
)
const handleClose = useCallback(
(action?: QuickPanelCloseAction) => {
ctx.close(action)
setHistoryPanel([])
if (action === 'delete-symbol') {
const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement
if (textArea) {
setInputText(textArea.value)
}
} else if (action && !['outsideclick', 'esc'].includes(action)) {
clearSearchText(true)
}
},
[ctx, clearSearchText, setInputText]
)
const handleItemAction = useCallback(
(item: QuickPanelListItem, action?: QuickPanelCloseAction) => {
if (item.disabled) return
const quickPanelCallBackOptions: QuickPanelCallBackOptions = {
symbol: ctx.symbol,
action,
item,
searchText: searchText,
multiple: isAssistiveKeyPressed
}
ctx.beforeAction?.(quickPanelCallBackOptions)
item?.action?.(quickPanelCallBackOptions)
ctx.afterAction?.(quickPanelCallBackOptions)
if (item.isMenu) {
// 保存上一个打开的选项,用于回退
setHistoryPanel((prev) => [
...(prev || []),
{
title: ctx.title,
list: ctx.list,
symbol: ctx.symbol,
multiple: ctx.multiple,
defaultIndex: index,
pageSize: ctx.pageSize,
onClose: ctx.onClose,
beforeAction: ctx.beforeAction,
afterAction: ctx.afterAction
}
])
clearSearchText(false)
return
}
if (ctx.multiple && isAssistiveKeyPressed) return
handleClose(action)
},
[ctx, searchText, isAssistiveKeyPressed, handleClose, clearSearchText, index]
)
useEffect(() => {
searchTextRef.current = searchText
}, [searchText])
// 获取当前输入的搜索词
useEffect(() => {
if (!ctx.isVisible) return
const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement
const handleInput = (e: Event) => {
const target = e.target as HTMLTextAreaElement
const cursorPosition = target.selectionStart
const textBeforeCursor = target.value.slice(0, cursorPosition)
const lastSlashIndex = textBeforeCursor.lastIndexOf('/')
const lastAtIndex = textBeforeCursor.lastIndexOf('@')
const lastSymbolIndex = Math.max(lastSlashIndex, lastAtIndex)
if (lastSymbolIndex !== -1) {
const newSearchText = textBeforeCursor.slice(lastSymbolIndex)
setSearchText(newSearchText)
} else {
handleClose('delete-symbol')
}
}
textArea.addEventListener('input', handleInput)
return () => {
textArea.removeEventListener('input', handleInput)
setSearchText('')
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ctx.isVisible])
// 处理上下翻时滚动到选中的元素
useEffect(() => {
if (!contentRef.current) return
const selectedElement = contentRef.current.children[index] as HTMLElement
if (selectedElement) {
selectedElement.scrollIntoView({
block: scrollBlock.current,
behavior: scrollBehavior.current
})
scrollBlock.current = 'nearest'
}
}, [index])
// 处理键盘事件
useEffect(() => {
if (!ctx.isVisible) return
const handleKeyDown = (e: KeyboardEvent) => {
if (isMac ? e.metaKey : e.ctrlKey) {
setIsAssistiveKeyPressed(true)
}
// 处理上下翻页时,滚动太慢问题
if (['ArrowUp', 'ArrowDown'].includes(e.key)) {
keyPressCount.current++
if (keyPressCount.current > 5) {
scrollBehavior.current = 'auto'
}
}
if (['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Enter', 'Escape'].includes(e.key)) {
e.preventDefault()
e.stopPropagation()
setIsMouseOver(false)
}
if (['ArrowLeft', 'ArrowRight'].includes(e.key) && isAssistiveKeyPressed) {
e.preventDefault()
e.stopPropagation()
setIsMouseOver(false)
}
switch (e.key) {
case 'ArrowUp':
if (isAssistiveKeyPressed) {
scrollBlock.current = 'start'
setIndex((prev) => {
const newIndex = prev - ctx.pageSize
if (prev === 0) return list.length - 1
return newIndex < 0 ? 0 : newIndex
})
} else {
scrollBlock.current = 'nearest'
setIndex((prev) => (prev > 0 ? prev - 1 : list.length - 1))
}
break
case 'ArrowDown':
if (isAssistiveKeyPressed) {
scrollBlock.current = 'start'
setIndex((prev) => {
const newIndex = prev + ctx.pageSize
if (prev + 1 === list.length) return 0
return newIndex >= list.length ? list.length - 1 : newIndex
})
} else {
scrollBlock.current = 'nearest'
setIndex((prev) => (prev < list.length - 1 ? prev + 1 : 0))
}
break
case 'PageUp':
scrollBlock.current = 'start'
setIndex((prev) => {
const newIndex = prev - ctx.pageSize
return newIndex < 0 ? 0 : newIndex
})
break
case 'PageDown':
scrollBlock.current = 'start'
setIndex((prev) => {
const newIndex = prev + ctx.pageSize
return newIndex >= list.length ? list.length - 1 : newIndex
})
break
case 'ArrowLeft':
if (!isAssistiveKeyPressed) return
if (!historyPanel.length) return
clearSearchText(false)
if (historyPanel.length > 0) {
const lastPanel = historyPanel.pop()
if (lastPanel) {
ctx.open(lastPanel)
}
}
break
case 'ArrowRight':
if (!isAssistiveKeyPressed) return
if (!list?.[index]?.isMenu) return
clearSearchText(false)
handleItemAction(list[index], 'enter')
break
case 'Enter':
if (list?.[index]) {
handleItemAction(list[index], 'enter')
}
break
case 'Escape':
handleClose('esc')
break
}
}
const handleKeyUp = (e: KeyboardEvent) => {
if (isMac ? !e.metaKey : !e.ctrlKey) {
setIsAssistiveKeyPressed(false)
}
keyPressCount.current = 0
scrollBehavior.current = 'smooth'
}
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement
if (target.closest('#inputbar')) return
if (bodyRef.current && !bodyRef.current.contains(target)) {
handleClose('outsideclick')
}
}
window.addEventListener('keydown', handleKeyDown)
window.addEventListener('keyup', handleKeyUp)
window.addEventListener('click', handleClickOutside)
return () => {
window.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('keyup', handleKeyUp)
window.removeEventListener('click', handleClickOutside)
}
}, [index, isAssistiveKeyPressed, historyPanel, ctx, list, handleItemAction, handleClose, clearSearchText])
const [footerWidth, setFooterWidth] = useState(0)
useEffect(() => {
if (!footerRef.current || !ctx.isVisible) return
const footerWidth = footerRef.current.clientWidth
setFooterWidth(footerWidth)
const handleResize = () => {
const footerWidth = footerRef.current!.clientWidth
setFooterWidth(footerWidth)
}
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [ctx.isVisible])
return (
<QuickPanelContainer $pageSize={ctx.pageSize} className={ctx.isVisible ? 'visible' : ''}>
<QuickPanelBody ref={bodyRef} onMouseMove={() => setIsMouseOver(true)}>
<QuickPanelContent ref={contentRef} $pageSize={ctx.pageSize} $isMouseOver={isMouseOver}>
{list.map((item, i) => (
<QuickPanelItem
className={classNames({
focused: i === index,
selected: item.isSelected,
disabled: item.disabled
})}
key={i}
onClick={(e) => {
e.stopPropagation()
handleItemAction(item, 'click')
}}
onMouseEnter={() => setIndex(i)}>
<QuickPanelItemLeft>
<QuickPanelItemIcon>{item.icon}</QuickPanelItemIcon>
<QuickPanelItemLabel>{item.label}</QuickPanelItemLabel>
</QuickPanelItemLeft>
<QuickPanelItemRight>
{item.description && <QuickPanelItemDescription>{item.description}</QuickPanelItemDescription>}
<QuickPanelItemSuffixIcon>
{item.suffix ? (
item.suffix
) : item.isSelected ? (
<CheckOutlined />
) : (
item.isMenu && !item.disabled && <RightOutlined />
)}
</QuickPanelItemSuffixIcon>
</QuickPanelItemRight>
</QuickPanelItem>
))}
</QuickPanelContent>
<QuickPanelFooter ref={footerRef}>
<QuickPanelFooterTitle>{ctx.title || ''}</QuickPanelFooterTitle>
<QuickPanelFooterTips $footerWidth={footerWidth}>
<span>ESC {t('settings.quickPanel.close')}</span>
<Flex align="center" gap={4}>
{t('settings.quickPanel.select')}
</Flex>
{footerWidth >= 500 && (
<>
<Flex align="center" gap={4}>
<span style={{ color: isAssistiveKeyPressed ? 'var(--color-primary)' : 'var(--color-text-3)' }}>
{ASSISTIVE_KEY}
</span>
+ {t('settings.quickPanel.page')}
</Flex>
{canForwardAndBackward && (
<Flex align="center" gap={4}>
<span style={{ color: isAssistiveKeyPressed ? 'var(--color-primary)' : 'var(--color-text-3)' }}>
{ASSISTIVE_KEY}
</span>
+ {t('settings.quickPanel.back')}/{t('settings.quickPanel.forward')}
</Flex>
)}
</>
)}
<Flex align="center" gap={4}>
{t('settings.quickPanel.confirm')}
</Flex>
{ctx.multiple && (
<Flex align="center" gap={4}>
<span style={{ color: isAssistiveKeyPressed ? 'var(--color-primary)' : 'var(--color-text-3)' }}>
{ASSISTIVE_KEY}
</span>
+ {t('settings.quickPanel.multiple')}
</Flex>
)}
</QuickPanelFooterTips>
</QuickPanelFooter>
</QuickPanelBody>
</QuickPanelContainer>
)
}
const QuickPanelContainer = styled.div<{ $pageSize: number }>`
--focused-color: rgba(0, 0, 0, 0.06);
--selected-color: rgba(0, 0, 0, 0.03);
max-height: 0;
position: absolute;
top: 1px;
left: 0;
right: 0;
width: 100%;
padding: 0 30px 0 30px;
transform: translateY(-100%);
transform-origin: bottom;
transition: max-height 0.2s ease;
overflow: hidden;
pointer-events: none;
&.visible {
pointer-events: auto;
max-height: ${(props) => props.$pageSize * 31 + 100}px;
}
body[theme-mode='dark'] & {
--focused-color: rgba(255, 255, 255, 0.1);
--selected-color: rgba(255, 255, 255, 0.03);
}
`
const QuickPanelBody = styled.div`
background-color: rgba(240, 240, 240, 0.5);
backdrop-filter: blur(35px) saturate(150%);
border-radius: 8px 8px 0 0;
padding: 5px 0;
border-width: 0.5px 0.5px 0 0.5px;
border-style: solid;
border-color: var(--color-border);
body[theme-mode='dark'] & {
background-color: rgba(40, 40, 40, 0.4);
}
`
const QuickPanelFooter = styled.div`
display: flex;
width: 100%;
justify-content: space-between;
align-items: center;
gap: 16px;
padding: 8px 12px 5px;
`
const QuickPanelFooterTips = styled.div<{ $footerWidth: number }>`
display: flex;
align-items: center;
justify-content: flex-end;
flex-shrink: 0;
gap: 16px;
font-size: 10px;
color: var(--color-text-3);
`
const QuickPanelFooterTitle = styled.div`
font-size: 11px;
color: var(--color-text-3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`
const QuickPanelContent = styled.div<{ $pageSize: number; $isMouseOver: boolean }>`
width: 100%;
max-height: ${(props) => props.$pageSize * 31}px;
padding: 0 5px;
overflow-x: hidden;
overflow-y: auto;
pointer-events: ${(props) => (props.$isMouseOver ? 'auto' : 'none')};
&::-webkit-scrollbar {
width: 3px;
}
`
const QuickPanelItem = styled.div`
height: 30px;
display: flex;
align-items: center;
gap: 20px;
justify-content: space-between;
padding: 5px;
border-radius: 6px;
cursor: pointer;
transition: background-color 0.1s ease;
margin-bottom: 1px;
&.selected {
background-color: var(--selected-color);
}
&.focused {
background-color: var(--focused-color);
}
&.disabled {
--selected-color: rgba(0, 0, 0, 0.02);
opacity: 0.4;
cursor: not-allowed;
}
`
const QuickPanelItemLeft = styled.div`
max-width: 60%;
display: flex;
align-items: center;
gap: 5px;
flex: 1;
flex-shrink: 0;
`
const QuickPanelItemIcon = styled.span`
font-size: 12px;
color: var(--color-text-3);
`
const QuickPanelItemLabel = styled.span`
flex: 1;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex-shrink: 0;
`
const QuickPanelItemRight = styled.div`
min-width: 20%;
font-size: 11px;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 2px;
color: var(--color-text-3);
`
const QuickPanelItemDescription = styled.span`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`
const QuickPanelItemSuffixIcon = styled.span`
min-width: 12px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 3px;
`

View File

@@ -288,7 +288,7 @@ const PinnedApps: FC = () => {
<Icon
theme={theme}
onClick={() => openMinappKeepAlive(app)}
className={`${isActive ? 'active' : ''} ${openedKeepAliveMinapps.some((item) => item.id === app.id) ? 'opened-animation' : ''}`}>
className={`${isActive ? 'active' : ''} ${openedKeepAliveMinapps.some((item) => item.id === app.id) ? 'opened-minapp' : ''}`}>
<MinAppIcon size={20} app={app} style={{ borderRadius: 6 }} />
</Icon>
</Dropdown>
@@ -403,11 +403,10 @@ const Icon = styled.div<{ theme: string }>`
}
}
&.opened-animation {
&.opened-minapp {
position: relative;
}
&.opened-animation::after {
&.opened-minapp::after {
content: '';
position: absolute;
width: 100%;
@@ -415,13 +414,8 @@ const Icon = styled.div<{ theme: string }>`
top: 0;
left: 0;
border-radius: inherit;
opacity: 0;
will-change: opacity;
opacity: 0.3;
border: 0.5px solid var(--color-primary);
/* NOTICE: although we have optimized for the performance,
* the infinite animation will still consume a little GPU resources,
* it's a trade-off balance between performance and animation smoothness*/
animation: borderBreath 4s ease-in-out infinite;
}
`

View File

@@ -1,12 +1,12 @@
import ThreeMinTopAppLogo from '@renderer/assets/images/apps/3mintop.png?url'
import AbacusLogo from '@renderer/assets/images/apps/abacus.webp?url'
import AIStudioLogo from '@renderer/assets/images/apps/aistudio.svg?url'
import BaiduAiAppLogo from '@renderer/assets/images/apps/baidu-ai.png?url'
import BaiduAiSearchLogo from '@renderer/assets/images/apps/baidu-ai-search.webp?url'
import BaicuanAppLogo from '@renderer/assets/images/apps/baixiaoying.webp?url'
import BoltAppLogo from '@renderer/assets/images/apps/bolt.svg?url'
import CiciAppLogo from '@renderer/assets/images/apps/cici.webp?url'
import CozeAppLogo from '@renderer/assets/images/apps/coze.webp?url'
import DangbeiLogo from '@renderer/assets/images/apps/dangbei.jpg?url'
import DevvAppLogo from '@renderer/assets/images/apps/devv.png?url'
import DifyAppLogo from '@renderer/assets/images/apps/dify.svg?url'
import DoubaoAppLogo from '@renderer/assets/images/apps/doubao.png?url'
@@ -308,12 +308,6 @@ export const DEFAULT_MIN_APPS: MinAppType[] = [
url: 'https://3min.top',
bodered: false
},
{
id: 'aistudio',
name: 'AI Studio',
logo: AIStudioLogo,
url: 'https://aistudio.google.com/'
},
{
id: 'xiaoyi',
name: '小艺',
@@ -391,5 +385,12 @@ export const DEFAULT_MIN_APPS: MinAppType[] = [
logo: ZhihuAppLogo,
url: 'https://zhida.zhihu.com/',
bodered: true
},
{
id: 'dangbei',
name: '当贝AI',
logo: DangbeiLogo,
url: 'https://ai.dangbei.com/',
bodered: true
}
]

View File

@@ -133,6 +133,7 @@ import { getProviderByModel } from '@renderer/services/AssistantService'
import { Assistant, Model } from '@renderer/types'
import OpenAI from 'openai'
import { WEB_SEARCH_PROMPT_FOR_OPENROUTER } from './prompts'
import { getWebSearchTools } from './tools'
// Vision models
@@ -2012,7 +2013,8 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
name: 'rerank-2-lite',
group: 'Voyage Rerank V2'
}
]
],
qiniu: []
}
export const TEXT_TO_IMAGES_MODELS = [
@@ -2148,6 +2150,9 @@ export function isVisionModel(model: Model): boolean {
export function isOpenAIoSeries(model: Model): boolean {
return ['o1', 'o1-2024-12-17'].includes(model.id) || model.id.includes('o3')
}
export function isOpenAIWebSearch(model: Model): boolean {
return model.id.includes('gpt-4o-search-preview') || model.id.includes('gpt-4o-mini-search-preview')
}
export function isSupportedResoningEffortModel(model?: Model): boolean {
if (!model) {
@@ -2212,7 +2217,7 @@ export function isWebSearchModel(model: Model): boolean {
}
if (provider?.type === 'openai') {
if (GEMINI_SEARCH_MODELS.includes(model?.id)) {
if (GEMINI_SEARCH_MODELS.includes(model?.id) || isOpenAIWebSearch(model)) {
return true
}
}
@@ -2239,7 +2244,7 @@ export function isWebSearchModel(model: Model): boolean {
return true
}
return false
return model.type?.includes('web_search') || false
}
export function isGenerateImageModel(model: Model): boolean {
@@ -2270,7 +2275,7 @@ export function getOpenAIWebSearchParams(assistant: Assistant, model: Model): Re
const webSearchTools = getWebSearchTools(model)
if (model.provider === 'hunyuan') {
return { enable_enhancement: true }
return { enable_enhancement: true, citation: true, search_info: true }
}
if (model.provider === 'dashscope') {
@@ -2284,10 +2289,14 @@ export function getOpenAIWebSearchParams(assistant: Assistant, model: Model): Re
if (model.provider === 'openrouter') {
return {
plugins: [{ id: 'web' }]
plugins: [{ id: 'web', search_prompts: WEB_SEARCH_PROMPT_FOR_OPENROUTER }]
}
}
if (isOpenAIWebSearch(model)) {
return {}
}
return {
tools: webSearchTools
}
@@ -2308,3 +2317,23 @@ export function isGemmaModel(model?: Model): boolean {
return model.id.includes('gemma-') || model.group === 'Gemma'
}
export function isZhipuModel(model?: Model): boolean {
if (!model) {
return false
}
return model.provider === 'zhipu'
}
export function isHunyuanSearchModel(model?: Model): boolean {
if (!model) {
return false
}
if (model.provider === 'hunyuan') {
return model.id !== 'hunyuan-lite'
}
return false
}

View File

@@ -1,3 +1,5 @@
import dayjs from 'dayjs'
export const AGENT_PROMPT = `
You are a Prompt Generator. You will integrate user input information into a structured Prompt using Markdown syntax. Please do not use code blocks for output, display directly!
@@ -109,3 +111,20 @@ export const FOOTNOTE_PROMPT = `Please answer the question based on the referenc
{references}
`
export const WEB_SEARCH_PROMPT_FOR_ZHIPU = `
# 以下是来自互联网的信息:
{search_result}
# 当前日期: ${dayjs().format('YYYY-MM-DD')}
# 要求:
根据最新发布的信息回答用户问题,当回答引用了参考信息时,必须在句末使用对应的[ref_序号](url)的markdown链接形式来标明参考信息来源。
`
export const WEB_SEARCH_PROMPT_FOR_OPENROUTER = `
A web search was conducted on \`${dayjs().format('YYYY-MM-DD')}\`. Incorporate the following web search results into your response.
IMPORTANT: Cite them using markdown links named using the domain of the source.
Example: [nytimes.com](https://nytimes.com/some-page).
If have multiple citations, please directly list them like this:
[www.nytimes.com](https://nytimes.com/some-page)[www.bbc.com](https://bbc.com/some-page)
`

View File

@@ -14,7 +14,6 @@ import GiteeAIProviderLogo from '@renderer/assets/images/providers/gitee-ai.png'
import GithubProviderLogo from '@renderer/assets/images/providers/github.png'
import GoogleProviderLogo from '@renderer/assets/images/providers/google.png'
import GPUStackProviderLogo from '@renderer/assets/images/providers/gpustack.svg'
import GraphRagProviderLogo from '@renderer/assets/images/providers/graph-rag.png'
import GrokProviderLogo from '@renderer/assets/images/providers/grok.png'
import GroqProviderLogo from '@renderer/assets/images/providers/groq.png'
import HyperbolicProviderLogo from '@renderer/assets/images/providers/hyperbolic.png'
@@ -33,6 +32,7 @@ import OpenAiProviderLogo from '@renderer/assets/images/providers/openai.png'
import OpenRouterProviderLogo from '@renderer/assets/images/providers/openrouter.png'
import PerplexityProviderLogo from '@renderer/assets/images/providers/perplexity.png'
import PPIOProviderLogo from '@renderer/assets/images/providers/ppio.png'
import QiniuProviderLogo from '@renderer/assets/images/providers/qiniu.webp'
import SiliconFlowProviderLogo from '@renderer/assets/images/providers/silicon.png'
import StepProviderLogo from '@renderer/assets/images/providers/step.png'
import TencentCloudProviderLogo from '@renderer/assets/images/providers/tencent-cloud-ti.png'
@@ -64,7 +64,6 @@ const PROVIDER_LOGO_MAP = {
gemini: GoogleProviderLogo,
stepfun: StepProviderLogo,
doubao: BytedanceProviderLogo,
'graphrag-kylin-mountain': GraphRagProviderLogo,
minimax: MinimaxProviderLogo,
github: GithubProviderLogo,
copilot: GithubProviderLogo,
@@ -88,7 +87,8 @@ const PROVIDER_LOGO_MAP = {
'tencent-cloud-ti': TencentCloudProviderLogo,
gpustack: GPUStackProviderLogo,
alayanew: AlayaNewProviderLogo,
voyageai: VoyageAIProviderLogo
voyageai: VoyageAIProviderLogo,
qiniu: QiniuProviderLogo
} as const
export function getProviderLogo(providerId: string) {
@@ -125,10 +125,9 @@ export const PROVIDER_CONFIG = {
url: 'https://api.ppinfra.com/v3/openai'
},
websites: {
official:
'https://ppinfra.com/model-api/product/llm-api?utm_source=github_cherry-studio&utm_medium=github_readme&utm_campaign=link',
apiKey: 'https://ppinfra.com/settings/key-management',
docs: 'https://ppinfra.com/docs/model-api/reference/llm/llm.html',
official: 'https://ppinfra.com/user/register?invited_by=JYT9GD&utm_source=github_cherry-studio',
apiKey: 'https://ppinfra.com/user/register?invited_by=JYT9GD&utm_source=github_cherry-studio',
docs: 'https://docs.cherry-ai.com/pre-basic/providers/ppio?invited_by=JYT9GD&utm_source=github_cherry-studio',
models:
'https://ppinfra.com/model-api/product/llm-api?utm_source=github_cherry-studio&utm_medium=github_readme&utm_campaign=link'
}
@@ -150,7 +149,7 @@ export const PROVIDER_CONFIG = {
},
websites: {
official: 'https://www.siliconflow.cn/',
apiKey: 'https://cloud.siliconflow.cn/account/ak?referrer=clxty1xuy0014lvqwh5z50i88',
apiKey: 'https://cloud.siliconflow.cn/i/d1nTBKXU',
docs: 'https://docs.siliconflow.cn/',
models: 'https://docs.siliconflow.cn/docs/model-names'
}
@@ -573,5 +572,16 @@ export const PROVIDER_CONFIG = {
docs: 'https://docs.voyageai.com/docs',
models: 'https://docs.voyageai.com/docs'
}
},
qiniu: {
api: {
url: 'https://api.qnaigc.com'
},
websites: {
official: 'https://qiniu.com',
apiKey: 'https://marketing.qiniu.com/activity/2025_newspring?cps_key=1h4vzfbkxobiq#deepseek-title',
docs: 'https://developer.qiniu.com/aitokenapi',
models: 'https://developer.qiniu.com/aitokenapi/12883/model-list'
}
}
}

View File

@@ -1,12 +1,17 @@
import { Model } from '@renderer/types'
import { ChatCompletionTool } from 'openai/resources'
import { WEB_SEARCH_PROMPT_FOR_ZHIPU } from './prompts'
export function getWebSearchTools(model: Model): ChatCompletionTool[] {
if (model?.provider === 'zhipu') {
if (model.id === 'glm-4-alltools') {
return [
{
type: 'web_browser'
type: 'web_browser',
web_browser: {
browser: 'auto'
}
} as unknown as ChatCompletionTool
]
}
@@ -15,18 +20,22 @@ export function getWebSearchTools(model: Model): ChatCompletionTool[] {
type: 'web_search',
web_search: {
enable: true,
search_result: true
search_result: true,
search_prompt: WEB_SEARCH_PROMPT_FOR_ZHIPU
}
} as unknown as ChatCompletionTool
]
}
return [
{
type: 'function',
function: {
name: 'googleSearch'
if (model?.id.includes('gemini')) {
return [
{
type: 'function',
function: {
name: 'googleSearch'
}
}
}
]
]
}
return []
}

View File

@@ -1,21 +1,33 @@
import { useTheme } from '@renderer/context/ThemeProvider'
import { useMermaid } from '@renderer/hooks/useMermaid'
import { useSettings } from '@renderer/hooks/useSettings'
import { CodeCacheService } from '@renderer/services/CodeCacheService'
import { type CodeStyleVarious, ThemeMode } from '@renderer/types'
import type React from 'react'
import { createContext, type PropsWithChildren, use, useCallback, useEffect, useMemo, useState } from 'react'
import type { BundledLanguage, BundledTheme, HighlighterGeneric } from 'shiki'
import { bundledLanguages, bundledThemes, createHighlighter } from 'shiki'
import { createContext, type PropsWithChildren, use, useCallback, useMemo } from 'react'
import { bundledLanguages, bundledThemes, createHighlighter, type Highlighter } from 'shiki'
let highlighterPromise: Promise<Highlighter> | null = null
async function getHighlighter() {
if (!highlighterPromise) {
highlighterPromise = createHighlighter({
langs: ['javascript', 'typescript', 'python', 'java', 'markdown'],
themes: ['one-light', 'material-theme-darker']
})
}
return await highlighterPromise
}
interface SyntaxHighlighterContextType {
codeToHtml: (code: string, language: string) => Promise<string>
codeToHtml: (code: string, language: string, enableCache: boolean) => Promise<string>
}
const SyntaxHighlighterContext = createContext<SyntaxHighlighterContextType | undefined>(undefined)
export const SyntaxHighlighterProvider: React.FC<PropsWithChildren> = ({ children }) => {
const { theme } = useTheme()
const [highlighter, setHighlighter] = useState<HighlighterGeneric<BundledLanguage, BundledTheme> | null>(null)
const { codeStyle } = useSettings()
useMermaid()
@@ -27,29 +39,14 @@ export const SyntaxHighlighterProvider: React.FC<PropsWithChildren> = ({ childre
return codeStyle
}, [theme, codeStyle])
useEffect(() => {
const initHighlighter = async () => {
const commonLanguages = ['javascript', 'typescript', 'python', 'java', 'markdown']
const hl = await createHighlighter({
themes: [highlighterTheme],
langs: commonLanguages
})
setHighlighter(hl)
// Load all themes and languages
// hl.loadTheme(...(Object.keys(bundledThemes) as BundledTheme[]))
// hl.loadLanguage(...(Object.keys(bundledLanguages) as BundledLanguage[]))
}
initHighlighter()
}, [highlighterTheme])
const codeToHtml = useCallback(
async (_code: string, language: string) => {
async (_code: string, language: string, enableCache: boolean) => {
{
if (!highlighter) return ''
if (!_code) return ''
const key = CodeCacheService.generateCacheKey(_code, language, highlighterTheme)
const cached = enableCache ? CodeCacheService.getCachedResult(key) : null
if (cached) return cached
const languageMap: Record<string, string> = {
vab: 'vb'
@@ -61,25 +58,41 @@ export const SyntaxHighlighterProvider: React.FC<PropsWithChildren> = ({ childre
const escapedCode = code?.replace(/[<>]/g, (char) => ({ '<': '&lt;', '>': '&gt;' })[char]!)
try {
if (!highlighter.getLoadedLanguages().includes(mappedLanguage as BundledLanguage)) {
if (mappedLanguage in bundledLanguages || mappedLanguage === 'text') {
await highlighter.loadLanguage(mappedLanguage as BundledLanguage)
} else {
return `<pre style="padding: 10px"><code>${escapedCode}</code></pre>`
const highlighter = await getHighlighter()
if (!highlighter.getLoadedThemes().includes(highlighterTheme)) {
const themeImportFn = bundledThemes[highlighterTheme]
if (themeImportFn) {
await highlighter.loadTheme(await themeImportFn())
}
}
return highlighter.codeToHtml(code, {
if (!highlighter.getLoadedLanguages().includes(mappedLanguage)) {
const languageImportFn = bundledLanguages[mappedLanguage]
if (languageImportFn) {
await highlighter.loadLanguage(await languageImportFn())
}
}
// 生成高亮HTML
const html = highlighter.codeToHtml(code, {
lang: mappedLanguage,
theme: highlighterTheme
})
// 设置缓存
if (enableCache) {
CodeCacheService.setCachedResult(key, html, _code.length)
}
return html
} catch (error) {
console.warn(`Error highlighting code for language '${mappedLanguage}':`, error)
console.debug(`Error highlighting code for language '${mappedLanguage}':`, error)
return `<pre style="padding: 10px"><code>${escapedCode}</code></pre>`
}
}
},
[highlighter, highlighterTheme]
[highlighterTheme]
)
return <SyntaxHighlighterContext value={{ codeToHtml }}>{children}</SyntaxHighlighterContext>

View File

@@ -1,6 +1,7 @@
import { isMac } from '@renderer/config/constant'
import { useSettings } from '@renderer/hooks/useSettings'
import { ThemeMode } from '@renderer/types'
import { IpcChannel } from '@shared/IpcChannel'
import React, { createContext, PropsWithChildren, use, useEffect, useState } from 'react'
interface ThemeContextType {
@@ -49,7 +50,7 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children, defaultT
document.body.setAttribute('os', isMac ? 'mac' : 'windows')
// listen theme change from main process from other windows
const themeChangeListenerRemover = window.electron.ipcRenderer.on('theme:change', (_, newTheme) => {
const themeChangeListenerRemover = window.electron.ipcRenderer.on(IpcChannel.ThemeChange, (_, newTheme) => {
setTheme(newTheme)
})
return () => {

View File

@@ -1,7 +1,8 @@
import { FileType, KnowledgeItem, Topic, TranslateHistory } from '@renderer/types'
import { FileType, KnowledgeItem, QuickPhrase, Topic, TranslateHistory } from '@renderer/types'
import { Dexie, type EntityTable } from 'dexie'
import { upgradeToV5 } from './upgrades'
// Database declaration (move this to its own module also)
export const db = new Dexie('CherryStudio') as Dexie & {
files: EntityTable<FileType, 'id'>
@@ -9,6 +10,7 @@ export const db = new Dexie('CherryStudio') as Dexie & {
settings: EntityTable<{ id: string; value: any }, 'id'>
knowledge_notes: EntityTable<KnowledgeItem, 'id'>
translate_history: EntityTable<TranslateHistory, 'id'>
quick_phrases: EntityTable<QuickPhrase, 'id'>
}
db.version(1).stores({
@@ -46,4 +48,13 @@ db.version(5)
})
.upgrade((tx) => upgradeToV5(tx))
db.version(6).stores({
files: 'id, name, origin_name, path, size, ext, type, created_at, count',
topics: '&id, messages',
settings: '&id, value',
knowledge_notes: '&id, baseId, type, content, created_at, updated_at',
translate_history: '&id, sourceText, targetText, sourceLanguage, targetLanguage, createdAt',
quick_phrases: 'id'
})
export default db

View File

@@ -6,6 +6,8 @@ import i18n from '@renderer/i18n'
import { useAppDispatch } from '@renderer/store'
import { setAvatar, setFilesPath, setResourcesPath, setUpdateState } from '@renderer/store/runtime'
import { delay, runAsyncFunction } from '@renderer/utils'
import { disableAnalytics, initAnalytics } from '@renderer/utils/analytics'
import { defaultLanguage } from '@shared/config/constant'
import { useLiveQuery } from 'dexie-react-hooks'
import { useEffect } from 'react'
@@ -17,7 +19,7 @@ import useUpdateHandler from './useUpdateHandler'
export function useAppInit() {
const dispatch = useAppDispatch()
const { proxyUrl, language, windowStyle, autoCheckUpdate, proxyMode, customCss } = useSettings()
const { proxyUrl, language, windowStyle, autoCheckUpdate, proxyMode, customCss, enableDataCollection } = useSettings()
const { minappShow } = useRuntime()
const { setDefaultModel, setTopicNamingModel, setTranslateModel } = useDefaultModel()
const avatar = useLiveQuery(() => db.settings.get('image://avatar'))
@@ -53,7 +55,7 @@ export function useAppInit() {
}, [proxyUrl, proxyMode])
useEffect(() => {
i18n.changeLanguage(language || navigator.language || 'en-US')
i18n.changeLanguage(language || navigator.language || defaultLanguage)
}, [language])
useEffect(() => {
@@ -102,4 +104,8 @@ export function useAppInit() {
document.head.appendChild(style)
}
}, [customCss])
useEffect(() => {
enableDataCollection ? initAnalytics() : disableAnalytics()
}, [enableDataCollection])
}

View File

@@ -71,8 +71,8 @@ export function useAssistant(id: string) {
updateTopics: (topics: Topic[]) => dispatch(updateTopics({ assistantId: assistant.id, topics })),
removeAllTopics: () => dispatch(removeAllTopics({ assistantId: assistant.id })),
setModel: useCallback(
(model: Model) => dispatch(setModel({ assistantId: assistant.id, model })),
[dispatch, assistant.id]
(model: Model) => assistant && dispatch(setModel({ assistantId: assistant?.id, model })),
[assistant, dispatch]
),
updateAssistant: (assistant: Assistant) => dispatch(updateAssistant(assistant)),
updateAssistantSettings: (settings: Partial<AssistantSettings>) => {

View File

@@ -1,4 +1,5 @@
import { isWindows } from '@renderer/config/constant'
import { IpcChannel } from '@shared/IpcChannel'
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
@@ -6,7 +7,7 @@ export function useFullScreenNotice() {
const { t } = useTranslation()
useEffect(() => {
const cleanup = window.electron.ipcRenderer.on('fullscreen-status-changed', (_, isFullscreen) => {
const cleanup = window.electron.ipcRenderer.on(IpcChannel.FullscreenStatusChanged, (_, isFullscreen) => {
if (isWindows && isFullscreen) {
window.message.info({
content: t('common.fullscreen'),

View File

@@ -21,6 +21,7 @@ import {
} from '@renderer/store/knowledge'
import { FileType, KnowledgeBase, KnowledgeItem, ProcessingStatus } from '@renderer/types'
import { runAsyncFunction } from '@renderer/utils'
import { IpcChannel } from '@shared/IpcChannel'
import { useEffect, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { v4 as uuidv4 } from 'uuid'
@@ -207,7 +208,7 @@ export const useKnowledge = (baseId: string) => {
}
const cleanup = window.electron.ipcRenderer.on(
'directory-processing-percent',
IpcChannel.DirectoryProcessingPercent,
(_, { itemId: id, percent }: { itemId: string; percent: number }) => {
if (itemId === id) {
setPercent(percent)

View File

@@ -1,11 +1,12 @@
import store, { useAppDispatch, useAppSelector } from '@renderer/store'
import { addMCPServer, deleteMCPServer, setMCPServers, updateMCPServer } from '@renderer/store/mcp'
import { MCPServer } from '@renderer/types'
import { IpcChannel } from '@shared/IpcChannel'
const ipcRenderer = window.electron.ipcRenderer
// Listen for server changes from main process
ipcRenderer.on('mcp:servers-changed', (_event, servers) => {
ipcRenderer.on(IpcChannel.Mcp_ServersChanged, (_event, servers) => {
store.dispatch(setMCPServers(servers))
})

View File

@@ -40,7 +40,6 @@ export const useMermaid = () => {
useEffect(() => {
const handleWheel = (e: WheelEvent) => {
if (e.ctrlKey || e.metaKey) {
e.preventDefault()
const mermaidElement = (e.target as HTMLElement).closest('.mermaid')
if (!mermaidElement) return
@@ -61,7 +60,7 @@ export const useMermaid = () => {
}
}
document.addEventListener('wheel', handleWheel, { passive: false })
document.addEventListener('wheel', handleWheel, { passive: true })
return () => document.removeEventListener('wheel', handleWheel)
}, [])
}

View File

@@ -1,5 +1,6 @@
import { useAppDispatch } from '@renderer/store'
import { setUpdateState } from '@renderer/store/runtime'
import { IpcChannel } from '@shared/IpcChannel'
import type { ProgressInfo, UpdateInfo } from 'builder-util-runtime'
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
@@ -14,13 +15,13 @@ export default function useUpdateHandler() {
const ipcRenderer = window.electron.ipcRenderer
const removers = [
ipcRenderer.on('update-not-available', () => {
ipcRenderer.on(IpcChannel.UpdateNotAvailable, () => {
dispatch(setUpdateState({ checking: false }))
if (window.location.hash.includes('settings/about')) {
window.message.success(t('settings.about.updateNotAvailable'))
}
}),
ipcRenderer.on('update-available', (_, releaseInfo: UpdateInfo) => {
ipcRenderer.on(IpcChannel.UpdateAvailable, (_, releaseInfo: UpdateInfo) => {
dispatch(
setUpdateState({
checking: false,
@@ -30,7 +31,7 @@ export default function useUpdateHandler() {
})
)
}),
ipcRenderer.on('download-update', () => {
ipcRenderer.on(IpcChannel.DownloadUpdate, () => {
dispatch(
setUpdateState({
checking: false,
@@ -38,7 +39,7 @@ export default function useUpdateHandler() {
})
)
}),
ipcRenderer.on('download-progress', (_, progress: ProgressInfo) => {
ipcRenderer.on(IpcChannel.DownloadProgress, (_, progress: ProgressInfo) => {
dispatch(
setUpdateState({
downloading: progress.percent < 100,
@@ -46,7 +47,7 @@ export default function useUpdateHandler() {
})
)
}),
ipcRenderer.on('update-downloaded', (_, releaseInfo: UpdateInfo) => {
ipcRenderer.on(IpcChannel.UpdateDownloaded, (_, releaseInfo: UpdateInfo) => {
dispatch(
setUpdateState({
downloading: false,
@@ -55,7 +56,7 @@ export default function useUpdateHandler() {
})
)
}),
ipcRenderer.on('update-error', (_, error) => {
ipcRenderer.on(IpcChannel.UpdateError, (_, error) => {
dispatch(
setUpdateState({
checking: false,

View File

@@ -1,3 +1,4 @@
import { defaultLanguage } from '@shared/config/constant'
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
@@ -26,7 +27,7 @@ const resources = {
}
export const getLanguage = () => {
return localStorage.getItem('language') || navigator.language || 'en-US'
return localStorage.getItem('language') || navigator.language || defaultLanguage
}
export const getLanguageCode = () => {
@@ -36,7 +37,7 @@ export const getLanguageCode = () => {
i18n.use(initReactI18next).init({
resources,
lng: getLanguage(),
fallbackLng: 'en-US',
fallbackLng: defaultLanguage,
interpolation: {
escapeValue: false
}

View File

@@ -159,6 +159,14 @@
"save": "Save",
"settings.code_collapsible": "Code block collapsible",
"settings.code_wrappable": "Code block wrappable",
"settings.code_cacheable": "Code block cache",
"settings.code_cacheable.tip": "Caching code blocks can reduce the rendering time of long code blocks, but it will increase memory usage",
"settings.code_cache_max_size": "Max cache size",
"settings.code_cache_max_size.tip": "The maximum number of characters allowed to be cached (thousand characters), calculated according to the highlighted code. The length of the highlighted code is much longer than the pure text.",
"settings.code_cache_ttl": "Cache TTL",
"settings.code_cache_ttl.tip": "Cache expiration time (minutes)",
"settings.code_cache_threshold": "Cache threshold",
"settings.code_cache_threshold.tip": "The minimum number of characters allowed to be cached (thousand characters), calculated according to the actual code. Only code blocks exceeding the threshold will be cached.",
"settings.context_count": "Context",
"settings.context_count.tip": "The number of previous messages to keep in the context.",
"settings.max": "Max",
@@ -188,6 +196,7 @@
"topics.export.image": "Export as image",
"topics.export.joplin": "Export to Joplin",
"topics.export.md": "Export as markdown",
"topics.export.md.reason": "Export as Markdown (with reasoning)",
"topics.export.notion": "Export to Notion",
"topics.export.obsidian": "Export to Obsidian",
"topics.export.obsidian_vault": "Vault",
@@ -235,7 +244,9 @@
"topics.export.siyuan": "Export to Siyuan Note",
"topics.export.wait_for_title_naming": "Generating title...",
"topics.export.title_naming_success": "Title generated successfully",
"topics.export.title_naming_failed": "Failed to generate title, using default title"
"topics.export.title_naming_failed": "Failed to generate title, using default title",
"input.translating": "Translating...",
"input.upload.upload_from_local": "Upload local file..."
},
"code_block": {
"collapse": "Collapse",
@@ -266,6 +277,7 @@
"duplicate": "Duplicate",
"edit": "Edit",
"expand": "Expand",
"collapse": "Collapse",
"footnote": "Reference content",
"footnotes": "References",
"fullscreen": "Entered fullscreen mode. Press F11 to exit",
@@ -286,7 +298,8 @@
"select": "Select",
"topics": "Topics",
"warning": "Warning",
"you": "You"
"you": "You",
"reasoning_content": "Deep reasoning"
},
"docs": {
"title": "Docs"
@@ -328,7 +341,7 @@
"files": {
"actions": "Actions",
"all": "All Files",
"count": "Count",
"count": "files",
"created_at": "Created At",
"delete": "Delete",
"delete.content": "Deleting a file will delete its reference from all messages. Are you sure you want to delete this file?",
@@ -697,7 +710,6 @@
"gitee-ai": "Gitee AI",
"github": "GitHub Models",
"gpustack": "GPUStack",
"graphrag-kylin-mountain": "GraphRAG",
"grok": "Grok",
"groq": "Groq",
"hunyuan": "Tencent Hunyuan",
@@ -726,7 +738,8 @@
"yi": "Yi",
"zhinao": "360AI",
"zhipu": "ZHIPU AI",
"voyageai": "Voyage AI"
"voyageai": "Voyage AI",
"qiniu": "Qiniu"
},
"restore": {
"confirm": "Are you sure you want to restore data?",
@@ -786,8 +799,24 @@
"title": "Clear Cache"
},
"data.title": "Data Directory",
"divider.basic": "Basic Data Settings",
"divider.cloud_storage": "Cloud Backup Settings",
"divider.export_settings": "Export Settings",
"divider.third_party": "Third-party Connections",
"hour_interval_one": "{{count}} hour",
"hour_interval_other": "{{count}} hours",
"export_menu": {
"title": "Export Menu Settings",
"image": "Export as Image",
"markdown": "Export as Markdown",
"markdown_reason": "Export as Markdown (with reasoning)",
"notion": "Export to Notion",
"yuque": "Export to Yuque",
"obsidian": "Export to Obsidian",
"siyuan": "Export to SiYuan Note",
"joplin": "Export to Joplin",
"docx": "Export as Word"
},
"joplin": {
"check": {
"button": "Check",
@@ -999,6 +1028,9 @@
"argsTooltip": "Each argument on a new line",
"baseUrlTooltip": "Remote server base URL",
"command": "Command",
"sse": "Server-Sent Events(sse)",
"stdio": "Standard Input/Output(stdio)",
"inMemory": "Memory",
"config_description": "Configure Model Context Protocol servers",
"deleteError": "Failed to delete server",
"deleteSuccess": "Server deleted successfully",
@@ -1060,7 +1092,10 @@
"deleteServerConfirm": "Are you sure you want to delete this server?",
"registry": "Package Registry",
"registryTooltip": "Choose the registry for package installation to resolve network issues with the default registry.",
"registryDefault": "Default"
"registryDefault": "Default",
"not_support": "Model not supported",
"user": "User",
"system": "System"
},
"messages.divider": "Show divider between messages",
"messages.grid_columns": "Message grid display columns",
@@ -1257,7 +1292,34 @@
"description": "Tavily is a search engine tailored for AI agents, delivering real-time, accurate results, intelligent query suggestions, and in-depth research capabilities.",
"title": "Tavily"
},
"title": "Web Search"
"title": "Web Search",
"overwrite": "Override search service",
"overwrite_tooltip": "Force use search service instead of LLM"
},
"quickPhrase": {
"title": "Quick Phrases",
"add": "Add Phrase",
"edit": "Edit Phrase",
"titleLabel": "Title",
"contentLabel": "Content",
"titlePlaceholder": "Please enter phrase title",
"contentPlaceholder": "Please enter phrase content, support using variables, and press Tab to quickly locate the variable to modify. For example: \nHelp me plan a route from ${from} to ${to}, and send it to ${email}.",
"delete": "Delete Phrase",
"deleteConfirm": "The phrase cannot be recovered after deletion, continue?"
},
"quickPanel": {
"title": "Quick Menu",
"close": "Close",
"select": "Select",
"page": "Page",
"confirm": "Confirm",
"back": "Back",
"forward": "Forward",
"multiple": "Multiple Select"
},
"privacy": {
"title": "Privacy Settings",
"enable_privacy_mode": "Anonymous reporting of errors and statistics"
}
},
"translate": {
@@ -1283,7 +1345,10 @@
"scroll_sync.disable": "Disable synced scroll",
"scroll_sync.enable": "Enable synced scroll",
"title": "Translation",
"tooltip.newline": "Newline"
"tooltip.newline": "Newline",
"menu": {
"description": "Translate the content of the current input box"
}
},
"tray": {
"quit": "Quit",

View File

@@ -159,6 +159,14 @@
"save": "保存",
"settings.code_collapsible": "コードブロック折り畳み",
"settings.code_wrappable": "コードブロック折り返し",
"settings.code_cacheable": "コードブロックキャッシュ",
"settings.code_cacheable.tip": "コードブロックのキャッシュは長いコードブロックのレンダリング時間を短縮できますが、メモリ使用量が増加します",
"settings.code_cache_max_size": "キャッシュ上限",
"settings.code_cache_max_size.tip": "キャッシュできる文字数の上限(千字符)。ハイライトされたコードの長さは純粋なテキストよりもはるかに長くなります。",
"settings.code_cache_ttl": "キャッシュ期限",
"settings.code_cache_ttl.tip": "キャッシュの有効期限(分単位)。",
"settings.code_cache_threshold": "キャッシュ閾値",
"settings.code_cache_threshold.tip": "キャッシュできる最小のコード長(千字符)。キャッシュできる最小のコード長を超えたコードブロックのみがキャッシュされます。",
"settings.context_count": "コンテキスト",
"settings.context_count.tip": "コンテキストに保持する以前のメッセージの数",
"settings.max": "最大",
@@ -188,6 +196,7 @@
"topics.export.image": "画像としてエクスポート",
"topics.export.joplin": "Joplin にエクスポート",
"topics.export.md": "Markdownとしてエクスポート",
"topics.export.md.reason": "Markdown としてエクスポート (思考内容を含む)",
"topics.export.notion": "Notion にエクスポート",
"topics.export.obsidian": "Obsidian にエクスポート",
"topics.export.obsidian_vault": "保管庫",
@@ -235,7 +244,9 @@
"topics.export.siyuan": "思源笔记にエクスポート",
"topics.export.wait_for_title_naming": "タイトルを生成中...",
"topics.export.title_naming_success": "タイトルの生成に成功しました",
"topics.export.title_naming_failed": "タイトルの生成に失敗しました。デフォルトのタイトルを使用します"
"topics.export.title_naming_failed": "タイトルの生成に失敗しました。デフォルトのタイトルを使用します",
"input.translating": "翻訳中...",
"input.upload.upload_from_local": "ローカルファイルをアップロード..."
},
"code_block": {
"collapse": "折りたたむ",
@@ -266,6 +277,7 @@
"duplicate": "複製",
"edit": "編集",
"expand": "展開",
"collapse": "折りたたむ",
"footnote": "引用内容",
"footnotes": "脚注",
"fullscreen": "全画面モードに入りました。F11キーで終了します",
@@ -286,7 +298,8 @@
"select": "選択",
"topics": "トピック",
"warning": "警告",
"you": "あなた"
"you": "あなた",
"reasoning_content": "深く考察済み"
},
"docs": {
"title": "ドキュメント"
@@ -328,7 +341,7 @@
"files": {
"actions": "操作",
"all": "すべてのファイル",
"count": "",
"count": "ファイル",
"created_at": "作成日",
"delete": "削除",
"delete.content": "ファイルを削除すると、ファイルがすべてのメッセージで参照されることを削除します。このファイルを削除してもよろしいですか?",
@@ -697,7 +710,6 @@
"gitee-ai": "Gitee AI",
"github": "GitHub Models",
"gpustack": "GPUStack",
"graphrag-kylin-mountain": "GraphRAG",
"grok": "Grok",
"groq": "Groq",
"hunyuan": "腾讯混元",
@@ -726,7 +738,8 @@
"yi": "零一万物",
"zhinao": "360智脳",
"zhipu": "智譜AI",
"voyageai": "Voyage AI"
"voyageai": "Voyage AI",
"qiniu": "七牛云"
},
"restore": {
"confirm": "データを復元しますか?",
@@ -786,8 +799,24 @@
"title": "キャッシュをクリア"
},
"data.title": "データディレクトリ",
"divider.basic": "基本データ設定",
"divider.cloud_storage": "クラウドバックアップ設定",
"divider.export_settings": "エクスポート設定",
"divider.third_party": "サードパーティー連携",
"hour_interval_one": "{{count}} 時間",
"hour_interval_other": "{{count}} 時間",
"export_menu": {
"title": "エクスポートメニュー設定",
"image": "画像としてエクスポート",
"markdown": "Markdownとしてエクスポート",
"markdown_reason": "Markdownとしてエクスポート思考内容を含む",
"notion": "Notionにエクスポート",
"yuque": "語雀にエクスポート",
"obsidian": "Obsidianにエクスポート",
"siyuan": "思源ノートにエクスポート",
"joplin": "Joplinにエクスポート",
"docx": "Wordとしてエクスポート"
},
"joplin": {
"check": {
"button": "確認",
@@ -998,6 +1027,9 @@
"argsTooltip": "1行に1つの引数を入力してください",
"baseUrlTooltip": "リモートURLアドレス",
"command": "コマンド",
"sse": "サーバー送信イベント(sse)",
"stdio": "標準入力/出力(stdio)",
"inMemory": "メモリ",
"config_description": "モデルコンテキストプロトコルサーバーの設定",
"deleteError": "サーバーの削除に失敗しました",
"deleteSuccess": "サーバーが正常に削除されました",
@@ -1059,7 +1091,10 @@
"deleteServerConfirm": "このサーバーを削除してもよろしいですか?",
"registry": "パッケージ管理レジストリ",
"registryTooltip": "デフォルトのレジストリでネットワークの問題が発生した場合、パッケージインストールに使用するレジストリを選択してください。",
"registryDefault": "デフォルト"
"registryDefault": "デフォルト",
"not_support": "モデルはサポートされていません",
"user": "ユーザー",
"system": "システム"
},
"messages.divider": "メッセージ間に区切り線を表示",
"messages.grid_columns": "メッセージグリッドの表示列数",
@@ -1256,9 +1291,36 @@
"description": "Tavily は、AI エージェントのために特別に開発された検索エンジンで、最新の結果、インテリジェントな検索提案、そして深い研究能力を提供します",
"title": "Tavily"
},
"title": "ウェブ検索"
"title": "ウェブ検索",
"overwrite": "サービス検索を上書き",
"overwrite_tooltip": "大規模言語モデルではなく、サービス検索を使用する"
},
"general.auto_check_update.title": "自動更新チェックを有効にする"
"general.auto_check_update.title": "自動更新チェックを有効にする",
"quickPhrase": {
"title": "クイックフレーズ",
"add": "フレーズを追加",
"edit": "フレーズを編集",
"titleLabel": "タイトル",
"contentLabel": "内容",
"titlePlaceholder": "フレーズのタイトルを入力してください",
"contentPlaceholder": "フレーズの内容を入力してください。変数を使用することもできます。変数を使用する場合は、Tabキーを押して変数を選択し、変数を変更してください。例\n私の名前は${name}です。",
"delete": "フレーズを削除",
"deleteConfirm": "削除後は復元できません。続行しますか?"
},
"quickPanel": {
"title": "クイックメニュー",
"close": "閉じる",
"select": "選択",
"page": "ページ",
"confirm": "確認",
"back": "戻る",
"forward": "進む",
"multiple": "複数選択"
},
"privacy": {
"title": "プライバシー設定",
"enable_privacy_mode": "匿名エラーレポートとデータ統計の送信"
}
},
"translate": {
"any.language": "任意の言語",
@@ -1283,7 +1345,10 @@
"scroll_sync.disable": "關閉滾動同步",
"scroll_sync.enable": "開啟滾動同步",
"title": "翻訳",
"tooltip.newline": "改行"
"tooltip.newline": "改行",
"menu": {
"description": "對當前輸入框內容進行翻譯"
}
},
"tray": {
"quit": "終了",

View File

@@ -159,6 +159,14 @@
"save": "Сохранить",
"settings.code_collapsible": "Блок кода свернут",
"settings.code_wrappable": "Блок кода можно переносить",
"settings.code_cacheable": "Кэш блока кода",
"settings.code_cacheable.tip": "Кэширование блока кода может уменьшить время рендеринга длинных блоков кода, но увеличит использование памяти",
"settings.code_cache_max_size": "Максимальный размер кэша",
"settings.code_cache_max_size.tip": "Максимальное количество символов, которое может быть кэшировано (тысяч символов), рассчитывается по кэшированному коду. Длина кэшированного кода значительно превышает длину чистого текста.",
"settings.code_cache_ttl": "Время жизни кэша",
"settings.code_cache_ttl.tip": "Время жизни кэша (минуты)",
"settings.code_cache_threshold": "Пороговое значение кэша",
"settings.code_cache_threshold.tip": "Минимальное количество символов для кэширования (тысяч символов), рассчитывается по фактическому коду. Будут кэшированы только те блоки кода, которые превышают пороговое значение",
"settings.context_count": "Контекст",
"settings.context_count.tip": "Количество предыдущих сообщений, которые нужно сохранить в контексте.",
"settings.max": "Максимум",
@@ -188,6 +196,7 @@
"topics.export.image": "Экспорт как изображение",
"topics.export.joplin": "Экспорт в Joplin",
"topics.export.md": "Экспорт как markdown",
"topics.export.md.reason": "Экспорт в Markdown (с рассуждениями)",
"topics.export.notion": "Экспорт в Notion",
"topics.export.obsidian": "Экспорт в Obsidian",
"topics.export.obsidian_vault": "Хранилище",
@@ -235,7 +244,9 @@
"topics.export.siyuan": "Экспорт в Siyuan Note",
"topics.export.wait_for_title_naming": "Создание заголовка...",
"topics.export.title_naming_success": "Заголовок успешно создан",
"topics.export.title_naming_failed": "Не удалось создать заголовок, используется заголовок по умолчанию"
"topics.export.title_naming_failed": "Не удалось создать заголовок, используется заголовок по умолчанию",
"input.translating": "Перевод...",
"input.upload.upload_from_local": "Загрузить локальный файл..."
},
"code_block": {
"collapse": "Свернуть",
@@ -266,6 +277,7 @@
"duplicate": "Дублировать",
"edit": "Редактировать",
"expand": "Развернуть",
"collapse": "Свернуть",
"footnote": "Цитируемый контент",
"footnotes": "Сноски",
"fullscreen": "Вы вошли в полноэкранный режим. Нажмите F11 для выхода",
@@ -286,7 +298,8 @@
"select": "Выбрать",
"topics": "Топики",
"warning": "Предупреждение",
"you": "Вы"
"you": "Вы",
"reasoning_content": "Глубокий анализ"
},
"docs": {
"title": "Документация"
@@ -328,7 +341,7 @@
"files": {
"actions": "Действия",
"all": "Все файлы",
"count": "Количество",
"count": "файлов",
"created_at": "Дата создания",
"delete": "Удалить",
"delete.content": "Удаление файла удалит его из всех сообщений, вы уверены, что хотите удалить этот файл?",
@@ -697,7 +710,6 @@
"gitee-ai": "Gitee AI",
"github": "GitHub Models",
"gpustack": "GPUStack",
"graphrag-kylin-mountain": "GraphRAG",
"grok": "Grok",
"groq": "Groq",
"hunyuan": "Tencent Hunyuan",
@@ -726,7 +738,8 @@
"yi": "Yi",
"zhinao": "360AI",
"zhipu": "ZHIPU AI",
"voyageai": "Voyage AI"
"voyageai": "Voyage AI",
"qiniu": "Qiniu"
},
"restore": {
"confirm": "Вы уверены, что хотите восстановить данные?",
@@ -786,8 +799,24 @@
"title": "Очистка кэша"
},
"data.title": "Каталог данных",
"divider.basic": "Основные настройки данных",
"divider.cloud_storage": "Настройки облачного резервирования",
"divider.export_settings": "Настройки экспорта",
"divider.third_party": "Сторонние подключения",
"hour_interval_one": "{{count}} час",
"hour_interval_other": "{{count}} часов",
"export_menu": {
"title": "Настройки меню экспорта",
"image": "Экспорт как изображение",
"markdown": "Экспорт в Markdown",
"markdown_reason": "Экспорт в Markdown (с рассуждениями)",
"notion": "Экспорт в Notion",
"yuque": "Экспорт в Yuque",
"obsidian": "Экспорт в Obsidian",
"siyuan": "Экспорт в SiYuan Note",
"joplin": "Экспорт в Joplin",
"docx": "Экспорт в Word"
},
"joplin": {
"check": {
"button": "Проверить",
@@ -998,6 +1027,9 @@
"argsTooltip": "Каждый аргумент с новой строки",
"baseUrlTooltip": "Адрес удаленного URL",
"command": "Команда",
"sse": "События, отправляемые сервером(sse)",
"stdio": "Стандартный ввод/вывод(stdio)",
"inMemory": "Память",
"config_description": "Настройка серверов протокола контекста модели",
"deleteError": "Не удалось удалить сервер",
"deleteSuccess": "Сервер успешно удален",
@@ -1059,7 +1091,10 @@
"deleteServerConfirm": "Вы уверены, что хотите удалить этот сервер?",
"registry": "Реестр пакетов",
"registryTooltip": "Выберите реестр для установки пакетов, если возникают проблемы с сетью при использовании реестра по умолчанию.",
"registryDefault": "По умолчанию"
"registryDefault": "По умолчанию",
"not_support": "Модель не поддерживается",
"user": "Пользователь",
"system": "Система"
},
"messages.divider": "Показывать разделитель между сообщениями",
"messages.grid_columns": "Количество столбцов сетки сообщений",
@@ -1256,9 +1291,36 @@
"description": "Tavily — это поисковая система, специально разработанная для ИИ-агентов, предоставляющая актуальные результаты, умные предложения по запросам и глубокие исследовательские возможности",
"title": "Tavily"
},
"title": "Поиск в Интернете"
"title": "Поиск в Интернете",
"overwrite": "Переопределить поставщика поиска",
"overwrite_tooltip": "Использовать поставщика поиска вместо LLM"
},
"general.auto_check_update.title": "Включить автоматическую проверку обновлений"
"general.auto_check_update.title": "Включить автоматическую проверку обновлений",
"quickPhrase": {
"title": "Быстрые фразы",
"add": "Добавить фразу",
"edit": "Редактировать фразу",
"titleLabel": "Заголовок",
"contentLabel": "Содержание",
"titlePlaceholder": "Введите заголовок фразы",
"contentPlaceholder": "Введите содержание фразы, поддерживает использование переменных, и нажмите Tab для быстрого перехода к переменной для изменения. Например: \nПомоги мне спланировать маршрут от ${from} до ${to} и отправить его на ${email}.",
"delete": "Удалить фразу",
"deleteConfirm": "После удаления фраза не может быть восстановлена, продолжить?"
},
"quickPanel": {
"title": "Быстрое меню",
"close": "Закрыть",
"select": "Выбрать",
"page": "Страница",
"confirm": "Подтвердить",
"back": "Назад",
"forward": "Вперед",
"multiple": "Множественный выбор"
},
"privacy": {
"title": "Настройки приватности",
"enable_privacy_mode": "Анонимная отправка отчетов об ошибках и статистики"
}
},
"translate": {
"any.language": "Любой язык",
@@ -1283,7 +1345,10 @@
"scroll_sync.disable": "Отключить синхронизацию прокрутки",
"scroll_sync.enable": "Включить синхронизацию прокрутки",
"title": "Перевод",
"tooltip.newline": "Перевести"
"tooltip.newline": "Перевести",
"menu": {
"description": "Перевести содержимое текущего ввода"
}
},
"tray": {
"quit": "Выйти",

View File

@@ -129,11 +129,13 @@
"input.new_topic": "新话题 {{Command}}",
"input.pause": "暂停",
"input.placeholder": "在这里输入消息...",
"input.translating": "翻译中...",
"input.send": "发送",
"input.settings": "设置",
"input.topics": " 话题 ",
"input.translate": "翻译成{{target_language}}",
"input.upload": "上传图片或文档",
"input.upload.upload_from_local": "上传本地文件...",
"input.upload.document": "上传文档(模型不支持图片)",
"input.web_search": "开启网络搜索",
"input.web_search.button.ok": "去设置",
@@ -159,6 +161,14 @@
"save": "保存",
"settings.code_collapsible": "代码块可折叠",
"settings.code_wrappable": "代码块可换行",
"settings.code_cacheable": "代码块缓存",
"settings.code_cacheable.tip": "缓存代码块可以减少长代码块的渲染时间,但会增加内存占用",
"settings.code_cache_max_size": "缓存上限",
"settings.code_cache_max_size.tip": "允许缓存的字符数上限(千字符),按照高亮后的代码计算。高亮后的代码长度相比于纯文本会长很多。",
"settings.code_cache_ttl": "缓存期限",
"settings.code_cache_ttl.tip": "缓存过期时间(分钟)",
"settings.code_cache_threshold": "缓存阈值",
"settings.code_cache_threshold.tip": "允许缓存的最小代码长度(千字符),超过阈值的代码块才会被缓存",
"settings.context_count": "上下文数",
"settings.context_count.tip": "要保留在上下文中的消息数量,数值越大,上下文越长,消耗的 token 越多。普通聊天建议 5-10",
"settings.max": "不限",
@@ -188,6 +198,7 @@
"topics.export.image": "导出为图片",
"topics.export.joplin": "导出到 Joplin",
"topics.export.md": "导出为 Markdown",
"topics.export.md.reason": "导出为 Markdown (包含思考)",
"topics.export.notion": "导出到 Notion",
"topics.export.obsidian": "导出到 Obsidian",
"topics.export.obsidian_vault": "保管库",
@@ -266,6 +277,7 @@
"duplicate": "复制",
"edit": "编辑",
"expand": "展开",
"collapse": "折叠",
"footnote": "引用内容",
"footnotes": "引用内容",
"fullscreen": "已进入全屏模式,按 F11 退出",
@@ -286,7 +298,8 @@
"select": "选择",
"topics": "话题",
"warning": "警告",
"you": "用户"
"you": "用户",
"reasoning_content": "已深度思考"
},
"docs": {
"title": "帮助文档"
@@ -328,7 +341,7 @@
"files": {
"actions": "操作",
"all": "所有文件",
"count": "文件",
"count": "文件",
"created_at": "创建时间",
"delete": "删除",
"delete.content": "删除文件会删除文件在所有消息中的引用,确定要删除此文件吗?",
@@ -697,7 +710,6 @@
"gitee-ai": "Gitee AI",
"github": "GitHub Models",
"gpustack": "GPUStack",
"graphrag-kylin-mountain": "GraphRAG",
"grok": "Grok",
"groq": "Groq",
"hunyuan": "腾讯混元",
@@ -726,7 +738,8 @@
"yi": "零一万物",
"zhinao": "360智脑",
"zhipu": "智谱AI",
"voyageai": "Voyage AI"
"voyageai": "Voyage AI",
"qiniu": "七牛云"
},
"restore": {
"confirm": "确定要恢复数据吗?",
@@ -786,8 +799,24 @@
"title": "清除缓存"
},
"data.title": "数据目录",
"divider.basic": "基础数据设置",
"divider.cloud_storage": "云备份设置",
"divider.export_settings": "导出设置",
"divider.third_party": "第三方连接",
"hour_interval_one": "{{count}} 小时",
"hour_interval_other": "{{count}} 小时",
"export_menu": {
"title": "导出菜单设置",
"image": "导出为图片",
"markdown": "导出为Markdown",
"markdown_reason": "导出为Markdown包含思考",
"notion": "导出到Notion",
"yuque": "导出到语雀",
"obsidian": "导出到Obsidian",
"siyuan": "导出到思源笔记",
"joplin": "导出到Joplin",
"docx": "导出为Word"
},
"joplin": {
"check": {
"button": "检查",
@@ -999,6 +1028,9 @@
"argsTooltip": "每个参数占一行",
"baseUrlTooltip": "远程 URL 地址",
"command": "命令",
"sse": "服务器发送事件(sse)",
"stdio": "标准输入/输出(stdio)",
"inMemory": "内存",
"config_description": "配置模型上下文协议服务器",
"deleteError": "删除服务器失败",
"deleteSuccess": "服务器删除成功",
@@ -1060,7 +1092,10 @@
"deleteServerConfirm": "确定要删除此服务器吗?",
"registry": "包管理源",
"registryTooltip": "选择用于安装包的源,以解决默认源的网络问题。",
"registryDefault": "默认"
"registryDefault": "默认",
"not_support": "模型不支持",
"user": "用户",
"system": "系统"
},
"messages.divider": "消息分割线",
"messages.grid_columns": "消息网格展示列数",
@@ -1244,6 +1279,8 @@
"check_success": "验证成功",
"enhance_mode": "搜索增强模式",
"enhance_mode_tooltip": "使用默认模型提取关键词后搜索",
"overwrite": "覆盖服务商搜索",
"overwrite_tooltip": "强制使用搜索服务商而不是大语言模型进行搜索",
"get_api_key": "点击这里获取密钥",
"no_provider_selected": "请选择搜索服务商后再检查",
"search_max_result": "搜索结果个数",
@@ -1258,6 +1295,31 @@
"title": "Tavily"
},
"title": "网络搜索"
},
"quickPhrase": {
"title": "快捷短语",
"add": "添加短语",
"edit": "编辑短语",
"titleLabel": "标题",
"contentLabel": "内容",
"titlePlaceholder": "请输入短语标题",
"contentPlaceholder": "请输入短语内容支持使用变量然后按Tab键可以快速定位到变量进行修改。比如\n帮我规划从${from}到${to}的路线,然后发送到${email}。",
"delete": "删除短语",
"deleteConfirm": "删除短语后将无法恢复,是否继续?"
},
"quickPanel": {
"title": "快捷菜单",
"close": "关闭",
"select": "选择",
"page": "翻页",
"confirm": "确认",
"back": "后退",
"forward": "前进",
"multiple": "多选"
},
"privacy": {
"title": "隐私设置",
"enable_privacy_mode": "匿名发送错误报告和数据统计"
}
},
"translate": {
@@ -1277,6 +1339,9 @@
"empty": "暂无翻译历史",
"title": "翻译历史"
},
"menu": {
"description": "对当前输入框内容进行翻译"
},
"input.placeholder": "输入文本进行翻译",
"output.placeholder": "翻译",
"processing": "翻译中...",

View File

@@ -159,6 +159,14 @@
"save": "儲存",
"settings.code_collapsible": "程式碼區塊可折疊",
"settings.code_wrappable": "程式碼區塊可自動換行",
"settings.code_cacheable": "程式碼區塊快取",
"settings.code_cacheable.tip": "快取程式碼區塊可以減少長程式碼區塊的渲染時間,但會增加記憶體使用量",
"settings.code_cache_max_size": "快取上限",
"settings.code_cache_max_size.tip": "允許快取的字元數上限(千字符),按照高亮後的程式碼計算。高亮後的程式碼長度相比純文字會長很多。",
"settings.code_cache_ttl": "快取期限",
"settings.code_cache_ttl.tip": "快取的存活時間(分鐘)",
"settings.code_cache_threshold": "快取門檻",
"settings.code_cache_threshold.tip": "允許快取的最小程式碼長度(千字符),超過門檻的程式碼區塊才會被快取",
"settings.context_count": "上下文",
"settings.context_count.tip": "在上下文中保留的前幾則訊息。",
"settings.max": "最大",
@@ -188,6 +196,7 @@
"topics.export.image": "匯出為圖片",
"topics.export.joplin": "匯出到 Joplin",
"topics.export.md": "匯出為 Markdown",
"topics.export.md.reason": "匯出為 Markdown (包含思考)",
"topics.export.notion": "匯出到 Notion",
"topics.export.obsidian": "匯出到 Obsidian",
"topics.export.obsidian_vault": "保管庫",
@@ -235,7 +244,9 @@
"topics.export.siyuan": "匯出到思源筆記",
"topics.export.wait_for_title_naming": "正在生成標題...",
"topics.export.title_naming_success": "標題生成成功",
"topics.export.title_naming_failed": "標題生成失敗,使用預設標題"
"topics.export.title_naming_failed": "標題生成失敗,使用預設標題",
"input.translating": "翻譯中...",
"input.upload.upload_from_local": "上傳本地文件..."
},
"code_block": {
"collapse": "折疊",
@@ -266,6 +277,7 @@
"duplicate": "複製",
"edit": "編輯",
"expand": "展開",
"collapse": "折疊",
"footnote": "引用內容",
"footnotes": "引用",
"fullscreen": "已進入全螢幕模式,按 F11 結束",
@@ -286,7 +298,8 @@
"select": "選擇",
"topics": "話題",
"warning": "警告",
"you": "您"
"you": "您",
"reasoning_content": "已深度思考"
},
"docs": {
"title": "說明文件"
@@ -328,7 +341,7 @@
"files": {
"actions": "操作",
"all": "所有檔案",
"count": "數量",
"count": "個檔案",
"created_at": "建立時間",
"delete": "刪除",
"delete.content": "刪除檔案會刪除檔案在所有訊息中的引用,確定要刪除此檔案嗎?",
@@ -697,7 +710,6 @@
"gitee-ai": "Gitee AI",
"github": "GitHub Models",
"gpustack": "GPUStack",
"graphrag-kylin-mountain": "GraphRAG",
"grok": "Grok",
"groq": "Groq",
"hunyuan": "騰訊混元",
@@ -726,7 +738,8 @@
"yi": "零一萬物",
"zhinao": "360 智腦",
"zhipu": "智譜 AI",
"voyageai": "Voyage AI"
"voyageai": "Voyage AI",
"qiniu": "七牛雲"
},
"restore": {
"confirm": "確定要復原資料嗎?",
@@ -786,8 +799,24 @@
"title": "清除快取"
},
"data.title": "資料目錄",
"divider.basic": "基礎數據設定",
"divider.cloud_storage": "雲備份設定",
"divider.export_settings": "匯出設定",
"divider.third_party": "第三方連接",
"hour_interval_one": "{{count}} 小時",
"hour_interval_other": "{{count}} 小時",
"export_menu": {
"title": "匯出選單設定",
"image": "匯出為圖片",
"markdown": "匯出為Markdown",
"markdown_reason": "匯出為Markdown包含思考",
"notion": "匯出到Notion",
"yuque": "匯出到語雀",
"obsidian": "匯出到Obsidian",
"siyuan": "匯出到思源筆記",
"joplin": "匯出到Joplin",
"docx": "匯出為Word"
},
"joplin": {
"check": {
"button": "檢查",
@@ -998,6 +1027,9 @@
"argsTooltip": "每個參數佔一行",
"baseUrlTooltip": "遠端 URL 地址",
"command": "指令",
"sse": "伺服器傳送事件(sse)",
"stdio": "標準輸入/輸出(stdio)",
"inMemory": "記憶體",
"config_description": "設定模型上下文協議伺服器",
"deleteError": "刪除伺服器失敗",
"deleteSuccess": "伺服器刪除成功",
@@ -1059,7 +1091,10 @@
"deleteServerConfirm": "確定要刪除此伺服器嗎?",
"registry": "套件管理源",
"registryTooltip": "選擇用於安裝套件的源,以解決預設源的網路問題。",
"registryDefault": "預設"
"registryDefault": "預設",
"not_support": "不支援此模型",
"user": "用戶",
"system": "系統"
},
"messages.divider": "訊息間顯示分隔線",
"messages.grid_columns": "訊息網格展示列數",
@@ -1256,9 +1291,36 @@
"description": "Tavily 是一個為 AI 代理量身訂製的搜尋引擎,提供即時、準確的結果、智慧查詢建議和深入的研究能力",
"title": "Tavily"
},
"title": "網路搜尋"
"title": "網路搜尋",
"overwrite": "覆蓋搜尋服務商",
"overwrite_tooltip": "強制使用搜尋服務商而不是大語言模型進行搜尋"
},
"general.auto_check_update.title": "啟用自動更新檢查"
"general.auto_check_update.title": "啟用自動更新檢查",
"quickPhrase": {
"title": "快捷短語",
"add": "新增短語",
"edit": "編輯短語",
"titleLabel": "標題",
"contentLabel": "內容",
"titlePlaceholder": "請輸入短語標題",
"contentPlaceholder": "請輸入短語內容支持使用變量然後按Tab鍵可以快速定位到變量進行修改。比如\n幫我規劃從${from}到${to}的行程,然後發送到${email}。",
"delete": "刪除短語",
"deleteConfirm": "刪除後無法復原,是否繼續?"
},
"quickPanel": {
"title": "快捷選單",
"close": "關閉",
"select": "選擇",
"page": "翻頁",
"confirm": "確認",
"back": "後退",
"forward": "前進",
"multiple": "多選"
},
"privacy": {
"title": "隱私設定",
"enable_privacy_mode": "匿名發送錯誤報告和資料統計"
}
},
"translate": {
"any.language": "任意語言",
@@ -1283,7 +1345,10 @@
"scroll_sync.disable": "關閉滾動同步",
"scroll_sync.enable": "開啟滾動同步",
"title": "翻譯",
"tooltip.newline": "換行"
"tooltip.newline": "換行",
"menu": {
"description": "對當前輸入框內容進行翻譯"
}
},
"tray": {
"quit": "結束",

View File

@@ -643,7 +643,6 @@
"gitee-ai": "Gitee AI",
"github": "GitHub Models",
"gpustack": "GPUStack",
"graphrag-kylin-mountain": "GraphRAG",
"grok": "Grok",
"groq": "Groq",
"hunyuan": "Tencent Hunyuan",

View File

@@ -643,7 +643,6 @@
"gitee-ai": "Gitee IA",
"github": "GitHub Modelos",
"gpustack": "GPUStack",
"graphrag-kylin-mountain": "GraphRAG",
"grok": "Grok",
"groq": "Groq",
"hunyuan": "Tencent Hùnyuán",

View File

@@ -643,7 +643,6 @@
"gitee-ai": "Gitee IA",
"github": "GitHub Modèles",
"gpustack": "GPUStack",
"graphrag-kylin-mountain": "GraphRAG",
"grok": "Grok",
"groq": "Groq",
"hunyuan": "Tencent HunYuan",

View File

@@ -643,7 +643,6 @@
"gitee-ai": "Gitee IA",
"github": "GitHub Models",
"gpustack": "GPUStack",
"graphrag-kylin-mountain": "GraphRAG",
"grok": "Compreender",
"groq": "Groq",
"hunyuan": "Tencent Hún Yuán",

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