Compare commits

...

131 Commits

Author SHA1 Message Date
Neal_Tan
370cfd6e9f Merge pull request #4331 from CherryHQ/main
Merge main code
2025-04-02 22:06:47 +01:00
Yuzhong Zhang
5cdf4eff77 fix(CodeBlock): incorrect behavior of message in multiple models (#4328)
* 修复多模型对比时的复制按钮sticky行为,并添加注释

* 同时修复横向滚动条消失的问题

* 增加布局判断
2025-04-03 01:01:04 +08:00
LiuVaayne
b53dbcbb30 fix(mcp-tools): enhance tool lookup to match by name in addition to ID (#4323) 2025-04-02 23:20:07 +08:00
Bowie He
a42283e789 feat:add default doubao model to model list 2025-04-02 18:56:30 +08:00
kanweiwei
d2ed9972bd fix(NutstoreService): Fix slash handling in path processing #4208 2025-04-02 13:38:29 +08:00
fullex
0fd9b6e56c feat: miniWindow Pin/Resize (#3201)
feat: [#2030] miniWindow pin/resizable/copy toast/move optimized
2025-04-02 10:26:56 +08:00
Neal_Tan
d213bc1024 Merge branch 'main' into feat/variable_replace_prompt 2025-04-01 18:57:46 +01:00
shiquda
91b9a48c48 fix(Knowledge): enable text selection in knowledge base search results (#4281) 2025-04-01 23:16:34 +08:00
kangfenmao
e572b3801b lint: Added an eslint disable comment in MinappPopupContainer to address 2025-04-01 21:05:36 +08:00
亢奋猫
4bf15aed25 refactor(MCPService, process): Updated MCPService to conditionally set the NPM_CONFIG_REGISTRY
* refactor(MCPService, process): enhance registry URL handling and improve getBinaryPath function

- Updated MCPService to conditionally set the NPM_CONFIG_REGISTRY based on server name, improving flexibility for auto-install scenarios.
- Modified getBinaryPath function to handle optional name parameter, returning a default path when no name is provided, enhancing usability.

* refactor(MCPService, utils): add directory existence check for registry file

- Introduced makeSureDirExists utility function to ensure the specified directory exists, enhancing robustness.
- Updated MCPService to utilize this function when setting the registry URL for the mcp-auto-install server, improving error handling.

* feat:change MCP_REGISTRY_PATH

* refactor(MCPService): streamline environment variable setup for mcp-auto-install

- Updated MCPService to conditionally set NPM_CONFIG_REGISTRY and MCP_REGISTRY_PATH in a more concise manner.
- Enhanced readability by removing redundant code while maintaining functionality.

---------

Co-authored-by: lizhixuan <zhixuan.li@banosuperapp.com>
2025-04-01 20:57:56 +08:00
Chen Tao
6d568688ed fix(KnowledgeContent): adjust VirtualList height based on item count (#4270) 2025-04-01 19:47:58 +08:00
kangfenmao
f20cbf31a8 refactor(McpSettings): simplify args form item and adjust navbar padding for Windows
- Removed unnecessary validation rules from the args Form.Item in McpSettings for cleaner code.
- Updated McpSettingsNavbar to conditionally adjust padding based on the operating system.

close #4244
2025-04-01 17:03:27 +08:00
kangfenmao
bfbfba13fe feat(Inputbar, MCPToolsButton, AssistantMCPSettings): integrate active MCP server handling and UI updates
- Added active MCP server filtering in Inputbar for message sending.
- Updated MCPToolsButton to reflect availability of enabled MCPs.
- Refactored AssistantMCPSettings to streamline MCP server updates and adjusted UI styles for consistency.
2025-04-01 16:57:12 +08:00
Hobee Liu
8b9929cc7b feat: add chat navigation bar close (#4019)
* feat(聊天导航): 新增关闭、置顶和置底按钮并更新图标

在聊天导航组件中新增了关闭、置顶和置底按钮,并更新了相关图标以提升用户体验。同时,添加了点击关闭按钮时隐藏导航的功能。

* feat(消息导航): 添加手动关闭状态以避免误触

在 ChatNavigation 组件中添加 `manuallyClosedUntil` 状态,用于在用户手动关闭导航后,1分钟内不响应鼠标靠近事件。这可以防止用户在操作时误触导航栏,提升用户体验。

* refactor(ChatNavigation): 重命名函数并添加滚动处理逻辑

重命名 handleChatNavigationClick 为 handleCloseChatNavigation 以提高代码可读性,并添加 handleScrollToTop 和 handleScrollToBottom 函数以处理滚动逻辑

* fix: 修复滚动到顶部时位置不正确的问题

将 `scrollToTop` 函数中的 `top` 值从 `0` 改为 `-container.scrollHeight`,以确保滚动到顶部时位置正确

* docs(i18n): 添加新的翻译字符串以支持更多操作

在多个语言文件中添加了“回到顶部”、“回到底部”和“关闭”的翻译字符串,以支持更多用户界面操作。

* refactor: 移除未使用的变量以简化代码
```

解释:
- **类型**: `refactor`,因为这是代码重构,移除了未使用的变量,没有改变功能行为。
- **描述**: 移除了未使用的变量以简化代码,符合简洁和可维护性的原则。
2025-04-01 16:17:57 +08:00
Cherry
a90be7e83f feat: One-click copy model id (#4190)
* feat:One-click copy model id

* fix:model id消失问题,样式问题
2025-04-01 16:11:06 +08:00
fullex
efa68c8519 fix: chat history dark theme (#4254) 2025-04-01 14:55:24 +08:00
LiuVaayne
d7bd240a9a refactor: enhance mcp init (#4238)
* fix(MCPService): extend command support to include 'bun' and 'bunx', and improve environment variable handling

* fix(MCPService): enhance environment variable handling by incorporating default environment settings

* fix(hooks): simplify active MCP servers selection logic
2025-04-01 13:08:25 +08:00
kangfenmao
95df69ff82 refactor: move websearch provider code to providers folder 2025-04-01 11:05:31 +08:00
kangfenmao
e41df917b4 feat(i18n): add topic naming model support for message title generation in Japanese, Russian, and Traditional Chinese locales 2025-04-01 10:41:30 +08:00
kangfenmao
0a33649b3c fix(settings): enable clickAssistantToShowTopic by default 2025-04-01 10:06:24 +08:00
kangfenmao
d1cb7258d2 fix(Inputbar): simplify assistant state reset logic in useEffect 2025-04-01 09:57:21 +08:00
shiquda
8fbedb2bd0 feat: add support for title generation when exporting single message
#3992
2025-04-01 07:34:57 +08:00
MyPrototypeWhat
750247aef8 feat: add React Developer Tools extension support and optimize CodeBlock component 2025-04-01 07:33:16 +08:00
suyao
32e1f428e7 fix(styles): set dropdown menu width to max-content for better layout 2025-03-31 23:25:10 +08:00
suyao
aee6219a75 fix(styles): improve scrollbar visibility by adjusting opacity and background color on hover 2025-03-31 23:25:10 +08:00
suyao
5329fa7ede fix(UI): enhance scrollbar visibility and dropdown menu overflow handling 2025-03-31 23:25:10 +08:00
SuYao
ba640d4070 refactor(MCP): enhance schema validation for gemini (#4153) 2025-03-31 21:13:59 +08:00
LiuVaayne
8c5f61d407 feat(MCP): add registryUrl support for package management (#4200) 2025-03-31 21:13:20 +08:00
one
b43ecb75f5 perf: improve modellist search bar responsiveness (and memorization) (#4221) 2025-03-31 21:11:46 +08:00
Yuzhong Zhang
3dc4947e26 optimize: Sticky CopyButton in CodeBlock (#4205) 2025-03-31 21:11:28 +08:00
LiuVaayne
a5b0480418 Feat/assistant level mcp (#4220) 2025-03-31 21:10:33 +08:00
fullex
8a7db19e73 fix: Resolve a series of miniWindow display issues and improve app behavior across platforms (#3072) 2025-03-31 21:07:16 +08:00
MyPrototypeWhat
2da8a73124 feat(MCP): add auto-install server configuration and migration for ve… (#4156)
* feat(MCP): add auto-install server configuration and migration for version 87

* update persistReducer version
2025-03-31 18:07:50 +08:00
fullex
5223a3c5a6 feat: minapp show&copy current REAL url and can open it 2025-03-31 18:01:10 +08:00
fullex
72c5de3b81 optimize: reduce animation gpu load of sidebar minapp 2025-03-31 17:47:41 +08:00
one
9f11e7c22b perf(Tabs): improve responsiveness when switching items rapidly 2025-03-31 09:33:17 +08:00
fullex
1ce86c11ca fix: zoomfactor should not change when resize (#4159)
* fix: zoomfactor should not change when resize

* add linux fallback support
2025-03-31 09:24:49 +08:00
suyao
57c1b59a51 fix(models): reorganize gemini websearch model lists 2025-03-30 23:52:58 +08:00
kangfenmao
a2f9067908 chore(version): 1.1.17 2025-03-30 14:39:43 +08:00
kangfenmao
2a4c512e49 refactor(BackupManager): switch to stream-based file writing for improved performance
* Updated BackupManager to use streams for writing data to temporary and backup files, enhancing efficiency and error handling.
* Replaced synchronous file writing with asynchronous stream operations to prevent blocking the event loop.
2025-03-30 14:37:20 +08:00
kangfenmao
94eb7f3a34 refactor(knowledge): enhance CustomCollapse component and improve UI consistency
* Updated CustomCollapse to accept React nodes for labels, allowing for more flexible content.
* Replaced static labels with CollapseLabel component to display item counts.
* Introduced EmptyView component for consistent empty state representation across collapsible sections.
* Removed unnecessary styles and improved button click handling to prevent event propagation.
2025-03-30 14:32:57 +08:00
kangfenmao
b363cb06a4 chore(store): update migration logic and increment version to 87
* Updated migration functions to include error handling for provider additions.
* Incremented the version number in the persisted reducer configuration.
2025-03-30 14:08:14 +08:00
Hao He
9e977f4b35 feat: Add keyboard navigation and selection highlighting for AddAssistantPopup (#4022)
* feat(AddAssistantPopup): 添加键盘导航和选中项高亮功能

* feat(AddAssistantPopup): 为所有项添加相同宽度的透明边框,避免布局跳动。
2025-03-30 13:58:52 +08:00
Teo
00de616958 refactor(files): Reconstruct file system UI (#4100)
* refactor(files): Reconstruct file system UI

* refactor(knowledge): replace Card components with CustomCollapse for better UI structure

* refactor(files): update folder icon from FolderOpenOutlined to FolderOpenFilled

* feat(components): add CustomCollapse component for enhanced collapsible UI

* refactor(files): implement virtual scrolling in FileList and KnowledgeContent components
2025-03-30 13:56:34 +08:00
Neal_Tan
1187a47698 Merge pull request #4129 from TeacherTan/main
feat(Assistant): Variables replace prompts
2025-03-30 02:15:18 +01:00
Neal_Tan
83d0eb07aa fix(i18n): update locales json file
关联提交 8f6bf113
2025-03-30 02:10:50 +01:00
Neal_Tan
8f6bf11320 feat(Assistant): 增加提示词变量输入
- 在编辑助手处添加了变量
- 保存智能体时可以保存变量
Fixed #4049
2025-03-30 00:51:48 +00:00
George·Dong
22b0bd54b4 refactor(settings): 重构小程序设置 (#4092) 2025-03-30 08:48:23 +08:00
kangfenmao
be39c5f40c feat(MCPService): enhance PATH management with platform-specific directories 2025-03-30 08:32:45 +08:00
kangfenmao
8b00ff4b93 fix(MCPSettings): ensure server name is set when missing and reorder radio options 2025-03-30 08:26:01 +08:00
Vaayne
f5b675b356 fix(MCPService): clear cache on server close and refactor tool fetching logic 2025-03-30 07:42:27 +08:00
LiuVaayne
de8dbb2646 fix(MCPService): prefix tool IDs with 'f' for consistency (#4121) 2025-03-30 00:26:47 +08:00
fullex
7e67005e70 fix(UI/markdown): markdown not recognized ** as emphasis marks in CJK (#4119) 2025-03-29 23:56:41 +08:00
yangtb2024
d6e66f3a4d feat(config): 增强模型支持 (#4085)
* feat(config): 添加对新模型的支持

- 新增 gemini-2.5 到 visionAllowedModels
- 新增 gpt-4.5 到 visionAllowedModels 和 FUNCTION_CALLING_MODELS
- 新增 o1 到 FUNCTION_CALLING_MODELS
- 从 visionExcludedModels 和 FUNCTION_CALLING_EXCLUDED_MODELS 中排除 o1-mini, o1-preview, AIDC-AI/Marco-o1

* feat(config): 添加对 deepseek-ai 函数调用的支持

- 新增 deepseek-ai 到 FUNCTION_CALLING_MODELS
2025-03-29 23:04:51 +08:00
one
e5aaec2129 fix: race condition in topic auto renaming 2025-03-29 22:58:38 +08:00
Herio
464634d051 feat(ApiCheckPopup): 使用Promise.all并行处理API验证请求并更新状态 2025-03-29 22:00:21 +08:00
fullex
3698238e9e fix: one-off minapp should not show minimize button 2025-03-29 21:59:03 +08:00
George·Dong
ae2a661201 fix(ApiService): context clear failed 2025-03-29 21:43:29 +08:00
Chen Tao
d6dbe357fb fix: add base url for gemini (#4109) 2025-03-29 21:35:04 +08:00
Catwine
e9dd795f9a docs(config): fix typo in electron-builder.yml 2025-03-29 21:08:55 +08:00
kangfenmao
03a18c1f3b fix(WebviewContainer): update webview partition to use a generic identifier 2025-03-29 19:00:08 +08:00
kangfenmao
e3ba44fc2c chore(version): 1.1.16 2025-03-29 15:29:28 +08:00
kangfenmao
9976ad9ed0 fix(migrate): add error handling to migration functions and ensure state integrity during updates 2025-03-29 15:28:57 +08:00
kangfenmao
3bb294e698 chore(version): 1.1.15 2025-03-29 15:00:02 +08:00
kangfenmao
990b1651a9 chore(version): 1.1.14 2025-03-29 08:05:56 +08:00
kangfenmao
11c070a1d7 feat(i18n): add delete server confirmation messages in multiple languages 2025-03-29 08:00:51 +08:00
fullex
57ba91072d feat: MinApp tabs on sidebar, we can keep MinApps alive and re-open it without loading again. 2025-03-29 07:29:45 +08:00
fullex
433d562599 refactor: MinAppType id required and only string 2025-03-29 07:29:45 +08:00
Chen Tao
194ba1baa0 feat: support gpt-4o image generation (#4054)
* feat: support gpt-4o image generation

* clean code
2025-03-29 07:18:42 +08:00
suyao
53ae427f2f feat(models): add support for new Gemini models 2025-03-29 07:17:40 +08:00
LiuVaayne
3f40cc28ac feat: mcp tools (#4069)
* feat(McpSettings): add MCP tools section and fetch tools on server activation

* refactor(McpService): improve client management and connection handling

* feat(McpService): add server management functions for restart and stop

* feat(McpTool): add tools section with input schema and availability messages

* feat(McpService): add unique IDs to tools and update function name mapping

* feat(McpService): implement caching for tool listings and enhance tool structure

* feat(McpToolsButton): streamline active server handling and update dropdown rendering

* fix(mcp-tools): update tool lookup to use unique IDs and add warning for missing tools
2025-03-29 07:16:59 +08:00
kangfenmao
d3584d2d39 chore(version): 1.1.13 2025-03-28 21:49:09 +08:00
kangfenmao
da0db73916 feat(Markdown): disallow iframe elements in Markdown rendering #4059 2025-03-28 21:46:37 +08:00
Teo
21f1b8b373 fix: fix fold selected (#4058)
* fix: 修复foldSelected问题

* refactor: 优化布局定位
2025-03-28 21:22:45 +08:00
fullex
f1a03916e7 remove unnecessary css 2025-03-28 18:11:21 +08:00
fullex
45f0bfa0f9 fix: code block selection abnormal
- the reason is using display: table/table-row, which makes the selection behavior become table style.
- use display: flex/block to solve this problem, meanwhile the line number css also modified to fit the adjust
2025-03-28 18:11:21 +08:00
kangfenmao
f2102daf00 feat(electron-builder): update release notes to include Nutstore login, SiYuan note export, and MCP improvements 2025-03-28 18:01:09 +08:00
kangfenmao
8f5c4483fc chore(version): 1.1.12 2025-03-28 15:17:33 +08:00
Chris Wan
43adac3f74 feat(MessagesService): two or more adjacent messages have the same role as user, then only the last one should be kept 2025-03-28 15:11:52 +08:00
ousugo
7b8c5f185c fix(TopicsTab): Topic prompts cannot be cleared 2025-03-28 15:09:01 +08:00
Teo
eeb537048b refactor(mcp settings): enhance NpxSearch component layout and styling (#4053)
* refactor: mcp setting ui refactor

* refactor(mcp settings): enhance NpxSearch component layout and styling
2025-03-28 15:08:24 +08:00
nutstore-dev
5712a58a5e fix(nutstore): fix the issue of not being able to customize the name of nutstore backup files. (#4050)
Co-authored-by: shlroland <shlroland1995@gmail.com>
2025-03-28 15:06:15 +08:00
kangfenmao
c4162bd9e3 feat(i18n): add "New Folder" button label to multiple locales 2025-03-28 13:42:14 +08:00
Teo
eddbae6f5e refactor: mcp setting ui refactor 2025-03-28 13:21:32 +08:00
kangfenmao
29f7da1a4c chore(version): 1.1.11 2025-03-28 11:30:20 +08:00
kangfenmao
403ed8cbf4 fix: mcp install ui 2025-03-28 11:15:49 +08:00
kangfenmao
7263a682b7 chore: remove useless code 2025-03-28 09:24:54 +08:00
kangfenmao
29b5ba787b refactor: mcp service 2025-03-28 04:24:10 +08:00
kangfenmao
bb6fdd2db7 Revert "feat: Use logo instead of avatar"
This reverts commit aee0f9ea3f.
2025-03-28 04:14:20 +08:00
Chen Tao
710171278a fix(Reranker): 修复rerank 400 and 完善错误信息 (#4013)
feat(Reranker): enhance error handling with detailed error messages and early return for empty results
2025-03-27 20:04:37 +08:00
MyPrototypeWhat
41191f6132 feat: mcp auto server (#3996)
* feat: add configuration file management to MCPService

- Introduced methods to ensure the existence of a configuration file, load configurations from it, and save server configurations.
- Updated the MCPService class to handle server configurations more effectively, improving initialization and error handling.
- Added dependency on chokidar for file system watching.

* feat: enhance MCPService configuration handling

- Improved configuration management by adding compatibility for both old and new server formats.
- Updated methods to ensure configuration file existence, load configurations, and save server data more effectively.
- Refined server initialization logic to handle updates and notifications to Redux more efficiently.
- Removed unnecessary waiting for server data from Redux during initialization.

* feat: enhance MCPService default configuration handling

- Added logic to create a default configuration if none exists, improving the initialization process.
- Implemented migration of server configurations from Redux to file, ensuring data consistency.
- Updated methods to handle nested server structures and improved error handling during server updates.

* refactor: clean up MCPService by removing redundant console logs and unused updateServerInRedux method

- Eliminated unnecessary console log statements to improve code readability.
- Removed the unused updateServerInRedux method, streamlining the MCPService class.
- Maintained existing functionality while enhancing code clarity.
2025-03-27 17:15:16 +08:00
kangfenmao
bbc7b20183 lint: fix code format 2025-03-27 15:15:15 +08:00
kangfenmao
8bb8081f31 feat(Messages): add foldSelected property to assistant messages for improved message handling 2025-03-27 15:15:01 +08:00
kangfenmao
7ddd2cb9d5 refactor(Messages): update message group styling and improve grouped message handling 2025-03-27 14:07:19 +08:00
kangfenmao
06ff44f97c refactor(Scrollbar, Tabs): simplify component structure and improve styling 2025-03-27 13:40:09 +08:00
kangfenmao
1a85b8bd5d feat(i18n): update assistant settings titles and add new translations for multiple languages 2025-03-27 13:19:04 +08:00
MyPrototypeWhat
fb9c23c500 Perf/optimize rendering (#3923)
* optimize useMessageOperations

* chore: update dependencies and refactor React imports

- Added @ant-design/v5-patch-for-react-19 and rc-virtual-list to package.json.
- Updated React and ReactDOM types to version 19 in package.json and yarn.lock.
- Refactored ReactDOM usage to createRoot in main.tsx for better compatibility with React 18+.
- Changed useContext to use in SyntaxHighlighterProvider and ThemeProvider components.
- Adjusted flex-direction in Messages components to column for improved layout.
- Removed unused state in CodeBlock component.

* refactor(Messages): enhance scrolling behavior and introduce scroll utilities

- Added createScrollHandler and scrollToBottom utilities for improved scroll management.
- Updated Messages component to utilize new scroll utilities for better user experience.
- Refactored scroll handling logic to ensure smooth scrolling when new messages are added.
- Changed containerRef type to HTMLElement for better type safety.

* refactor(Messages): streamline message handling and introduce useTopicMessages hook

- Removed direct message selection from useMessageOperations and created a new useTopicMessages hook for better separation of concerns.
- Updated Messages component to utilize the new useTopicMessages hook for fetching messages.
- Enhanced message display logic with computeDisplayMessages function for improved message rendering.
- Refactored scrolling behavior to maintain a smooth user experience during message updates.

* refactor(Message Operations): introduce useTopicLoading hook for improved loading state management

- Removed loading state from useMessageOperations and created a new useTopicLoading hook for better separation of concerns.
- Updated components to utilize the new useTopicLoading hook for fetching loading states related to topics.
- Enhanced code organization and readability by streamlining message operations and loading state handling.

* refactor(Messages): replace updateMessage with updateMessageThunk for improved async handling

- Updated useMessageOperations and MessageAnchorLine components to utilize updateMessageThunk for message updates.
- Enhanced error handling and database synchronization in the new thunk implementation.
- Streamlined message update logic to improve code clarity and maintainability.

* refactor(SyntaxHighlighterProvider, MessageTools, AddMcpServerPopup): update styles and improve type safety

- Changed import statements to use TypeScript's type imports for better clarity and type safety.
- Updated MessageTools and AddMcpServerPopup components to replace bodyStyle with styles prop for consistent styling approach.
- Enhanced overall code organization and maintainability by adhering to TypeScript best practices.

* refactor(Messages): update layout and remove unnecessary prop

- Removed the hasChildren prop from the Messages component for cleaner code.
- Adjusted flex-direction in the mini chat Messages component to column-reverse for improved layout consistency.

* refactor: enhance type safety and component return types

- Updated functional components to return React.ReactElement instead of JSX.Element for better type consistency.
- Changed import statements to use TypeScript's type imports for improved clarity.
- Initialized useRef hooks with null for better type safety in various components.
- Adjusted props types to use HTMLAttributes for more accurate type definitions.

* chore: update package dependencies

- Removed outdated dependencies: @agentic/exa, @agentic/searxng, @agentic/tavily, and rc-virtual-list.
- Added back @ant-design/v5-patch-for-react-19 and rc-virtual-list with specified versions for improved compatibility.

* fix(useMessageOperations): ensure message retrieval from store when updating content
2025-03-27 12:06:47 +08:00
ousugo
7fb85dc311 feat(message): calculate token usage when message content is updated 2025-03-27 08:32:46 +08:00
ousugo
2af15e4172 feat(models): add 'qwen2.5-omni' to allowed vision models 2025-03-27 08:31:58 +08:00
one
415f991143 fix: open 'data' page by default after routing to data settings 2025-03-26 22:15:27 +08:00
one
c162242433 fix(HealthCheck): exclude rerank models from being checked (#3969)
* fix(HealthCheck): exclude rerank models from being checked

* fix: info in en
2025-03-26 21:44:26 +08:00
OrzMiku
487d7a502e refactor(AgentCard): unify dropmenus 2025-03-26 21:17:24 +08:00
Sorades
d64d6969ae feat(message): 将fold display mode的状态持久化 2025-03-26 19:21:39 +08:00
kangfenmao
cc32c36222 build: replace @llm-tools/embedjs with @cherrystudio/embedjs 2025-03-26 18:14:04 +08:00
purefkh
0d320120a4 fix(UI): exclude rerank models from mention dropdown (#3958) 2025-03-26 16:17:07 +08:00
Yanhua Zheng
3cbe45fc8d docs(README): Add PaperMaterial Theme in README (#3955)
* Add PaperMaterial Theme

* Update README.md
2025-03-26 16:10:08 +08:00
africa1207
917943386e feat: 优化导出obsidian,自动选择库路径,不再需要手动配置 (#3854)
* feat: 优化导出obsidian,自动选择库路径,不再需要手动配置

* fix: eslint报错

* feat: 增加预设置默认仓库

* fix: 解决合并冲突
2025-03-25 21:31:22 +08:00
Teo
aee0f9ea3f feat: Use logo instead of avatar 2025-03-25 21:30:49 +08:00
kangfenmao
2055615aca feat: update model identifiers and names in configuration
- Changed model ID from 'mixtral-8x7b-32768' to 'mistral-saba-24b' and updated its name to 'Mistral Saba 24B'.
- Updated model ID from 'gemma-7b-it' to 'gemma-9b-it' and changed its name to 'Gemma 9B'.
- Enhanced clarity and consistency in model naming conventions.
2025-03-25 20:18:14 +08:00
kangfenmao
40cac47136 Revert "feat: support acrylic effect for Windows"
This reverts commit c8b2e8dd79.
2025-03-25 20:13:54 +08:00
kangfenmao
40d9629681 ci: fix eslint slow 2025-03-25 18:34:20 +08:00
kangfenmao
8acefaa907 feat: update migration for settings auto-check update
Incremented version to 85 and updated migration logic to transition from manual to automatic update checks in settings, enhancing user experience.
2025-03-25 13:16:18 +08:00
wangxiaolong
e2d8b89ffd feat: 更新自动检查更新功能易读性
将手动检查更新的设置更改为自动检查更新,更新相关的状态管理和界面文本,以提升用户体验。
2025-03-25 13:06:25 +08:00
d5v
8d48824981 feat: add Siyuan Note export functionality and configuration (#3845)
* feat(i18n): add Siyuan Note export functionality and configuration

- 增加导出到思源笔记。

* feat/Add document address

---------

Co-authored-by: 亢奋猫 <kangfenmao@qq.com>
2025-03-25 13:05:21 +08:00
nutstore-dev
fd66881022 feat: nutstore integration (#3461)
* feat(protocol): add custom protocol

* feat(webdav): add handler for checking webdav connection

* feat(webdav): abstract WebDAV modal components

* feat(nutstore): add nutstore sso

---------

Co-authored-by: shlroland <shlroland1995@gmail.com>
2025-03-25 11:40:11 +08:00
z-zeechung
b321169ca2 feat: Katex and MathJax blocks horizontally auto-scroll (#3806) 2025-03-25 09:44:35 +08:00
ousugo
123362b493 fix(thinking): Claude think label recognition error problem 2025-03-25 08:57:49 +08:00
Chen Tao
a1568808d4 feat(genmini): enhance (#3849) 2025-03-25 08:52:43 +08:00
RarityBrown
6dff8b2725 feat: Update text-based file extensions for EDAs
远期可以进一步考虑直接自动使用 https://github.com/github-linguist/linguist/blob/main/lib/linguist/languages.yml 加上自定义扩展集的方式,减轻维护负担

Should consider directly and automatically using https://github.com/github-linguist/linguist/blob/main/lib/linguist/languages.yml along with a custom extension set to reduce the maintenance burden.
2025-03-25 08:51:30 +08:00
Hakadao
c8b2e8dd79 feat: support acrylic effect for Windows 2025-03-25 08:50:24 +08:00
hobee
8ac18934e9 feat(主题): 添加settingTheme字段以增强主题切换功能
在ThemeProvider中添加settingTheme字段,用于在Sidebar组件中显示当前主题状态。这样用户可以更直观地了解当前主题设置
2025-03-25 08:50:03 +08:00
Teo
6699b0902f feat(pending-animation): 当消息处于后台pending时,助手头像跟话题显示脉冲动画效果 (#3867) 2025-03-25 08:48:26 +08:00
fullex
9b98312775 fix: some shortcuts not enabled 2025-03-25 00:48:35 +08:00
hobee
1e14dd6ea2 feat: 添加隐藏小程序功能
可以直接在小程序界面隐藏小程序
2025-03-25 00:32:39 +08:00
hobee
0d612cb827 feat(i18n): 在侧边栏添加隐藏小工具的翻译
为多种语言添加了“隐藏小工具”的翻译,以支持新的功能需求。
2025-03-25 00:32:39 +08:00
kangfenmao
ccfac25a04 feat(docs): add theme section to README files in multiple languages 2025-03-24 09:53:59 +08:00
kangfenmao
7447dfe771 feat(docs): add guide section and update contact email in README files 2025-03-24 09:41:47 +08:00
kangfenmao
0fe45a203c feat(docs): add Product Hunt badge to README files in multiple languages 2025-03-24 09:34:21 +08:00
kangfenmao
94942141b9 feat(docs): add contact information to README files in multiple languages 2025-03-24 09:31:41 +08:00
Asurada
f08856ae42 feat(SettingTab): add support for reasoning effort model check (#3842) 2025-03-24 09:23:32 +08:00
Pleasurecruise
a606f4b6c5 fix: apikey input flickering 2025-03-23 22:45:33 +08:00
210 changed files with 10444 additions and 4334 deletions

View File

@@ -6,3 +6,4 @@ tsconfig.json
tsconfig.*.json
CHANGELOG*.md
agents.json
src/renderer/src/integration/nutstore/sso/lib

View File

@@ -1,13 +0,0 @@
diff --git a/src/markdown-loader.js b/src/markdown-loader.js
index eaf30b114a273e68abbb92c8b07018495e63f4cb..4b06519bdb51845e4693fe877da9de01c7a81039 100644
--- a/src/markdown-loader.js
+++ b/src/markdown-loader.js
@@ -21,7 +21,7 @@ export class MarkdownLoader extends BaseLoader {
? (await getSafe(this.filePathOrUrl, { format: 'buffer' })).body
: await streamToBuffer(fs.createReadStream(this.filePathOrUrl));
this.debug('MarkdownLoader stream created');
- const result = micromark(buffer, { extensions: [gfm(), mdxJsx()], htmlExtensions: [gfmHtml()] });
+ const result = micromark(buffer, { extensions: [gfm()], htmlExtensions: [gfmHtml()] });
this.debug('Markdown parsed...');
const webLoader = new WebLoader({
urlOrContent: result,

View File

@@ -1,158 +0,0 @@
diff --git a/src/loaders/local-path-loader.d.ts b/src/loaders/local-path-loader.d.ts
index 48c20e68c469cd309be2dc8f28e44c1bd04a26e9..1c16d83bcbf9b7140292793d6cbb8c04281949d9 100644
--- a/src/loaders/local-path-loader.d.ts
+++ b/src/loaders/local-path-loader.d.ts
@@ -4,8 +4,10 @@ export declare class LocalPathLoader extends BaseLoader<{
}> {
private readonly debug;
private readonly path;
- constructor({ path }: {
+ constructor({ path, chunkSize, chunkOverlap }: {
path: string;
+ chunkSize?: number;
+ chunkOverlap?: number;
});
getUnfilteredChunks(): AsyncGenerator<{
metadata: {
diff --git a/src/loaders/local-path-loader.js b/src/loaders/local-path-loader.js
index 4cf8a6bd1d890244c8ec49d4a05ee3bd58861c79..ec8215b01195a21ef20f3c5d56ecc99f186bb596 100644
--- a/src/loaders/local-path-loader.js
+++ b/src/loaders/local-path-loader.js
@@ -8,8 +8,8 @@ import { BaseLoader } from '@llm-tools/embedjs-interfaces';
export class LocalPathLoader extends BaseLoader {
debug = createDebugMessages('embedjs:loader:LocalPathLoader');
path;
- constructor({ path }) {
- super(`LocalPathLoader_${md5(path)}`, { path });
+ constructor({ path, chunkSize, chunkOverlap }) {
+ super(`LocalPathLoader_${md5(path)}`, { path }, chunkSize ?? 1000, chunkOverlap ?? 0);
this.path = path;
}
async *getUnfilteredChunks() {
@@ -36,10 +36,12 @@ export class LocalPathLoader extends BaseLoader {
const extension = currentPath.split('.').pop().toLowerCase();
if (extension === 'md' || extension === 'mdx')
mime = 'text/markdown';
+ if (extension === 'txt')
+ mime = 'text/plain';
this.debug(`File '${this.path}' mime type updated to 'text/markdown'`);
}
try {
- const loader = await createLoaderFromMimeType(currentPath, mime);
+ const loader = await createLoaderFromMimeType(currentPath, mime, this.chunkSize, this.chunkOverlap);
for await (const result of await loader.getUnfilteredChunks()) {
yield {
pageContent: result.pageContent,
diff --git a/src/util/mime.d.ts b/src/util/mime.d.ts
index 57f56a1b8edc98366af9f84d671676c41c2f01ca..14be3b5727cff6eb1978838045e9a788f8f53bfb 100644
--- a/src/util/mime.d.ts
+++ b/src/util/mime.d.ts
@@ -1,2 +1,2 @@
import { BaseLoader } from '@llm-tools/embedjs-interfaces';
-export declare function createLoaderFromMimeType(loaderData: string, mimeType: string): Promise<BaseLoader>;
+export declare function createLoaderFromMimeType(loaderData: string, mimeType: string, chunkSize?: number, chunkOverlap?: number): Promise<BaseLoader>;
diff --git a/src/util/mime.js b/src/util/mime.js
index b6426a859968e2bf6206795f70333e90ae27aeb7..16ae2adb863f8d7abfa757f1c5cc39f6bb1c44fa 100644
--- a/src/util/mime.js
+++ b/src/util/mime.js
@@ -1,7 +1,9 @@
import mime from 'mime';
import createDebugMessages from 'debug';
import { TextLoader } from '../loaders/text-loader.js';
-export async function createLoaderFromMimeType(loaderData, mimeType) {
+import fs from 'node:fs'
+
+export async function createLoaderFromMimeType(loaderData, mimeType, chunkSize, chunkOverlap) {
createDebugMessages('embedjs:util:createLoaderFromMimeType')(`Incoming mime type '${mimeType}'`);
switch (mimeType) {
case 'application/msword':
@@ -10,7 +12,7 @@ export async function createLoaderFromMimeType(loaderData, mimeType) {
throw new Error('Package `@llm-tools/embedjs-loader-msoffice` needs to be installed to load docx files');
});
createDebugMessages('embedjs:util:createLoaderFromMimeType')('Dynamically imported DocxLoader');
- return new DocxLoader({ filePathOrUrl: loaderData });
+ return new DocxLoader({ filePathOrUrl: loaderData, chunkSize, chunkOverlap });
}
case 'application/vnd.ms-excel':
case 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': {
@@ -18,21 +20,21 @@ export async function createLoaderFromMimeType(loaderData, mimeType) {
throw new Error('Package `@llm-tools/embedjs-loader-msoffice` needs to be installed to load excel files');
});
createDebugMessages('embedjs:util:createLoaderFromMimeType')('Dynamically imported ExcelLoader');
- return new ExcelLoader({ filePathOrUrl: loaderData });
+ return new ExcelLoader({ filePathOrUrl: loaderData, chunkSize, chunkOverlap });
}
case 'application/pdf': {
const { PdfLoader } = await import('@llm-tools/embedjs-loader-pdf').catch(() => {
throw new Error('Package `@llm-tools/embedjs-loader-pdf` needs to be installed to load PDF files');
});
createDebugMessages('embedjs:util:createLoaderFromMimeType')('Dynamically imported PdfLoader');
- return new PdfLoader({ filePathOrUrl: loaderData });
+ return new PdfLoader({ filePathOrUrl: loaderData, chunkSize, chunkOverlap });
}
case 'application/vnd.openxmlformats-officedocument.presentationml.presentation': {
const { PptLoader } = await import('@llm-tools/embedjs-loader-msoffice').catch(() => {
throw new Error('Package `@llm-tools/embedjs-loader-msoffice` needs to be installed to load pptx files');
});
createDebugMessages('embedjs:util:createLoaderFromMimeType')('Dynamically imported PptLoader');
- return new PptLoader({ filePathOrUrl: loaderData });
+ return new PptLoader({ filePathOrUrl: loaderData, chunkSize, chunkOverlap });
}
case 'text/plain': {
const fineType = mime.getType(loaderData);
@@ -42,24 +44,24 @@ export async function createLoaderFromMimeType(loaderData, mimeType) {
throw new Error('Package `@llm-tools/embedjs-loader-csv` needs to be installed to load CSV files');
});
createDebugMessages('embedjs:util:createLoaderFromMimeType')('Dynamically imported CsvLoader');
- return new CsvLoader({ filePathOrUrl: loaderData });
+ return new CsvLoader({ filePathOrUrl: loaderData, chunkSize, chunkOverlap });
}
- else
- return new TextLoader({ text: loaderData });
+ const content = fs.readFileSync(loaderData, 'utf-8');
+ return new TextLoader({ text: content, chunkSize, chunkOverlap });
}
case 'application/csv': {
const { CsvLoader } = await import('@llm-tools/embedjs-loader-csv').catch(() => {
throw new Error('Package `@llm-tools/embedjs-loader-csv` needs to be installed to load CSV files');
});
createDebugMessages('embedjs:util:createLoaderFromMimeType')('Dynamically imported CsvLoader');
- return new CsvLoader({ filePathOrUrl: loaderData });
+ return new CsvLoader({ filePathOrUrl: loaderData, chunkSize, chunkOverlap });
}
case 'text/html': {
const { WebLoader } = await import('@llm-tools/embedjs-loader-web').catch(() => {
throw new Error('Package `@llm-tools/embedjs-loader-web` needs to be installed to load web documents');
});
createDebugMessages('embedjs:util:createLoaderFromMimeType')('Dynamically imported WebLoader');
- return new WebLoader({ urlOrContent: loaderData });
+ return new WebLoader({ urlOrContent: loaderData, chunkSize, chunkOverlap });
}
case 'text/xml': {
const { SitemapLoader } = await import('@llm-tools/embedjs-loader-sitemap').catch(() => {
@@ -67,14 +69,14 @@ export async function createLoaderFromMimeType(loaderData, mimeType) {
});
createDebugMessages('embedjs:util:createLoaderFromMimeType')('Dynamically imported SitemapLoader');
if (await SitemapLoader.test(loaderData)) {
- return new SitemapLoader({ url: loaderData });
+ return new SitemapLoader({ url: loaderData, chunkSize, chunkOverlap });
}
//This is not a Sitemap but is still XML
const { XmlLoader } = await import('@llm-tools/embedjs-loader-xml').catch(() => {
throw new Error('Package `@llm-tools/embedjs-loader-xml` needs to be installed to load XML documents');
});
createDebugMessages('embedjs:util:createLoaderFromMimeType')('Dynamically imported XmlLoader');
- return new XmlLoader({ filePathOrUrl: loaderData });
+ return new XmlLoader({ filePathOrUrl: loaderData, chunkSize, chunkOverlap });
}
case 'text/x-markdown':
case 'text/markdown': {
@@ -82,7 +84,7 @@ export async function createLoaderFromMimeType(loaderData, mimeType) {
throw new Error('Package `@llm-tools/embedjs-loader-markdown` needs to be installed to load markdown files');
});
createDebugMessages('embedjs:util:createLoaderFromMimeType')('Dynamically imported MarkdownLoader');
- return new MarkdownLoader({ filePathOrUrl: loaderData });
+ return new MarkdownLoader({ filePathOrUrl: loaderData, chunkSize, chunkOverlap });
}
case 'image/png':
case 'image/jpeg': {

View File

@@ -1,26 +0,0 @@
diff --git a/dist/cjs/client/stdio.js b/dist/cjs/client/stdio.js
index 2ada8771c5f76673b5021d6453c6bdd7e0b88013..89f6ea9ca7de86294d3d966f3454b98e19bfe534 100644
--- a/dist/cjs/client/stdio.js
+++ b/dist/cjs/client/stdio.js
@@ -68,7 +68,7 @@ class StdioClientTransport {
this._process = (0, node_child_process_1.spawn)(this._serverParams.command, (_a = this._serverParams.args) !== null && _a !== void 0 ? _a : [], {
env: (_b = this._serverParams.env) !== null && _b !== void 0 ? _b : getDefaultEnvironment(),
stdio: ["pipe", "pipe", (_c = this._serverParams.stderr) !== null && _c !== void 0 ? _c : "inherit"],
- shell: false,
+ shell: process.platform === 'win32' ? true : false,
signal: this._abortController.signal,
windowsHide: node_process_1.default.platform === "win32" && isElectron(),
cwd: this._serverParams.cwd,
diff --git a/dist/esm/client/stdio.js b/dist/esm/client/stdio.js
index 387c982fd40fd8db9790a78e1a05c9ecb81501c0..7b7e60a306bca73149609015a27e904a0a68ca02 100644
--- a/dist/esm/client/stdio.js
+++ b/dist/esm/client/stdio.js
@@ -61,7 +61,7 @@ export class StdioClientTransport {
this._process = spawn(this._serverParams.command, (_a = this._serverParams.args) !== null && _a !== void 0 ? _a : [], {
env: (_b = this._serverParams.env) !== null && _b !== void 0 ? _b : getDefaultEnvironment(),
stdio: ["pipe", "pipe", (_c = this._serverParams.stderr) !== null && _c !== void 0 ? _c : "inherit"],
- shell: false,
+ shell: process.platform === 'win32' ? true : false,
signal: this._abortController.signal,
windowsHide: process.platform === "win32" && isElectron(),
cwd: this._serverParams.cwd,

View File

@@ -0,0 +1,18 @@
diff --git a/dist/index.node.js b/dist/index.node.js
index bb108cbc210af5b99e864fd1dd8c555e948ecf7a..8ef8c1aab59215c21d161c0e52125724528ecab8 100644
--- a/dist/index.node.js
+++ b/dist/index.node.js
@@ -1,8 +1,11 @@
let crypto;
crypto =
globalThis.crypto?.webcrypto ?? // Node.js 16 REPL has globalThis.crypto as node:crypto
- globalThis.crypto ?? // Node.js 18+
- (await import("node:crypto")).webcrypto; // Node.js 16 non-REPL
+ globalThis.crypto ?? // Node.js 18+
+ (async() => {
+ const crypto = await import("node:crypto");
+ return crypto.webcrypto;
+ })();
/**
* Creates an array of length `size` of random bytes
* @param size

View File

@@ -6,6 +6,7 @@
<p align="center">English | <a href="./docs/README.zh.md">中文</a> | <a href="./docs/README.ja.md">日本語</a><br></p>
<div align="center">
<a href="https://trendshift.io/repositories/11772" target="_blank"><img src="https://trendshift.io/api/badge/repositories/11772" alt="kangfenmao%2Fcherry-studio | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
<a href="https://www.producthunt.com/posts/cherry-studio?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cherry&#0045;studio" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=496640&theme=light" alt="Cherry&#0032;Studio - AI&#0032;Chatbots&#0044;&#0032;AI&#0032;Desktop&#0032;Client | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
</div>
# 🍒 Cherry Studio
@@ -16,6 +17,10 @@ Cherry Studio is a desktop client that supports for multiple LLM providers, avai
❤️ Like Cherry Studio? Give it a star 🌟 or [Sponsor](docs/sponsor.md) to support the development!
# 📖 Guide
https://docs.cherry-ai.com
# 🌠 Screenshot
![](https://github.com/user-attachments/assets/28585d83-4bf0-4714-b561-8c7bf57cc600)
@@ -77,6 +82,14 @@ Cherry Studio is a desktop client that supports for multiple LLM providers, avai
- [ ] Voice input and output (AI call)
- [ ] Data backup supports custom backup content
# 🌈 Theme
- Theme Gallery: https://cherrycss.com
- Aero Theme: https://github.com/hakadao/CherryStudio-Aero
- PaperMaterial Theme: https://github.com/rainoffallingstar/CherryStudio-PaperMaterial
Welcome PR for more themes
# 🖥️ Develop
Refer to the [development documentation](docs/dev.md)
@@ -119,11 +132,7 @@ Thank you for your support and contributions!
# 🌐 Community
[Telegram](https://t.me/CherryStudioAI) | [Email](mailto:kangfenmao@gmail.com) | [Twitter](https://x.com/kangfenmao)
# 📣 Product Hunt
<a href="https://www.producthunt.com/posts/cherry-studio?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cherry&#0045;studio" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=496640&theme=light" alt="Cherry&#0032;Studio - AI&#0032;Chatbots&#0044;&#0032;AI&#0032;Desktop&#0032;Client | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
[Telegram](https://t.me/CherryStudioAI) | [Email](mailto:support@cherry-ai.com) | [Twitter](https://x.com/kangfenmao)
# ☕ Sponsor
@@ -133,6 +142,10 @@ Thank you for your support and contributions!
[LICENSE](./LICENSE)
# ✉️ Contact
yinsenho@cherry-ai.com
# ⭐️ Star History
[![Star History Chart](https://api.star-history.com/svg?repos=kangfenmao/cherry-studio&type=Timeline)](https://star-history.com/#kangfenmao/cherry-studio&Timeline)

View File

@@ -8,6 +8,7 @@
</p>
<div align="center">
<a href="https://trendshift.io/repositories/11772" target="_blank"><img src="https://trendshift.io/api/badge/repositories/11772" alt="kangfenmao%2Fcherry-studio | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
<a href="https://www.producthunt.com/posts/cherry-studio?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cherry&#0045;studio" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=496640&theme=light" alt="Cherry&#0032;Studio - AI&#0032;Chatbots&#0044;&#0032;AI&#0032;Desktop&#0032;Client | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
</div>
# 🍒 Cherry Studio
@@ -17,6 +18,10 @@ Cherry Studio は、複数の LLM プロバイダーをサポートするデス
❤️ Cherry Studio をお気に入りにしましたか?小さな星をつけてください 🌟 または [スポンサー](sponsor.md) をして開発をサポートしてください!❤️
# 📖 ガイド
https://docs.cherry-ai.com
# 🌠 スクリーンショット
![](https://github.com/user-attachments/assets/28585d83-4bf0-4714-b561-8c7bf57cc600)
@@ -78,6 +83,13 @@ Cherry Studio は、複数の LLM プロバイダーをサポートするデス
- [ ] 音声入出力AI コール)
- [ ] データバックアップはカスタムバックアップコンテンツをサポート
# 🌈 テーマ
テーマギャラリー: https://cherrycss.com
Aero テーマ: https://github.com/hakadao/CherryStudio-Aero
より多くのテーマのPRを歓迎します
# 🖥️ 開発
参考[開発ドキュメント](dev.md)
@@ -117,11 +129,7 @@ Cherry Studio への貢献を歓迎します!以下の方法で貢献できま
# コミュニティ
[Telegram](https://t.me/CherryStudioAI) | [Email](mailto:kangfenmao@gmail.com) | [Twitter](https://x.com/kangfenmao)
# 📣 プロダクトハント
<a href="https://www.producthunt.com/posts/cherry-studio?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cherry&#0045;studio" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=496640&theme=light" alt="Cherry&#0032;Studio - AI&#0032;Chatbots&#0044;&#0032;AI&#0032;Desktop&#0032;Client | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
[Telegram](https://t.me/CherryStudioAI) | [Email](mailto:support@cherry-ai.com) | [Twitter](https://x.com/kangfenmao)
# スポンサー
@@ -131,6 +139,10 @@ Cherry Studio への貢献を歓迎します!以下の方法で貢献できま
[LICENSE](../LICENSE)
# ✉️ お問い合わせ
yinsenho@cherry-ai.com
# ⭐️ スター履歴
[![Star History Chart](https://api.star-history.com/svg?repos=kangfenmao/cherry-studio&type=Timeline)](https://star-history.com/#kangfenmao/cherry-studio&Timeline)

View File

@@ -7,7 +7,9 @@
<a href="https://github.com/CherryHQ/cherry-studio">English</a> | 中文 | <a href="./README.ja.md">日本語</a><br></p>
<div align="center">
<a href="https://trendshift.io/repositories/11772" target="_blank"><img src="https://trendshift.io/api/badge/repositories/11772" alt="kangfenmao%2Fcherry-studio | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
<a href="https://www.producthunt.com/posts/cherry-studio?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cherry&#0045;studio" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=496640&theme=light" alt="Cherry&#0032;Studio - AI&#0032;Chatbots&#0044;&#0032;AI&#0032;Desktop&#0032;Client | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
</div>
# 🍒 Cherry Studio
Cherry Studio 是一款支持多个大语言模型LLM服务商的桌面客户端兼容 Windows、Mac 和 Linux 系统。
@@ -16,6 +18,10 @@ Cherry Studio 是一款支持多个大语言模型LLM服务商的桌面客
❤️ 喜欢 Cherry Studio? 点亮小星星 🌟 或 [赞助开发者](sponsor.md)! ❤️
# 📖 使用教程
https://docs.cherry-ai.com
# 🌠 界面
![](https://github.com/user-attachments/assets/28585d83-4bf0-4714-b561-8c7bf57cc600)
@@ -77,6 +83,13 @@ Cherry Studio 是一款支持多个大语言模型LLM服务商的桌面客
- [ ] 语音输入输出AI 通话)
- [ ] 数据备份支持自定义备份内容
# 🌈 主题
主题库https://cherrycss.com
Aero 主题https://github.com/hakadao/CherryStudio-Aero
欢迎 PR 更多主题
# 🖥️ 开发
参考[开发文档](dev.md)
@@ -117,11 +130,7 @@ Cherry Studio 是一款支持多个大语言模型LLM服务商的桌面客
# 🌐 社区
[Telegram](https://t.me/CherryStudioAI) | [Email](mailto:kangfenmao@gmail.com) | [Twitter](https://x.com/kangfenmao)
# 📣 产品猎人
<a href="https://www.producthunt.com/posts/cherry-studio?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cherry&#0045;studio" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=496640&theme=light" alt="Cherry&#0032;Studio - AI&#0032;Chatbots&#0044;&#0032;AI&#0032;Desktop&#0032;Client | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
[Telegram](https://t.me/CherryStudioAI) | [Email](mailto:support@cherry-ai.com) | [Twitter](https://x.com/kangfenmao)
# ☕ 赞助
@@ -131,6 +140,10 @@ Cherry Studio 是一款支持多个大语言模型LLM服务商的桌面客
[LICENSE](../LICENSE)
# ✉️ 联系我们
yinsenho@cherry-ai.com
# ⭐️ Star 记录
[![Star History Chart](https://api.star-history.com/svg?repos=kangfenmao/cherry-studio&type=Timeline)](https://star-history.com/#kangfenmao/cherry-studio&Timeline)

View File

@@ -83,8 +83,7 @@ afterPack: scripts/after-pack.js
afterSign: scripts/notarize.js
releaseInfo:
releaseNotes: |
知识库设置增加重排模型,提升知识库的准确性
自定义服务商增加兼容模式
增加 Github Copilot 服务商
PlantUML 预览支持放大和缩小
联网模式支持增强模式
小程序支持多开
支持 GPT-4o 图像生成
修复 MCP 服务器无法使用问题
修复升级导致旧版本数据丢失问题

View File

@@ -12,16 +12,16 @@ export default defineConfig({
plugins: [
externalizeDepsPlugin({
exclude: [
'@llm-tools/embedjs',
'@llm-tools/embedjs-openai',
'@llm-tools/embedjs-loader-web',
'@llm-tools/embedjs-loader-markdown',
'@llm-tools/embedjs-loader-msoffice',
'@llm-tools/embedjs-loader-xml',
'@llm-tools/embedjs-loader-pdf',
'@llm-tools/embedjs-loader-sitemap',
'@llm-tools/embedjs-libsql',
'@llm-tools/embedjs-loader-image',
'@cherrystudio/embedjs',
'@cherrystudio/embedjs-openai',
'@cherrystudio/embedjs-loader-web',
'@cherrystudio/embedjs-loader-markdown',
'@cherrystudio/embedjs-loader-msoffice',
'@cherrystudio/embedjs-loader-xml',
'@cherrystudio/embedjs-loader-pdf',
'@cherrystudio/embedjs-loader-sitemap',
'@cherrystudio/embedjs-libsql',
'@cherrystudio/embedjs-loader-image',
'p-queue',
'webdav'
]

View File

@@ -53,6 +53,16 @@ export default defineConfig([
}
],
{
ignores: ['node_modules/**', 'dist/**', 'out/**', 'local/**', '.gitignore', 'scripts/cloudflare-worker.js']
ignores: [
'node_modules/**',
'build/**',
'dist/**',
'out/**',
'local/**',
'.yarn/**',
'.gitignore',
'scripts/cloudflare-worker.js',
'src/main/integration/nutstore/sso/lib/**'
]
}
])

View File

@@ -1,6 +1,6 @@
{
"name": "CherryStudio",
"version": "1.1.10",
"version": "1.1.17",
"private": true,
"description": "A powerful AI assistant for producer.",
"main": "./out/main/index.js",
@@ -50,21 +50,21 @@
"prepare": "husky"
},
"dependencies": {
"@cherrystudio/embedjs": "^0.1.28",
"@cherrystudio/embedjs-libsql": "^0.1.28",
"@cherrystudio/embedjs-loader-csv": "^0.1.28",
"@cherrystudio/embedjs-loader-image": "^0.1.28",
"@cherrystudio/embedjs-loader-markdown": "^0.1.28",
"@cherrystudio/embedjs-loader-msoffice": "^0.1.28",
"@cherrystudio/embedjs-loader-pdf": "^0.1.28",
"@cherrystudio/embedjs-loader-sitemap": "^0.1.28",
"@cherrystudio/embedjs-loader-web": "^0.1.28",
"@cherrystudio/embedjs-loader-xml": "^0.1.28",
"@cherrystudio/embedjs-openai": "^0.1.28",
"@electron-toolkit/utils": "^3.0.0",
"@electron/notarize": "^2.5.0",
"@google/generative-ai": "^0.21.0",
"@google/generative-ai": "^0.24.0",
"@langchain/community": "^0.3.36",
"@llm-tools/embedjs": "patch:@llm-tools/embedjs@npm%3A0.1.28#~/.yarn/patches/@llm-tools-embedjs-npm-0.1.28-8e4393fa2d.patch",
"@llm-tools/embedjs-libsql": "^0.1.28",
"@llm-tools/embedjs-loader-csv": "^0.1.28",
"@llm-tools/embedjs-loader-markdown": "patch:@llm-tools/embedjs-loader-markdown@npm%3A0.1.28#~/.yarn/patches/@llm-tools-embedjs-loader-markdown-npm-0.1.28-81647ffac6.patch",
"@llm-tools/embedjs-loader-msoffice": "^0.1.28",
"@llm-tools/embedjs-loader-pdf": "^0.1.28",
"@llm-tools/embedjs-loader-sitemap": "^0.1.28",
"@llm-tools/embedjs-loader-web": "^0.1.28",
"@llm-tools/embedjs-loader-xml": "^0.1.28",
"@llm-tools/embedjs-openai": "^0.1.28",
"@modelcontextprotocol/sdk": "patch:@modelcontextprotocol/sdk@npm%3A1.6.1#~/.yarn/patches/@modelcontextprotocol-sdk-npm-1.6.1-b46313efe7.patch",
"@notionhq/client": "^2.2.15",
"@tryfabric/martian": "^1.2.4",
"@types/react-infinite-scroll-component": "^5.0.0",
@@ -76,6 +76,7 @@
"electron-updater": "^6.3.9",
"electron-window-state": "^5.0.3",
"epub": "patch:epub@npm%3A1.3.0#~/.yarn/patches/epub-npm-1.3.0-8325494ffe.patch",
"fast-xml-parser": "^5.0.9",
"fetch-socks": "^1.3.2",
"fs-extra": "^11.2.0",
"markdown-it": "^14.1.0",
@@ -90,6 +91,7 @@
"@agentic/exa": "^7.3.3",
"@agentic/searxng": "^7.3.3",
"@agentic/tavily": "^7.3.3",
"@ant-design/v5-patch-for-react-19": "^1.0.3",
"@anthropic-ai/sdk": "^0.38.0",
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
"@electron-toolkit/eslint-config-ts": "^3.0.0",
@@ -101,7 +103,7 @@
"@google/genai": "^0.4.0",
"@hello-pangea/dnd": "^16.6.0",
"@kangfenmao/keyv-storage": "^0.1.0",
"@llm-tools/embedjs-loader-image": "^0.1.28",
"@modelcontextprotocol/sdk": "^1.8.0",
"@notionhq/client": "^2.2.15",
"@reduxjs/toolkit": "^2.2.5",
"@tavily/core": "patch:@tavily/core@npm%3A0.3.1#~/.yarn/patches/@tavily-core-npm-0.3.1-fe69bf2bea.patch",
@@ -113,8 +115,8 @@
"@types/md5": "^2.3.5",
"@types/node": "^18.19.9",
"@types/pako": "^1.0.2",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"@types/react": "^19.0.12",
"@types/react-dom": "^19.0.4",
"@types/react-infinite-scroll-component": "^5.0.0",
"@types/tinycolor2": "^1",
"@vitejs/plugin-react": "^4.2.1",
@@ -148,8 +150,9 @@
"openai": "patch:openai@npm%3A4.77.3#~/.yarn/patches/openai-npm-4.77.3-59c6d42e7a.patch",
"p-queue": "^8.1.0",
"prettier": "^3.5.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"rc-virtual-list": "^3.18.5",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hotkeys-hook": "^4.6.1",
"react-i18next": "^14.1.2",
"react-infinite-scroll-component": "^6.1.0",
@@ -163,6 +166,7 @@
"rehype-katex": "^7.0.1",
"rehype-mathjax": "^7.0.0",
"rehype-raw": "^7.0.0",
"remark-cjk-friendly": "^1.1.0",
"remark-gfm": "^4.0.0",
"remark-math": "^6.0.0",
"rollup-plugin-visualizer": "^5.12.0",
@@ -176,15 +180,12 @@
"uuid": "^10.0.0",
"vite": "^5.0.12"
},
"peerDependencies": {
"react": "^17.0.0 || ^18.0.0",
"react-dom": "^17.0.0 || ^18.0.0"
},
"resolutions": {
"pdf-parse@npm:1.1.1": "patch:pdf-parse@npm%3A1.1.1#~/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch",
"@langchain/openai@npm:^0.3.16": "patch:@langchain/openai@npm%3A0.3.16#~/.yarn/patches/@langchain-openai-npm-0.3.16-e525b59526.patch",
"@langchain/openai@npm:>=0.1.0 <0.4.0": "patch:@langchain/openai@npm%3A0.3.16#~/.yarn/patches/@langchain-openai-npm-0.3.16-e525b59526.patch",
"openai@npm:^4.77.0": "patch:openai@npm%3A4.77.3#~/.yarn/patches/openai-npm-4.77.3-59c6d42e7a.patch"
"openai@npm:^4.77.0": "patch:openai@npm%3A4.77.3#~/.yarn/patches/openai-npm-4.77.3-59c6d42e7a.patch",
"pkce-challenge@npm:^4.1.0": "patch:pkce-challenge@npm%3A4.1.0#~/.yarn/patches/pkce-challenge-npm-4.1.0-fbc51695a3.patch"
},
"packageManager": "yarn@4.6.0",
"lint-staged": {

View File

@@ -88,7 +88,7 @@ export const textExts = [
'.ctp', // CakePHP 视图文件
'.cfm', // ColdFusion 标记语言文件
'.cfc', // ColdFusion 组件文件
'.m', // Objective-C 源文件
'.m', // Objective-C 或 MATLAB 源文件
'.mm', // Objective-C++ 源文件
'.gradle', // Gradle 构建文件
'.groovy', // Gradle 构建文件
@@ -106,7 +106,32 @@ export const textExts = [
'.ixx', // C++20 模块实现文件
'.f90', // Fortran 90 源文件
'.f', // Fortran 固定格式源代码文件
'.f03' // Fortran 2003+ 源代码文件
'.f03', // Fortran 2003+ 源代码文件
'.ahk', // AutoHotKey 语言文件
'.tcl', // Tcl 脚本
'.do', // Questa 或 Modelsim Tcl 脚本
'.v', // Verilog 源文件
'.sv', // SystemVerilog 源文件
'.svh', // SystemVerilog 头文件
'.vhd', // VHDL 源文件
'.vhdl', // VHDL 源文件
'.lef', // Library Exchange Format
'.def', // Design Exchange Format
'.edif', // Electronic Design Interchange Format
'.sdf', // Standard Delay Format
'.sdc', // Synopsys Design Constraints
'.xdc', // Xilinx Design Constraints
'.rpt', // 报告文件
'.lisp', // Lisp 脚本
'.il', // Cadence SKILL 脚本
'.ils', // Cadence SKILL++ 脚本
'.sp', // SPICE netlist 文件
'.spi', // SPICE netlist 文件
'.cir', // SPICE netlist 文件
'.net', // SPICE netlist 文件
'.scs', // Spectre netlist 文件
'.asc', // LTspice netlist schematic 文件
'.tf' // Technology File
]
export const ZOOM_SHORTCUTS = [

View File

@@ -0,0 +1 @@
export const NUTSTORE_HOST = 'https://dav.jianguoyun.com/dav'

View File

@@ -1,8 +1,5 @@
const { ProxyAgent } = require('undici')
const { SocksProxyAgent } = require('socks-proxy-agent')
const https = require('https')
const fs = require('fs')
const { pipeline } = require('stream/promises')
/**
* Downloads a file from a URL with redirect handling
@@ -11,42 +8,28 @@ const { pipeline } = require('stream/promises')
* @returns {Promise<void>} Promise that resolves when download is complete
*/
async function downloadWithRedirects(url, destinationPath) {
const proxyUrl = process.env.HTTPS_PROXY || process.env.HTTP_PROXY
if (proxyUrl.startsWith('socks')) {
const proxyAgent = new SocksProxyAgent(proxyUrl)
return new Promise((resolve, reject) => {
const request = (url) => {
https
.get(url, { agent: proxyAgent }, (response) => {
if (response.statusCode == 301 || response.statusCode == 302) {
request(response.headers.location)
return
}
if (response.statusCode !== 200) {
reject(new Error(`Download failed: ${response.statusCode} ${response.statusMessage}`))
return
}
const file = fs.createWriteStream(destinationPath)
response.pipe(file)
file.on('finish', () => resolve())
})
.on('error', (err) => {
reject(err)
})
}
request(url)
})
} else {
const proxyAgent = new ProxyAgent(proxyUrl)
const response = await fetch(url, {
dispatcher: proxyAgent
})
if (!response.ok) {
throw new Error(`Download failed: ${response.status} ${response.statusText}`)
return new Promise((resolve, reject) => {
const request = (url) => {
https
.get(url, (response) => {
if (response.statusCode == 301 || response.statusCode == 302) {
request(response.headers.location)
return
}
if (response.statusCode !== 200) {
reject(new Error(`Download failed: ${response.statusCode} ${response.statusMessage}`))
return
}
const file = fs.createWriteStream(destinationPath)
response.pipe(file)
file.on('finish', () => resolve())
})
.on('error', (err) => {
reject(err)
})
}
const file = fs.createWriteStream(destinationPath)
await pipeline(response.body, file)
}
request(url)
})
}
module.exports = { downloadWithRedirects }

View File

@@ -1,4 +1,4 @@
import type { BaseEmbeddings } from '@llm-tools/embedjs-interfaces'
import type { BaseEmbeddings } from '@cherrystudio/embedjs-interfaces'
import { KnowledgeBaseParams } from '@types'
import EmbeddingsFactory from './EmbeddingsFactory'

View File

@@ -1,6 +1,6 @@
import type { BaseEmbeddings } from '@llm-tools/embedjs-interfaces'
import { OpenAiEmbeddings } from '@llm-tools/embedjs-openai'
import { AzureOpenAiEmbeddings } from '@llm-tools/embedjs-openai/src/azure-openai-embeddings'
import type { BaseEmbeddings } from '@cherrystudio/embedjs-interfaces'
import { OpenAiEmbeddings } from '@cherrystudio/embedjs-openai'
import { AzureOpenAiEmbeddings } from '@cherrystudio/embedjs-openai/src/azure-openai-embeddings'
import { getInstanceName } from '@main/utils'
import { KnowledgeBaseParams } from '@types'

View File

@@ -1,5 +1,5 @@
import { BaseEmbeddings } from '@cherrystudio/embedjs-interfaces'
import { VoyageEmbeddings as _VoyageEmbeddings } from '@langchain/community/embeddings/voyage'
import { BaseEmbeddings } from '@llm-tools/embedjs-interfaces'
export default class VoyageEmbeddings extends BaseEmbeddings {
private model: _VoyageEmbeddings

View File

@@ -1,10 +1,11 @@
import { electronApp, optimizer } from '@electron-toolkit/utils'
import { replaceDevtoolsFont } from '@main/utils/windowUtil'
import { app, ipcMain } from 'electron'
import installExtension, { REDUX_DEVTOOLS } from 'electron-devtools-installer'
import installExtension, { REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS } from 'electron-devtools-installer'
import { registerIpc } from './ipc'
import { configManager } from './services/ConfigManager'
import { CHERRY_STUDIO_PROTOCOL, handleProtocolUrl, registerProtocolClient } from './services/ProtocolClient'
import { registerShortcuts } from './services/ShortcutService'
import { TrayService } from './services/TrayService'
import { windowService } from './services/WindowService'
@@ -47,7 +48,7 @@ if (!app.requestSingleInstanceLock()) {
replaceDevtoolsFont(mainWindow)
if (process.env.NODE_ENV === 'development') {
installExtension(REDUX_DEVTOOLS)
installExtension([REDUX_DEVTOOLS, REACT_DEVELOPER_TOOLS])
.then((name) => console.log(`Added Extension: ${name}`))
.catch((err) => console.log('An error occurred: ', err))
}
@@ -56,9 +57,30 @@ if (!app.requestSingleInstanceLock()) {
})
})
registerProtocolClient(app)
// macOS specific: handle protocol when app is already running
app.on('open-url', (event, url) => {
event.preventDefault()
handleProtocolUrl(url)
})
registerProtocolClient(app)
// macOS specific: handle protocol when app is already running
app.on('open-url', (event, url) => {
event.preventDefault()
handleProtocolUrl(url)
})
// Listen for second instance
app.on('second-instance', () => {
app.on('second-instance', (_event, argv) => {
windowService.showMainWindow()
// Protocol handler for Windows/Linux
// The commandLine is an array of strings where the last item might be the URL
const url = argv.find((arg) => arg.startsWith(CHERRY_STUDIO_PROTOCOL + '://'))
if (url) handleProtocolUrl(url)
})
app.on('browser-window-created', (_, window) => {

View File

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

File diff suppressed because one or more lines are too long

View File

@@ -2,7 +2,7 @@ import fs from 'node:fs'
import { isMac, isWin } from '@main/constant'
import { getBinaryPath, isBinaryExists, runInstallScript } from '@main/utils/process'
import { MCPServer, Shortcut, ThemeMode } from '@types'
import { Shortcut, ThemeMode } from '@types'
import { BrowserWindow, ipcMain, session, shell } from 'electron'
import log from 'electron-log'
@@ -16,7 +16,9 @@ import FileService from './services/FileService'
import FileStorage from './services/FileStorage'
import { GeminiService } from './services/GeminiService'
import KnowledgeService from './services/KnowledgeService'
import MCPService from './services/MCPService'
import mcpService from './services/MCPService'
import * as NutstoreService from './services/NutstoreService'
import ObsidianVaultService from './services/ObsidianVaultService'
import { ProxyConfig, proxyManager } from './services/ProxyManager'
import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService'
import { TrayService } from './services/TrayService'
@@ -29,7 +31,7 @@ import { compress, decompress } from './utils/zip'
const fileManager = new FileStorage()
const backupManager = new BackupManager()
const exportService = new ExportService(fileManager)
const mcpService = new MCPService()
const obsidianVaultService = new ObsidianVaultService()
export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
const appUpdater = new AppUpdater(mainWindow)
@@ -164,6 +166,8 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
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)
// file
ipcMain.handle('file:open', fileManager.open)
@@ -251,6 +255,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
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))
// aes
ipcMain.handle('aes:encrypt', (_, text: string, secretKey: string, iv: string) => encrypt(text, secretKey, iv))
@@ -259,36 +264,18 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
)
// Register MCP handlers
ipcMain.on('mcp:servers-from-renderer', (_, servers) => mcpService.setServers(servers))
ipcMain.handle('mcp:list-servers', async () => mcpService.listAvailableServices())
ipcMain.handle('mcp:add-server', async (_, server: MCPServer) => mcpService.addServer(server))
ipcMain.handle('mcp:update-server', async (_, server: MCPServer) => mcpService.updateServer(server))
ipcMain.handle('mcp:delete-server', async (_, serverName: string) => mcpService.deleteServer(serverName))
ipcMain.handle('mcp:set-server-active', async (_, { name, isActive }) =>
mcpService.setServerActive({ name, isActive })
)
// According to preload, this should take no parameters, but our implementation accepts
// an optional serverName for better flexibility
ipcMain.handle('mcp:list-tools', async (_, serverName?: string) => mcpService.listTools(serverName))
ipcMain.handle('mcp:call-tool', async (_, params: { client: string; name: string; args: any }) =>
mcpService.callTool(params)
)
ipcMain.handle('mcp:cleanup', async () => mcpService.cleanup())
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('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'))
// Listen for changes in MCP servers and notify renderer
mcpService.on('servers-updated', (servers) => {
mainWindow?.webContents.send('mcp:servers-updated', servers)
})
app.on('before-quit', () => mcpService.cleanup())
//copilot
ipcMain.handle('copilot:get-auth-message', CopilotService.getAuthMessage)
ipcMain.handle('copilot:get-copilot-token', CopilotService.getCopilotToken)
@@ -296,4 +283,20 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle('copilot:get-token', CopilotService.getToken)
ipcMain.handle('copilot:logout', CopilotService.logout)
ipcMain.handle('copilot:get-user', CopilotService.getUser)
// Obsidian service
ipcMain.handle('obsidian:get-vaults', () => {
return obsidianVaultService.getVaults()
})
ipcMain.handle('obsidian:get-files', (_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) =>
NutstoreService.getDirectoryContents(token, path)
)
}

View File

@@ -1,6 +1,6 @@
import * as fs from 'node:fs'
import { JsonLoader } from '@llm-tools/embedjs'
import { JsonLoader } from '@cherrystudio/embedjs'
/**
* Drafts 应用导出的笔记文件加载器

View File

@@ -1,6 +1,6 @@
import { BaseLoader } from '@cherrystudio/embedjs-interfaces'
import { cleanString } from '@cherrystudio/embedjs-utils'
import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters'
import { BaseLoader } from '@llm-tools/embedjs-interfaces'
import { cleanString } from '@llm-tools/embedjs-utils'
import { getTempDir } from '@main/utils/file'
import Logger from 'electron-log'
import EPub from 'epub'

View File

@@ -1,8 +1,8 @@
import * as fs from 'node:fs'
import { JsonLoader, LocalPathLoader, RAGApplication, TextLoader } from '@llm-tools/embedjs'
import type { AddLoaderReturn } from '@llm-tools/embedjs-interfaces'
import { WebLoader } from '@llm-tools/embedjs-loader-web'
import { JsonLoader, LocalPathLoader, RAGApplication, TextLoader } from '@cherrystudio/embedjs'
import type { AddLoaderReturn } from '@cherrystudio/embedjs-interfaces'
import { WebLoader } from '@cherrystudio/embedjs-loader-web'
import { LoaderReturn } from '@shared/config/types'
import { FileType, KnowledgeBaseParams } from '@types'
import Logger from 'electron-log'

View File

@@ -1,6 +1,6 @@
import { BaseLoader } from '@cherrystudio/embedjs-interfaces'
import { cleanString } from '@cherrystudio/embedjs-utils'
import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters'
import { BaseLoader } from '@llm-tools/embedjs-interfaces'
import { cleanString } from '@llm-tools/embedjs-utils'
import md5 from 'md5'
import { OfficeParserConfig, parseOfficeAsync } from 'officeparser'

View File

@@ -1,4 +1,4 @@
import type { ExtractChunkData } from '@llm-tools/embedjs-interfaces'
import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
import { KnowledgeBaseParams } from '@types'
export default abstract class BaseReranker {
@@ -17,4 +17,15 @@ export default abstract class BaseReranker {
'Content-Type': 'application/json'
}
}
public formatErrorMessage(url: string, error: any, requestBody: any) {
const errorDetails = {
url: url,
message: error.message,
status: error.response?.status,
statusText: error.response?.statusText,
requestBody: requestBody
}
return JSON.stringify(errorDetails, null, 2)
}
}

View File

@@ -1,4 +1,4 @@
import type { ExtractChunkData } from '@llm-tools/embedjs-interfaces'
import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
import { KnowledgeBaseParams } from '@types'
import BaseReranker from './BaseReranker'

View File

@@ -1,4 +1,4 @@
import { ExtractChunkData } from '@llm-tools/embedjs-interfaces'
import { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
import { KnowledgeBaseParams } from '@types'
import axios from 'axios'
@@ -47,8 +47,10 @@ export default class JinaReranker extends BaseReranker {
.filter((doc): doc is ExtractChunkData => doc !== undefined)
.sort((a, b) => b.score - a.score)
} catch (error: any) {
console.error('Jina Reranker API 错误:', error.status)
throw new Error(`${error} - BaseUrl: ${baseURL}`)
const errorDetails = this.formatErrorMessage(url, error, requestBody)
console.error('Jina Reranker API Error:', errorDetails)
throw new Error(`重排序请求失败: ${error.message}\n请求详情: ${errorDetails}`)
}
}
}

View File

@@ -1,4 +1,4 @@
import type { ExtractChunkData } from '@llm-tools/embedjs-interfaces'
import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
import { KnowledgeBaseParams } from '@types'
import BaseReranker from './BaseReranker'

View File

@@ -1,4 +1,4 @@
import type { ExtractChunkData } from '@llm-tools/embedjs-interfaces'
import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
import { KnowledgeBaseParams } from '@types'
import axios from 'axios'
@@ -49,8 +49,10 @@ export default class SiliconFlowReranker extends BaseReranker {
.filter((doc): doc is ExtractChunkData => doc !== undefined)
.sort((a, b) => b.score - a.score)
} catch (error: any) {
console.error('SiliconFlow Reranker API 错误:', error.status)
throw new Error(`${error} - BaseUrl: ${baseURL}`)
const errorDetails = this.formatErrorMessage(url, error, requestBody)
console.error('SiliconFlow Reranker API 错误:', errorDetails)
throw new Error(`重排序请求失败: ${error.message}\n请求详情: ${errorDetails}`)
}
}
}

View File

@@ -1,4 +1,4 @@
import { ExtractChunkData } from '@llm-tools/embedjs-interfaces'
import { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
import { KnowledgeBaseParams } from '@types'
import axios from 'axios'
@@ -53,8 +53,10 @@ export default class VoyageReranker extends BaseReranker {
.filter((doc): doc is ExtractChunkData => doc !== undefined)
.sort((a, b) => b.score - a.score)
} catch (error: any) {
console.error('Voyage Reranker API 错误:', error.message || error)
throw new Error(`${error} - BaseUrl: ${baseURL}`)
const errorDetails = this.formatErrorMessage(url, error, requestBody)
console.error('Voyage Reranker API Error:', errorDetails)
throw new Error(`重排序请求失败: ${error.message}\n请求详情: ${errorDetails}`)
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 353 KiB

View File

@@ -5,7 +5,7 @@ import { app } from 'electron'
import Logger from 'electron-log'
import * as fs from 'fs-extra'
import * as path from 'path'
import { createClient, FileStat } from 'webdav'
import { createClient, CreateDirectoryOptions, FileStat } from 'webdav'
import WebDav from './WebDav'
import { windowService } from './WindowService'
@@ -15,6 +15,7 @@ class BackupManager {
private backupDir = path.join(app.getPath('temp'), 'cherry-studio', 'backup')
constructor() {
this.checkConnection = this.checkConnection.bind(this)
this.backup = this.backup.bind(this)
this.restore = this.restore.bind(this)
this.backupToWebdav = this.backupToWebdav.bind(this)
@@ -86,9 +87,16 @@ class BackupManager {
await fs.ensureDir(this.tempDir)
onProgress({ stage: 'preparing', progress: 0, total: 100 })
// 将 data 写入临时文件
// 使用流的方式写入 data.json
const tempDataPath = path.join(this.tempDir, 'data.json')
await fs.writeFile(tempDataPath, data)
await new Promise<void>((resolve, reject) => {
const writeStream = fs.createWriteStream(tempDataPath)
writeStream.write(data)
writeStream.end()
writeStream.on('finish', () => resolve())
writeStream.on('error', (error) => reject(error))
})
onProgress({ stage: 'writing_data', progress: 20, total: 100 })
// 复制 Data 目录到临时目录
@@ -207,8 +215,15 @@ class BackupManager {
fs.mkdirSync(this.backupDir, { recursive: true })
}
// sync为同步写无须await
fs.writeFileSync(backupedFilePath, retrievedFile as Buffer)
// 使用流的方式写入文件
await new Promise<void>((resolve, reject) => {
const writeStream = fs.createWriteStream(backupedFilePath)
writeStream.write(retrievedFile as Buffer)
writeStream.end()
writeStream.on('finish', () => resolve())
writeStream.on('error', (error) => reject(error))
})
return await this.restore(_, backupedFilePath)
} catch (error: any) {
@@ -278,6 +293,21 @@ class BackupManager {
}
}
}
async checkConnection(_: Electron.IpcMainInvokeEvent, webdavConfig: WebDavConfig) {
const webdavClient = new WebDav(webdavConfig)
return await webdavClient.checkConnection()
}
async createDirectory(
_: Electron.IpcMainInvokeEvent,
webdavConfig: WebDavConfig,
path: string,
options?: CreateDirectoryOptions
) {
const webdavClient = new WebDav(webdavConfig)
return await webdavClient.createDirectory(path, options)
}
}
export default BackupManager

View File

@@ -16,11 +16,11 @@
import * as fs from 'node:fs'
import path from 'node:path'
import { RAGApplication, RAGApplicationBuilder, TextLoader } from '@llm-tools/embedjs'
import type { ExtractChunkData } from '@llm-tools/embedjs-interfaces'
import { LibSqlDb } from '@llm-tools/embedjs-libsql'
import { SitemapLoader } from '@llm-tools/embedjs-loader-sitemap'
import { WebLoader } from '@llm-tools/embedjs-loader-web'
import { RAGApplication, RAGApplicationBuilder, TextLoader } from '@cherrystudio/embedjs'
import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
import { LibSqlDb } from '@cherrystudio/embedjs-libsql'
import { SitemapLoader } from '@cherrystudio/embedjs-loader-sitemap'
import { WebLoader } from '@cherrystudio/embedjs-loader-web'
import Embeddings from '@main/embeddings/Embeddings'
import { addFileLoader } from '@main/loader'
import Reranker from '@main/reranker/Reranker'
@@ -475,6 +475,9 @@ class KnowledgeService {
_: Electron.IpcMainInvokeEvent,
{ search, base, results }: { search: string; base: KnowledgeBaseParams; results: ExtractChunkData[] }
): Promise<ExtractChunkData[]> => {
if (results.length === 0) {
return results
}
return await new Reranker(base).rerank(search, results)
}
}

View File

@@ -1,559 +1,228 @@
import os from 'node:os'
import path from 'node:path'
import { isLinux, isMac, isWin } from '@main/constant'
import { getBinaryPath } from '@main/utils/process'
import type { Client } from '@modelcontextprotocol/sdk/client/index.js'
import type { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
import type { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
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 { nanoid } from '@reduxjs/toolkit'
import { MCPServer, MCPTool } from '@types'
import log from 'electron-log'
import { EventEmitter } from 'events'
import { v4 as uuidv4 } from 'uuid'
import { app } from 'electron'
import Logger from 'electron-log'
import { CacheService } from './CacheService'
import { windowService } from './WindowService'
/**
* Service for managing Model Context Protocol servers and tools
*/
export default class MCPService extends EventEmitter {
private servers: MCPServer[] = []
private activeServers: Map<string, any> = new Map()
private clients: { [key: string]: any } = {}
private Client: typeof Client | undefined
private stdioTransport: typeof StdioClientTransport | undefined
private sseTransport: typeof SSEClientTransport | undefined
private initialized = false
private initPromise: Promise<void> | null = null
class McpService {
private clients: Map<string, Client> = new Map()
// Simplified server loading state management
private readyState = {
serversLoaded: false,
promise: null as Promise<void> | null,
resolve: null as ((value: void) => void) | null
}
constructor() {
super()
this.createServerLoadingPromise()
this.init().catch((err) => this.logError('Failed to initialize MCP service', err))
}
/**
* Create a promise that resolves when servers are loaded
*/
private createServerLoadingPromise(): void {
this.readyState.promise = new Promise<void>((resolve) => {
this.readyState.resolve = resolve
private getServerKey(server: MCPServer): string {
return JSON.stringify({
baseUrl: server.baseUrl,
command: server.command,
args: server.args,
registryUrl: server.registryUrl,
env: server.env,
id: server.id
})
}
/**
* Set servers received from Redux and trigger initialization if needed
*/
public setServers(servers: MCPServer[]): void {
this.servers = servers
log.info(`[MCP] Received ${servers.length} servers from Redux`)
// Mark servers as loaded and resolve the waiting promise
if (!this.readyState.serversLoaded && this.readyState.resolve) {
this.readyState.serversLoaded = true
this.readyState.resolve()
this.readyState.resolve = null
}
// Initialize if not already initialized
if (!this.initialized) {
this.init().catch((err) => this.logError('Failed to initialize MCP service', err))
}
constructor() {
this.initClient = this.initClient.bind(this)
this.listTools = this.listTools.bind(this)
this.callTool = this.callTool.bind(this)
this.closeClient = this.closeClient.bind(this)
this.removeServer = this.removeServer.bind(this)
this.restartServer = this.restartServer.bind(this)
this.stopServer = this.stopServer.bind(this)
}
/**
* Initialize the MCP service if not already initialized
*/
public async init(): Promise<void> {
// If already initialized, return immediately
if (this.initialized) return
async initClient(server: MCPServer): Promise<Client> {
const serverKey = this.getServerKey(server)
// If initialization is in progress, return that promise
if (this.initPromise) return this.initPromise
this.initPromise = (async () => {
try {
log.info('[MCP] Starting initialization')
// Wait for servers to be loaded from Redux
await this.waitForServers()
// Load SDK components in parallel for better performance
const [Client, StdioTransport, SSETransport] = await Promise.all([
this.importClient(),
this.importStdioClientTransport(),
this.importSSEClientTransport()
])
this.Client = Client
this.stdioTransport = StdioTransport
this.sseTransport = SSETransport
// Mark as initialized before loading servers
this.initialized = true
// Load active servers
await this.loadActiveServers()
log.info('[MCP] Initialization successfully')
return
} catch (err) {
this.initialized = false // Reset flag on error
log.error('[MCP] Failed to initialize:', err)
throw err
} finally {
this.initPromise = null
// 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) {
this.clients.delete(serverKey)
} else {
return existingClient
}
})()
return this.initPromise
}
/**
* Wait for servers to be loaded from Redux
*/
private async waitForServers(): Promise<void> {
if (!this.readyState.serversLoaded && this.readyState.promise) {
log.info('[MCP] Waiting for servers data from Redux...')
await this.readyState.promise
log.info('[MCP] Servers received, continuing initialization')
}
}
/**
* Helper to create consistent error logging functions
*/
private logError(message: string, err?: any): void {
log.error(`[MCP] ${message}`, err)
}
/**
* Import the MCP client SDK
*/
private async importClient() {
try {
const { Client } = await import('@modelcontextprotocol/sdk/client/index.js')
return Client
} catch (err) {
this.logError('Failed to import Client:', err)
throw err
}
}
/**
* Import the stdio transport
*/
private async importStdioClientTransport() {
try {
const { StdioClientTransport } = await import('@modelcontextprotocol/sdk/client/stdio.js')
return StdioClientTransport
} catch (err) {
log.error('[MCP] Failed to import StdioTransport:', err)
throw err
}
}
/**
* Import the SSE transport
*/
private async importSSEClientTransport() {
try {
const { SSEClientTransport } = await import('@modelcontextprotocol/sdk/client/sse.js')
return SSEClientTransport
} catch (err) {
log.error('[MCP] Failed to import SSETransport:', err)
throw err
}
}
/**
* List all available MCP servers
*/
public async listAvailableServices(): Promise<MCPServer[]> {
await this.ensureInitialized()
return this.servers
}
/**
* Ensure the service is initialized before operations
*/
private async ensureInitialized() {
if (!this.initialized) {
log.debug('[MCP] Ensuring initialization')
await this.init()
}
}
/**
* Add a new MCP server
*/
public async addServer(server: MCPServer): Promise<void> {
await this.ensureInitialized()
// Check for duplicate name
if (this.servers.some((s) => s.name === server.name)) {
throw new Error(`Server with name ${server.name} already exists`)
}
// Activate if needed
if (server.isActive) {
await this.activate(server)
}
// Create new client instance for each connection
const client = new Client({ name: 'Cherry Studio', version: app.getVersion() }, { capabilities: {} })
// Add to servers list
this.servers = [...this.servers, server]
this.notifyReduxServersChanged(this.servers)
}
/**
* Update an existing MCP server
*/
public async updateServer(server: MCPServer): Promise<void> {
await this.ensureInitialized()
const index = this.servers.findIndex((s) => s.name === server.name)
if (index === -1) {
throw new Error(`Server ${server.name} not found`)
}
// Check activation status change
const wasActive = this.servers[index].isActive
if (wasActive && !server.isActive) {
await this.deactivate(server.name)
} else if (!wasActive && server.isActive) {
await this.activate(server)
} else {
await this.restartServer(server)
}
// Update servers list
const updatedServers = [...this.servers]
updatedServers[index] = server
this.servers = updatedServers
// Notify Redux
this.notifyReduxServersChanged(updatedServers)
}
public async restartServer(_server: MCPServer): Promise<void> {
await this.ensureInitialized()
const server = this.servers.find((s) => s.name === _server.name)
if (server) {
if (server.isActive) {
await this.deactivate(server.name)
}
await this.activate(server)
}
}
/**
* Delete an MCP server
*/
public async deleteServer(serverName: string): Promise<void> {
await this.ensureInitialized()
// Deactivate if running
if (this.clients[serverName]) {
await this.deactivate(serverName)
}
// Update servers list
const filteredServers = this.servers.filter((s) => s.name !== serverName)
this.servers = filteredServers
this.notifyReduxServersChanged(filteredServers)
}
/**
* Set a server's active state
*/
public async setServerActive(params: { name: string; isActive: boolean }): Promise<void> {
await this.ensureInitialized()
const { name, isActive } = params
const server = this.servers.find((s) => s.name === name)
if (!server) {
throw new Error(`Server ${name} not found`)
}
// Activate or deactivate as needed
if (isActive) {
await this.activate(server)
} else {
await this.deactivate(name)
}
// Update server status
server.isActive = isActive
this.notifyReduxServersChanged([...this.servers])
}
/**
* Notify Redux in the renderer process about server changes
*/
private notifyReduxServersChanged(servers: MCPServer[]): void {
const mainWindow = windowService.getMainWindow()
if (mainWindow) {
mainWindow.webContents.send('mcp:servers-changed', servers)
}
}
/**
* Activate an MCP server
*/
public async activate(server: MCPServer): Promise<void> {
await this.ensureInitialized()
const { name, baseUrl, command, env } = server
const args = [...(server.args || [])]
// Skip if already running
if (this.clients[name]) {
log.info(`[MCP] Server ${name} is already running`)
return
}
let transport: StdioClientTransport | SSEClientTransport
try {
// Create appropriate transport based on configuration
if (baseUrl) {
transport = new this.sseTransport!(new URL(baseUrl))
} else if (command) {
let cmd: string = command
if (command === 'npx') {
if (server.baseUrl) {
transport = new SSEClientTransport(new URL(server.baseUrl))
} else if (server.command) {
let cmd = server.command
if (server.command === 'npx' || server.command === 'bun' || server.command === 'bunx') {
cmd = await getBinaryPath('bun')
if (cmd === 'bun') {
cmd = 'npx'
}
log.info(`[MCP] Using command: ${cmd}`)
Logger.info(`[MCP] Using command: ${cmd}`)
// add -x to args if args exist
if (args && args.length > 0) {
if (!args.includes('-y')) {
args.unshift('-y')
!args.includes('-y') && args.unshift('-y')
}
if (cmd.includes('bun') && !args.includes('x')) {
if (!args.includes('x')) {
args.unshift('x')
}
}
} else if (command === 'uvx') {
cmd = await getBinaryPath('uvx')
if (server.registryUrl) {
server.env = {
...server.env,
NPM_CONFIG_REGISTRY: server.registryUrl
}
// if the server name is mcp-auto-install, use the mcp-registry.json file in the bin directory
if (server.name === 'mcp-auto-install') {
const binPath = await getBinaryPath()
makeSureDirExists(binPath)
server.env.MCP_REGISTRY_PATH = path.join(binPath, 'mcp-registry.json')
}
}
} else if (server.command === 'uvx' || server.command === 'uv') {
cmd = await getBinaryPath(server.command)
if (server.registryUrl) {
server.env = {
...server.env,
UV_DEFAULT_INDEX: server.registryUrl,
PIP_INDEX_URL: server.registryUrl
}
}
}
log.info(`[MCP] Starting server with command: ${cmd} ${args ? args.join(' ') : ''}`)
Logger.info(`[MCP] Starting server with command: ${cmd} ${args ? args.join(' ') : ''}`)
// Logger.info(`[MCP] Environment variables for server:`, server.env)
transport = new this.stdioTransport!({
transport = new StdioClientTransport({
command: cmd,
args,
stderr: 'pipe',
env: {
...getDefaultEnvironment(),
PATH: this.getEnhancedPath(process.env.PATH || ''),
...env
...server.env
}
})
} else {
throw new Error('Either baseUrl or command must be provided')
}
// Create and connect client
const client = new this.Client!({ name, version: '1.0.0' }, { capabilities: {} })
await client.connect(transport)
// Store client and server info
this.clients[name] = client
this.activeServers.set(name, { client, server })
// Store the new client in the cache
this.clients.set(serverKey, client)
log.info(`[MCP] Activated server: ${server.name}`)
this.emit('server-started', { name })
} catch (error) {
log.error(`[MCP] Error activating server ${name}:`, error)
this.setServerActive({ name, isActive: false })
Logger.info(`[MCP] Activated server: ${server.name}`)
return client
} catch (error: any) {
Logger.error(`[MCP] Error activating server ${server.name}:`, error)
throw error
}
}
/**
* Deactivate an MCP server
*/
public async deactivate(name: string): Promise<void> {
await this.ensureInitialized()
if (!this.clients[name]) {
log.warn(`[MCP] Server ${name} is not running`)
return
}
try {
log.info(`[MCP] Stopping server: ${name}`)
await this.clients[name].close()
delete this.clients[name]
this.activeServers.delete(name)
this.emit('server-stopped', { name })
} catch (error) {
log.error(`[MCP] Error deactivating server ${name}:`, error)
throw error
async closeClient(serverKey: string) {
const client = this.clients.get(serverKey)
if (client) {
// Remove the client from the cache
await client.close()
Logger.info(`[MCP] Closed server: ${serverKey}`)
this.clients.delete(serverKey)
CacheService.remove(`mcp:list_tool:${serverKey}`)
Logger.info(`[MCP] Cleared cache for server: ${serverKey}`)
} else {
Logger.warn(`[MCP] No client found for server: ${serverKey}`)
}
}
/**
* List available tools from active MCP servers
*/
public async listTools(serverName?: string): Promise<MCPTool[]> {
await this.ensureInitialized()
log.info(`[MCP] Listing tools from ${serverName || 'all active servers'}`)
async stopServer(_: Electron.IpcMainInvokeEvent, server: MCPServer) {
const serverKey = this.getServerKey(server)
Logger.info(`[MCP] Stopping server: ${server.name}`)
await this.closeClient(serverKey)
}
try {
// If server name provided, list tools for that server only
if (serverName) {
return await this.listToolsFromServer(serverName)
}
// Otherwise list tools from all active servers
let allTools: MCPTool[] = []
for (const clientName in this.clients) {
log.info(`[MCP] Listing tools from ${clientName}`)
try {
const tools = await this.listToolsFromServer(clientName)
allTools = allTools.concat(tools)
} catch (error) {
this.logError(`Error listing tools for ${clientName}`, error)
}
}
log.info(`[MCP] Total tools listed: ${allTools.length}`)
return allTools
} catch (error) {
this.logError('Error listing tools:', error)
return []
async removeServer(_: Electron.IpcMainInvokeEvent, server: MCPServer) {
const serverKey = this.getServerKey(server)
const existingClient = this.clients.get(serverKey)
if (existingClient) {
await this.closeClient(serverKey)
}
}
/**
* Helper method to list tools from a specific server
*/
private async listToolsFromServer(serverName: string): Promise<MCPTool[]> {
log.info(`[MCP] start list tools from ${serverName}:`)
if (!this.clients[serverName]) {
throw new Error(`MCP Client ${serverName} not found`)
}
const cacheKey = `mcp:list_tool:${serverName}`
async restartServer(_: Electron.IpcMainInvokeEvent, server: MCPServer) {
Logger.info(`[MCP] Restarting server: ${server.name}`)
const serverKey = this.getServerKey(server)
await this.closeClient(serverKey)
await this.initClient(server)
}
async listTools(_: Electron.IpcMainInvokeEvent, server: MCPServer) {
const client = await this.initClient(server)
const serverKey = this.getServerKey(server)
const cacheKey = `mcp:list_tool:${serverKey}`
if (CacheService.has(cacheKey)) {
log.info(`[MCP] Tools from ${serverName} loaded from cache`)
// Check if cache is still valid
Logger.info(`[MCP] Tools from ${server.name} loaded from cache`)
const cachedTools = CacheService.get<MCPTool[]>(cacheKey)
if (cachedTools && cachedTools.length > 0) {
return cachedTools
}
CacheService.remove(cacheKey)
}
const { tools } = await this.clients[serverName].listTools()
const transformedTools = tools.map((tool: any) => ({
...tool,
serverName,
id: 'f' + uuidv4().replace(/-/g, '')
}))
// Cache the tools for 5 minutes
if (transformedTools.length > 0) {
CacheService.set(cacheKey, transformedTools, 5 * 60 * 1000)
}
log.info(`[MCP] Tools from ${serverName}:`, transformedTools)
return transformedTools
Logger.info(`[MCP] Listing tools for server: ${server.name}`)
const { tools } = await client.listTools()
const serverTools: MCPTool[] = []
tools.map((tool: any) => {
const serverTool: MCPTool = {
...tool,
id: `f${nanoid()}`,
serverId: server.id,
serverName: server.name
}
serverTools.push(serverTool)
})
CacheService.set(cacheKey, serverTools, 5 * 60 * 1000)
return serverTools
}
/**
* Call a tool on an MCP server
*/
public async callTool(params: { client: string; name: string; args: any }): Promise<any> {
await this.ensureInitialized()
const { client, name, args } = params
if (!this.clients[client]) {
throw new Error(`MCP Client ${client} not found`)
}
log.info('[MCP] Calling:', client, name, args)
public async callTool(
_: Electron.IpcMainInvokeEvent,
{ server, name, args }: { server: MCPServer; name: string; args: any }
): Promise<any> {
try {
return await this.clients[client].callTool({
name,
arguments: args
})
Logger.info('[MCP] Calling:', server.name, name, args)
const client = await this.initClient(server)
const result = await client.callTool({ name, arguments: args })
return result
} catch (error) {
log.error(`[MCP] Error calling tool ${name} on ${client}:`, error)
Logger.error(`[MCP] Error calling tool ${name} on ${server.name}:`, error)
throw error
}
}
/**
* Clean up all MCP resources
*/
public async cleanup(): Promise<void> {
const clientNames = Object.keys(this.clients)
if (clientNames.length === 0) {
log.info('[MCP] No active servers to clean up')
return
}
log.info(`[MCP] Cleaning up ${clientNames.length} active servers`)
// Deactivate all clients
await Promise.allSettled(
clientNames.map((name) =>
this.deactivate(name).catch((err) => {
log.error(`[MCP] Error during cleanup of ${name}:`, err)
})
)
)
this.clients = {}
this.activeServers.clear()
log.info('[MCP] All servers cleaned up')
}
/**
* Load all active servers
*/
private async loadActiveServers(): Promise<void> {
const activeServers = this.servers.filter((server) => server.isActive)
if (activeServers.length === 0) {
log.info('[MCP] No active servers to load')
return
}
log.info(`[MCP] Start loading ${activeServers.length} active servers`)
// Activate servers in parallel for better performance
await Promise.allSettled(
activeServers.map(async (server) => {
try {
await this.activate(server)
} catch (error) {
this.logError(`Failed to activate server ${server.name}`, error)
this.emit('server-error', { name: server.name, error })
}
})
)
log.info(`[MCP] End loading ${Object.keys(this.clients).length} active servers`)
public async getInstallInfo() {
const dir = path.join(os.homedir(), '.cherrystudio', 'bin')
const uvName = await getBinaryName('uv')
const bunName = await getBinaryName('bun')
const uvPath = path.join(dir, uvName)
const bunPath = path.join(dir, bunName)
return { dir, uvPath, bunPath }
}
/**
@@ -581,6 +250,7 @@ export default class MCPService extends EventEmitter {
`${homeDir}/.npm-global/bin`,
`${homeDir}/.yarn/bin`,
`${homeDir}/.cargo/bin`,
`${homeDir}/.cherrystudio/bin`,
'/opt/local/bin'
)
}
@@ -594,12 +264,18 @@ export default class MCPService extends EventEmitter {
`${homeDir}/.npm-global/bin`,
`${homeDir}/.yarn/bin`,
`${homeDir}/.cargo/bin`,
`${homeDir}/.cherrystudio/bin`,
'/snap/bin'
)
}
if (isWin) {
newPaths.push(`${process.env.APPDATA}\\npm`, `${homeDir}\\AppData\\Local\\Yarn\\bin`, `${homeDir}\\.cargo\\bin`)
newPaths.push(
`${process.env.APPDATA}\\npm`,
`${homeDir}\\AppData\\Local\\Yarn\\bin`,
`${homeDir}\\.cargo\\bin`,
`${homeDir}\\.cherrystudio\\bin`
)
}
// 只添加不存在的路径
@@ -613,3 +289,5 @@ export default class MCPService extends EventEmitter {
return Array.from(existingPaths).join(pathSeparator)
}
}
export default new McpService()

View File

@@ -0,0 +1,134 @@
import path from 'node:path'
import { NUTSTORE_HOST } from '@shared/config/nutstore'
import { XMLParser } from 'fast-xml-parser'
import { isNil, partial } from 'lodash'
import { type FileStat } from 'webdav'
interface OAuthResponse {
username: string
userid: string
access_token: string
}
interface WebDAVResponse {
multistatus: {
response: Array<{
href: string
propstat: {
prop: {
displayname: string
resourcetype: { collection?: any }
getlastmodified?: string
getcontentlength?: string
getcontenttype?: string
}
status: string
}
}>
}
}
export async function getNutstoreSSOUrl() {
const { createOAuthUrl } = await import('../integration/nutstore/sso/lib')
const url = createOAuthUrl({
app: 'cherrystudio'
})
return url
}
export async function decryptToken(token: string) {
const { decrypt } = await import('../integration/nutstore/sso/lib')
try {
const decrypted = decrypt('cherrystudio', token)
return JSON.parse(decrypted) as OAuthResponse
} catch (error) {
console.error('解密失败:', error)
return null
}
}
export async function getDirectoryContents(token: string, target: string): Promise<FileStat[]> {
const contents: FileStat[] = []
if (!target.startsWith('/')) {
target = '/' + target
}
let currentUrl = `${NUTSTORE_HOST}${target}`
while (true) {
const response = await fetch(currentUrl, {
method: 'PROPFIND',
headers: {
Authorization: `Basic ${token}`,
'Content-Type': 'application/xml',
Depth: '1'
},
body: `<?xml version="1.0" encoding="utf-8"?>
<propfind xmlns="DAV:">
<prop>
<displayname/>
<resourcetype/>
<getlastmodified/>
<getcontentlength/>
<getcontenttype/>
</prop>
</propfind>`
})
const text = await response.text()
const result = parseXml<WebDAVResponse>(text)
const items = Array.isArray(result.multistatus.response)
? result.multistatus.response
: [result.multistatus.response]
// 跳过第一个条目(当前目录)
contents.push(...items.slice(1).map(partial(convertToFileStat, '/dav')))
const linkHeader = response.headers['link'] || response.headers['Link']
if (!linkHeader) {
break
}
const nextLink = extractNextLink(linkHeader)
if (!nextLink) {
break
}
currentUrl = decodeURI(nextLink)
}
return contents
}
function extractNextLink(linkHeader: string): string | null {
const matches = linkHeader.match(/<([^>]+)>;\s*rel="next"/)
return matches ? matches[1] : null
}
function convertToFileStat(serverBase: string, item: WebDAVResponse['multistatus']['response'][number]): FileStat {
const props = item.propstat.prop
const isDir = !isNil(props.resourcetype?.collection)
const href = decodeURIComponent(item.href)
const filename = serverBase === '/' ? href : path.posix.join('/', href.replace(serverBase, ''))
return {
filename: filename.endsWith('/') ? filename.slice(0, -1) : filename,
basename: path.basename(filename),
lastmod: props.getlastmodified || '',
size: props.getcontentlength ? parseInt(props.getcontentlength, 10) : 0,
type: isDir ? 'directory' : 'file',
etag: null,
mime: props.getcontenttype
}
}
function parseXml<T>(xml: string) {
const parser = new XMLParser({
attributeNamePrefix: '',
removeNSPrefix: true
})
return parser.parse(xml) as T
}

View File

@@ -0,0 +1,167 @@
import { app } from 'electron'
import fs from 'fs'
import path from 'path'
interface VaultInfo {
path: string
name: string
}
interface FileInfo {
path: string
type: 'folder' | 'markdown'
name: string
}
class ObsidianVaultService {
private obsidianConfigPath: string
constructor() {
// 根据操作系统获取Obsidian配置文件路径
if (process.platform === 'win32') {
this.obsidianConfigPath = path.join(app.getPath('appData'), 'obsidian', 'obsidian.json')
} else if (process.platform === 'darwin') {
this.obsidianConfigPath = path.join(
app.getPath('home'),
'Library',
'Application Support',
'obsidian',
'obsidian.json'
)
} else {
// Linux
this.obsidianConfigPath = path.join(app.getPath('home'), '.config', 'obsidian', 'obsidian.json')
}
}
/**
* 获取所有的Obsidian Vault
*/
getVaults(): VaultInfo[] {
try {
if (!fs.existsSync(this.obsidianConfigPath)) {
return []
}
const configContent = fs.readFileSync(this.obsidianConfigPath, 'utf8')
const config = JSON.parse(configContent)
if (!config.vaults) {
return []
}
return Object.entries(config.vaults).map(([, vault]: [string, any]) => ({
path: vault.path,
name: vault.name || path.basename(vault.path)
}))
} catch (error) {
console.error('获取Obsidian Vault失败:', error)
return []
}
}
/**
* 获取Vault中的文件夹和Markdown文件结构
*/
getVaultStructure(vaultPath: string): FileInfo[] {
const results: FileInfo[] = []
try {
// 检查vault路径是否存在
if (!fs.existsSync(vaultPath)) {
console.error('Vault路径不存在:', vaultPath)
return []
}
// 检查是否是目录
const stats = fs.statSync(vaultPath)
if (!stats.isDirectory()) {
console.error('Vault路径不是一个目录:', vaultPath)
return []
}
this.traverseDirectory(vaultPath, '', results)
} catch (error) {
console.error('读取Vault文件夹结构失败:', error)
}
return results
}
/**
* 递归遍历目录获取所有文件夹和Markdown文件
*/
private traverseDirectory(dirPath: string, relativePath: string, results: FileInfo[]) {
try {
// 首先添加当前文件夹
if (relativePath) {
results.push({
path: relativePath,
type: 'folder',
name: path.basename(relativePath)
})
}
// 确保目录存在且可访问
if (!fs.existsSync(dirPath)) {
console.error('目录不存在:', dirPath)
return
}
let items
try {
items = fs.readdirSync(dirPath, { withFileTypes: true })
} catch (err) {
console.error(`无法读取目录 ${dirPath}:`, err)
return
}
for (const item of items) {
// 忽略以.开头的隐藏文件夹和文件
if (item.name.startsWith('.')) {
continue
}
const newRelativePath = relativePath ? `${relativePath}/${item.name}` : item.name
const fullPath = path.join(dirPath, item.name)
if (item.isDirectory()) {
this.traverseDirectory(fullPath, newRelativePath, results)
} else if (item.isFile() && item.name.endsWith('.md')) {
// 收集.md文件
results.push({
path: newRelativePath,
type: 'markdown',
name: item.name
})
}
}
} catch (error) {
console.error(`遍历目录出错 ${dirPath}:`, error)
}
}
/**
* 获取指定Vault的文件夹和Markdown文件结构
* @param vaultName vault名称
*/
getFilesByVaultName(vaultName: string): FileInfo[] {
try {
const vaults = this.getVaults()
const vault = vaults.find((v) => v.name === vaultName)
if (!vault) {
console.error('未找到指定名称的Vault:', vaultName)
return []
}
console.log('获取Vault文件结构:', vault.name, vault.path)
return this.getVaultStructure(vault.path)
} catch (error) {
console.error('获取Vault文件结构时发生错误:', error)
return []
}
}
}
export default ObsidianVaultService

View File

@@ -0,0 +1,34 @@
import { windowService } from './WindowService'
export const CHERRY_STUDIO_PROTOCOL = 'cherrystudio'
export function registerProtocolClient(app: Electron.App) {
if (process.defaultApp) {
if (process.argv.length >= 2) {
app.setAsDefaultProtocolClient(CHERRY_STUDIO_PROTOCOL, process.execPath, [process.argv[1]])
}
}
app.setAsDefaultProtocolClient('cherrystudio')
}
export function handleProtocolUrl(url: string) {
if (!url) return
// Process the URL that was used to open the app
// The url will be in the format: cherrystudio://data?param1=value1&param2=value2
console.log('Received URL:', url)
// Parse the URL and extract parameters
const urlObj = new URL(url)
const params = new URLSearchParams(urlObj.search)
// You can send the data to your renderer process
const mainWindow = windowService.getMainWindow()
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('protocol-data', {
url,
params: Object.fromEntries(params.entries())
})
}
}

View File

@@ -23,17 +23,8 @@ function getShortcutHandler(shortcut: Shortcut) {
configManager.setZoomFactor(1)
}
case 'show_app':
return (window: BrowserWindow) => {
if (window.isVisible()) {
if (window.isFocused()) {
window.hide()
} else {
window.focus()
}
} else {
window.show()
window.focus()
}
return () => {
windowService.toggleMainWindow()
}
case 'mini_window':
return () => {
@@ -221,9 +212,13 @@ export function registerShortcuts(window: BrowserWindow) {
// only register the event handlers once
if (undefined === windowOnHandlers.get(window)) {
window.on('focus', register)
// pass register() directly to listener, the func will receive Event as argument, it's not expected
const registerHandler = () => {
register()
}
window.on('focus', registerHandler)
window.on('blur', unregister)
windowOnHandlers.set(window, { onFocusHandler: register, onBlurHandler: unregister })
windowOnHandlers.set(window, { onFocusHandler: registerHandler, onBlurHandler: unregister })
}
if (!window.isDestroyed() && window.isFocused()) {

View File

@@ -1,7 +1,14 @@
import { WebDavConfig } from '@types'
import Logger from 'electron-log'
import Stream from 'stream'
import { BufferLike, createClient, GetFileContentsOptions, PutFileContentsOptions, WebDAVClient } from 'webdav'
import {
BufferLike,
createClient,
CreateDirectoryOptions,
GetFileContentsOptions,
PutFileContentsOptions,
WebDAVClient
} from 'webdav'
export default class WebDav {
public instance: WebDAVClient | undefined
private webdavPath: string
@@ -18,6 +25,7 @@ export default class WebDav {
this.putFileContents = this.putFileContents.bind(this)
this.getFileContents = this.getFileContents.bind(this)
this.createDirectory = this.createDirectory.bind(this)
}
public putFileContents = async (
@@ -64,4 +72,30 @@ export default class WebDav {
throw error
}
}
public checkConnection = async () => {
if (!this.instance) {
throw new Error('WebDAV client not initialized')
}
try {
return await this.instance.exists('/')
} catch (error) {
Logger.error('[WebDAV] Error checking connection:', error)
throw error
}
}
public createDirectory = async (path: string, options?: CreateDirectoryOptions) => {
if (!this.instance) {
throw new Error('WebDAV client not initialized')
}
try {
return await this.instance.createDirectory(path, options)
} catch (error) {
Logger.error('[WebDAV] Error creating directory on WebDAV:', error)
throw error
}
}
}

View File

@@ -15,7 +15,11 @@ export class WindowService {
private static instance: WindowService | null = null
private mainWindow: BrowserWindow | null = null
private miniWindow: BrowserWindow | null = null
private isPinnedMiniWindow: boolean = false
private wasFullScreen: boolean = false
//hacky-fix: store the focused status of mainWindow before miniWindow shows
//to restore the focus status when miniWindow hides
private wasMainWindowFocused: boolean = false
private selectionMenuWindow: BrowserWindow | null = null
private lastSelectedText: string = ''
private contextMenu: Menu | null = null
@@ -30,6 +34,7 @@ export class WindowService {
public createMainWindow(): BrowserWindow {
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
this.mainWindow.show()
this.mainWindow.focus()
return this.mainWindow
}
@@ -56,7 +61,7 @@ export class WindowService {
titleBarOverlay: theme === 'dark' ? titleBarOverlayDark : titleBarOverlayLight,
backgroundColor: isMac ? undefined : theme === 'dark' ? '#181818' : '#FFFFFF',
trafficLightPosition: { x: 8, y: 12 },
...(process.platform === 'linux' ? { icon } : {}),
...(isLinux ? { icon } : {}),
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
@@ -68,6 +73,12 @@ export class WindowService {
this.setupMainWindow(this.mainWindow, mainWindowState)
//preload miniWindow to resolve series of issues about miniWindow in Mac
const enableQuickAssistant = configManager.getEnableQuickAssistant()
if (enableQuickAssistant && !this.miniWindow) {
this.miniWindow = this.createMiniWindow(true)
}
return this.mainWindow
}
@@ -148,6 +159,8 @@ export class WindowService {
// show window only when laucn to tray not set
const isLaunchToTray = configManager.getLaunchToTray()
if (!isLaunchToTray) {
//[mac]hacky-fix: miniWindow set visibleOnFullScreen:true will cause dock icon disappeared
app.dock?.show()
mainWindow.show()
}
})
@@ -163,6 +176,25 @@ export class WindowService {
mainWindow.webContents.send('fullscreen-status-changed', false)
})
// set the zoom factor again when the window is going to resize
//
// this is a workaround for the known bug that
// the zoom factor is reset to cached value when window is resized after routing to other page
// see: https://github.com/electron/electron/issues/10572
//
mainWindow.on('will-resize', () => {
mainWindow.webContents.setZoomFactor(configManager.getZoomFactor())
})
// ARCH: as `will-resize` is only for Win & Mac,
// linux has the same problem, use `resize` listener instead
// but `resize` will fliker the ui
if (isLinux) {
mainWindow.on('resize', () => {
mainWindow.webContents.setZoomFactor(configManager.getZoomFactor())
})
}
// 添加Escape键退出全屏的支持
mainWindow.webContents.on('before-input-event', (event, input) => {
// 当按下Escape键且窗口处于全屏状态时退出全屏
@@ -286,9 +318,8 @@ export class WindowService {
event.preventDefault()
mainWindow.hide()
if (isMac && isTrayOnClose) {
app.dock?.hide() //for mac to hide to tray
}
//for mac users, should hide dock icon if close to tray
app.dock?.hide()
})
mainWindow.on('closed', () => {
@@ -309,44 +340,52 @@ export class WindowService {
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
if (this.mainWindow.isMinimized()) {
return this.mainWindow.restore()
this.mainWindow.restore()
return
}
//[macOS] Known Issue
// setVisibleOnAllWorkspaces true/false will NOT bring window to current desktop in Mac (works fine with Windows)
// AppleScript may be a solution, but it's not worth
this.mainWindow.setVisibleOnAllWorkspaces(true)
this.mainWindow.show()
this.mainWindow.focus()
this.mainWindow.setVisibleOnAllWorkspaces(false)
} else {
this.mainWindow = this.createMainWindow()
this.mainWindow.focus()
}
//for mac users, when window is shown, should show dock icon (dock may be set to hide when launch)
app.dock?.show()
}
public showMiniWindow() {
const enableQuickAssistant = configManager.getEnableQuickAssistant()
if (!enableQuickAssistant) {
public toggleMainWindow() {
// should not toggle main window when in full screen
if (this.wasFullScreen) {
return
}
if (this.selectionMenuWindow && !this.selectionMenuWindow.isDestroyed()) {
this.selectionMenuWindow.hide()
}
if (this.miniWindow && !this.miniWindow.isDestroyed()) {
if (this.miniWindow.isMinimized()) {
this.miniWindow.restore()
if (this.mainWindow && !this.mainWindow.isDestroyed() && this.mainWindow.isVisible()) {
if (this.mainWindow.isFocused()) {
// if tray is enabled, hide the main window, else do nothing
if (configManager.getTray()) {
this.mainWindow.hide()
app.dock?.hide()
}
} else {
this.mainWindow.focus()
}
this.miniWindow.show()
this.miniWindow.center()
this.miniWindow.focus()
return
}
this.showMainWindow()
}
public createMiniWindow(isPreload: boolean = false): BrowserWindow {
this.miniWindow = new BrowserWindow({
width: 500,
height: 520,
show: true,
width: 550,
height: 400,
minWidth: 350,
minHeight: 380,
maxWidth: 1024,
maxHeight: 768,
show: false,
autoHideMenuBar: true,
transparent: isMac,
vibrancy: 'under-window',
@@ -354,8 +393,13 @@ export class WindowService {
center: true,
frame: false,
alwaysOnTop: true,
resizable: false,
resizable: true,
useContentSize: true,
...(isMac ? { type: 'panel' } : {}),
skipTaskbar: true,
minimizable: false,
maximizable: false,
fullscreenable: false,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
@@ -364,8 +408,25 @@ export class WindowService {
}
})
//miniWindow should show in current desktop
this.miniWindow?.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true })
//make miniWindow always on top of fullscreen apps with level set
this.miniWindow.setAlwaysOnTop(true, 'screen-saver', 1)
this.miniWindow.on('ready-to-show', () => {
if (isPreload) {
return
}
this.wasMainWindowFocused = this.mainWindow?.isFocused() || false
this.miniWindow?.center()
this.miniWindow?.show()
})
this.miniWindow.on('blur', () => {
this.miniWindow?.hide()
if (!this.isPinnedMiniWindow) {
this.hideMiniWindow()
}
})
this.miniWindow.on('closed', () => {
@@ -391,9 +452,48 @@ export class WindowService {
hash: '#/mini'
})
}
return this.miniWindow
}
public showMiniWindow() {
const enableQuickAssistant = configManager.getEnableQuickAssistant()
if (!enableQuickAssistant) {
return
}
if (this.selectionMenuWindow && !this.selectionMenuWindow.isDestroyed()) {
this.selectionMenuWindow.hide()
}
if (this.miniWindow && !this.miniWindow.isDestroyed()) {
this.wasMainWindowFocused = this.mainWindow?.isFocused() || false
if (this.miniWindow.isMinimized()) {
this.miniWindow.restore()
}
this.miniWindow.show()
return
}
this.miniWindow = this.createMiniWindow()
}
public hideMiniWindow() {
//hacky-fix:[mac/win] previous window(not self-app) should be focused again after miniWindow hide
if (isWin) {
this.miniWindow?.minimize()
this.miniWindow?.hide()
return
} else if (isMac) {
this.miniWindow?.hide()
if (!this.wasMainWindowFocused) {
app.hide()
}
return
}
this.miniWindow?.hide()
}
@@ -402,11 +502,16 @@ export class WindowService {
}
public toggleMiniWindow() {
if (this.miniWindow) {
this.miniWindow.isVisible() ? this.miniWindow.hide() : this.miniWindow.show()
} else {
this.showMiniWindow()
if (this.miniWindow && !this.miniWindow.isDestroyed() && this.miniWindow.isVisible()) {
this.hideMiniWindow()
return
}
this.showMiniWindow()
}
public setPinMiniWindow(isPinned) {
this.isPinnedMiniWindow = isPinned
}
public showSelectionMenu(bounds: { x: number; y: number }) {

View File

@@ -42,3 +42,13 @@ export function dumpPersistState() {
}
return JSON.stringify(persistState)
}
export const runAsyncFunction = async (fn: () => void) => {
await fn()
}
export function makeSureDirExists(dir: string) {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true })
}
}

View File

@@ -35,12 +35,22 @@ export function runInstallScript(scriptPath: string): Promise<void> {
})
}
export async function getBinaryPath(name: string): Promise<string> {
let cmd = process.platform === 'win32' ? `${name}.exe` : name
export async function getBinaryName(name: string): Promise<string> {
if (process.platform === 'win32') {
return `${name}.exe`
}
return name
}
export async function getBinaryPath(name?: string): Promise<string> {
if (!name) {
return path.join(os.homedir(), '.cherrystudio', 'bin')
}
const binaryName = await getBinaryName(name)
const binariesDir = path.join(os.homedir(), '.cherrystudio', 'bin')
const binariesDirExists = await fs.existsSync(binariesDir)
cmd = binariesDirExists ? path.join(binariesDir, cmd) : name
return cmd
return binariesDirExists ? path.join(binariesDir, binaryName) : binaryName
}
export async function isBinaryExists(name: string): Promise<boolean> {

View File

@@ -1,6 +1,6 @@
import { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
import { ElectronAPI } from '@electron-toolkit/preload'
import type { FileMetadataResponse, ListFilesResponse, UploadFileResponse } from '@google/generative-ai/server'
import { ExtractChunkData } from '@llm-tools/embedjs-interfaces'
import type { MCPServer, MCPTool } from '@renderer/types'
import { AppInfo, FileType, KnowledgeBaseParams, KnowledgeItem, LanguageVarious, WebDavConfig } from '@renderer/types'
import type { LoaderReturn } from '@shared/config/types'
@@ -45,6 +45,8 @@ declare global {
backupToWebdav: (data: string, webdavConfig: WebDavConfig) => Promise<boolean>
restoreFromWebdav: (webdavConfig: WebDavConfig) => Promise<string>
listWebdavFiles: (webdavConfig: WebDavConfig) => Promise<BackupFile[]>
checkConnection: (webdavConfig: WebDavConfig) => Promise<boolean>
createDirectory: (webdavConfig: WebDavConfig, path: string, options?: CreateDirectoryOptions) => Promise<void>
}
file: {
select: (options?: OpenDialogOptions) => Promise<FileType[] | null>
@@ -135,6 +137,7 @@ declare global {
hide: () => Promise<void>
close: () => Promise<void>
toggle: () => Promise<void>
setPin: (isPinned: boolean) => Promise<void>
}
aes: {
encrypt: (text: string, secretKey: string, iv: string) => Promise<{ iv: string; encryptedData: string }>
@@ -144,17 +147,12 @@ declare global {
openExternal: (url: string, options?: OpenExternalOptions) => Promise<void>
}
mcp: {
// servers
listServers: () => Promise<MCPServer[]>
addServer: (server: MCPServer) => Promise<void>
updateServer: (server: MCPServer) => Promise<void>
deleteServer: (serverName: string) => Promise<void>
setServerActive: (name: string, isActive: boolean) => Promise<void>
// tools
listTools: () => Promise<MCPTool[]>
callTool: ({ client, name, args }: { client: string; name: string; args: any }) => Promise<any>
// status
cleanup: () => Promise<void>
removeServer: (server: MCPServer) => Promise<void>
restartServer: (server: MCPServer) => Promise<void>
stopServer: (server: MCPServer) => Promise<void>
listTools: (server: MCPServer) => Promise<MCPTool[]>
callTool: ({ server, name, args }: { server: MCPServer; name: string; args: any }) => Promise<any>
getInstallInfo: () => Promise<{ dir: string; uvPath: string; bunPath: string }>
}
copilot: {
getAuthMessage: (
@@ -170,6 +168,14 @@ declare global {
getBinaryPath: (name: string) => Promise<string>
installUVBinary: () => Promise<void>
installBunBinary: () => Promise<void>
protocol: {
onReceiveData: (callback: (data: { url: string; params: any }) => void) => () => void
}
nutstore: {
getSSOUrl: () => Promise<string>
decryptToken: (token: string) => Promise<{ username: string; access_token: string }>
getDirectoryContents: (token: string, path: string) => Promise<any>
}
}
}
}

View File

@@ -1,7 +1,8 @@
import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
import { electronAPI } from '@electron-toolkit/preload'
import type { ExtractChunkData } from '@llm-tools/embedjs-interfaces'
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 = {
@@ -34,7 +35,10 @@ const api = {
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)
listWebdavFiles: (webdavConfig: WebDavConfig) => ipcRenderer.invoke('backup:listWebdavFiles', webdavConfig),
checkConnection: (webdavConfig: WebDavConfig) => ipcRenderer.invoke('backup:checkConnection', webdavConfig),
createDirectory: (webdavConfig: WebDavConfig, path: string, options?: CreateDirectoryOptions) =>
ipcRenderer.invoke('backup:createDirectory', webdavConfig, path, options)
},
file: {
select: (options?: OpenDialogOptions) => ipcRenderer.invoke('file:select', options),
@@ -108,7 +112,8 @@ const api = {
show: () => ipcRenderer.invoke('miniwindow:show'),
hide: () => ipcRenderer.invoke('miniwindow:hide'),
close: () => ipcRenderer.invoke('miniwindow:close'),
toggle: () => ipcRenderer.invoke('miniwindow:toggle')
toggle: () => ipcRenderer.invoke('miniwindow:toggle'),
setPin: (isPinned: boolean) => ipcRenderer.invoke('miniwindow:set-pin', isPinned)
},
aes: {
encrypt: (text: string, secretKey: string, iv: string) => ipcRenderer.invoke('aes:encrypt', text, secretKey, iv),
@@ -116,15 +121,13 @@ const api = {
ipcRenderer.invoke('aes:decrypt', encryptedData, iv, secretKey)
},
mcp: {
listServers: () => ipcRenderer.invoke('mcp:list-servers'),
addServer: (server: MCPServer) => ipcRenderer.invoke('mcp:add-server', server),
updateServer: (server: MCPServer) => ipcRenderer.invoke('mcp:update-server', server),
deleteServer: (serverName: string) => ipcRenderer.invoke('mcp:delete-server', serverName),
setServerActive: (name: string, isActive: boolean) =>
ipcRenderer.invoke('mcp:set-server-active', { name, isActive }),
listTools: (serverName?: string) => ipcRenderer.invoke('mcp:list-tools', serverName),
callTool: (params: { client: string; name: string; args: any }) => ipcRenderer.invoke('mcp:call-tool', params),
cleanup: () => ipcRenderer.invoke('mcp:cleanup')
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),
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')
},
shell: {
openExternal: shell.openExternal
@@ -143,7 +146,24 @@ const api = {
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')
installBunBinary: () => ipcRenderer.invoke('app:install-bun-binary'),
protocol: {
onReceiveData: (callback: (data: { url: string; params: any }) => void) => {
const listener = (_event: Electron.IpcRendererEvent, data: { url: string; params: any }) => {
callback(data)
}
ipcRenderer.on('protocol-data', listener)
return () => {
ipcRenderer.off('protocol-data', listener)
}
}
},
nutstore: {
getSSOUrl: () => ipcRenderer.invoke('nutstore:get-sso-url'),
decryptToken: (token: string) => ipcRenderer.invoke('nutstore:decrypt-token', token),
getDirectoryContents: (token: string, path: string) =>
ipcRenderer.invoke('nutstore:get-directory-contents', token, path)
}
}
// Use `contextBridge` APIs to expose Electron APIs to
@@ -153,6 +173,11 @@ if (process.contextIsolated) {
try {
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)
})
} catch (error) {
console.error(error)
}

View File

@@ -39,5 +39,4 @@
<script type="module" src="/src/init.ts"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
</html>

View File

@@ -21,7 +21,7 @@ import PaintingsPage from './pages/paintings/PaintingsPage'
import SettingsPage from './pages/settings/SettingsPage'
import TranslatePage from './pages/translate/TranslatePage'
function App(): JSX.Element {
function App(): React.ReactElement {
return (
<Provider store={store}>
<StyleSheetManager>

View File

@@ -0,0 +1,18 @@
@keyframes animation-pulse {
0% {
box-shadow: 0 0 0 0 rgba(var(--pulse-color), 0.5);
}
70% {
box-shadow: 0 0 0 var(--pulse-size) rgba(var(--pulse-color), 0);
}
100% {
box-shadow: 0 0 0 0 rgba(var(--pulse-color), 0);
}
}
// 电磁波扩散效果
.animation-pulse {
--pulse-color: 59, 130, 246;
--pulse-size: 8px;
animation: animation-pulse 1.5s infinite;
}

View File

@@ -192,3 +192,10 @@
}
}
}
.ant-dropdown-menu .ant-dropdown-menu-sub {
max-height: 350px;
width: max-content;
overflow-y: auto;
overflow-x: hidden;
}

View File

@@ -2,6 +2,7 @@
@use './ant.scss';
@use './scrollbar.scss';
@use './container.scss';
@use './animation.scss';
@import '../fonts/icon-fonts/iconfont.css';
@import '../fonts/ubuntu/ubuntu.css';
@@ -18,7 +19,7 @@
--color-gray-2: #414853;
--color-gray-3: #32363f;
--color-text-1: rgba(255, 255, 245, 0.86);
--color-text-1: rgba(255, 255, 245, 0.9);
--color-text-2: rgba(235, 235, 245, 0.6);
--color-text-3: rgba(235, 235, 245, 0.38);

View File

@@ -294,3 +294,11 @@
emoji-picker {
--border-size: 0;
}
.katex-display{
overflow-x: auto;
overflow-y: hidden;
}
mjx-container{
overflow-x: auto;
}

View File

@@ -8,9 +8,10 @@ interface Props {
model: Model
size: number
props?: AvatarProps
className?: string
}
const ModelAvatar: FC<Props> = ({ model, size, props }) => {
const ModelAvatar: FC<Props> = ({ model, size, props, className }) => {
return (
<Avatar
src={getModelLogo(model?.id || '')}
@@ -23,7 +24,8 @@ const ModelAvatar: FC<Props> = ({ model, size, props }) => {
alignItems: 'center',
justifyContent: 'center'
}}
{...props}>
{...props}
className={className}>
{first(model?.name)}
</Avatar>
)

View File

@@ -0,0 +1,43 @@
import { Collapse } from 'antd'
import { FC, memo } from 'react'
interface CustomCollapseProps {
label: React.ReactNode
extra: React.ReactNode
children: React.ReactNode
}
const CustomCollapse: FC<CustomCollapseProps> = ({ label, extra, children }) => {
const CollapseStyle = {
background: 'transparent',
border: '0.5px solid var(--color-border)'
}
const CollapseItemStyles = {
header: {
padding: '8px 16px',
alignItems: 'center',
justifyContent: 'space-between'
},
body: {
borderTop: '0.5px solid var(--color-border)'
}
}
return (
<Collapse
bordered={false}
style={CollapseStyle}
defaultActiveKey={['1']}
items={[
{
styles: CollapseItemStyles,
key: '1',
label,
extra,
children
}
]}
/>
)
}
export default memo(CustomCollapse)

View File

@@ -9,6 +9,7 @@ import {
ResponderProvided
} from '@hello-pangea/dnd'
import { droppableReorder } from '@renderer/utils'
import VirtualList from 'rc-virtual-list'
import { FC } from 'react'
interface Props<T> {
@@ -47,26 +48,28 @@ const DragableList: FC<Props<any>> = ({
<Droppable droppableId="droppable" {...droppableProps}>
{(provided) => (
<div {...provided.droppableProps} ref={provided.innerRef} style={style}>
{list.map((item, index) => {
const id = item.id || item
return (
<Draggable key={`draggable_${id}_${index}`} draggableId={id} index={index}>
{(provided) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
style={{
...listStyle,
...provided.draggableProps.style,
marginBottom: 8
}}>
{children(item, index)}
</div>
)}
</Draggable>
)
})}
<VirtualList data={list} itemKey="id">
{(item, index) => {
const id = item.id || item
return (
<Draggable key={`draggable_${id}_${index}`} draggableId={id} index={index}>
{(provided) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
style={{
...listStyle,
...provided.draggableProps.style,
marginBottom: 8
}}>
{children(item, index)}
</div>
)}
</Draggable>
)
}}
</VirtualList>
{provided.placeholder}
</div>
)}

View File

@@ -0,0 +1,50 @@
import styled from 'styled-components'
const IconSpan = styled.span`
display: flex;
justify-content: center;
align-items: center;
height: 100%;
`
export function NutstoreIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<IconSpan>
<svg
{...props}
width="16px"
height="16px"
viewBox="0 0 20 20"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink">
<title>线</title>
<g id="线性单坚果" stroke="none" strokeWidth="1" fill="none" fillRule="evenodd">
<path
d="M10.1590439,0.886175571 C10.1753674,0.890326544 10.291709,0.910777855 10.428428,0.935202765 L10.6388345,0.973279488 C10.7074276,0.985937901 10.77116,0.998048871 10.8200766,1.00807156 C11.2437905,1.09488771 11.6662387,1.21011472 12.1133986,1.37210166 C13.2580363,1.78675499 14.3714894,2.43940777 15.4224927,3.39703693 L15.621,3.584 L15.6351722,3.57092562 C16.53166,2.76294504 17.6751418,2.31986999 18.4291849,2.58060734 L18.5580792,2.63399481 C18.9455012,2.81584984 19.2328582,3.16284846 19.437028,3.61729231 C19.5709871,3.91546021 19.6526725,4.21929758 19.6985752,4.50662941 C19.7148596,4.80478115 19.5904581,5.0358501 19.4098118,5.1582622 C19.3815042,5.17858714 19.3523426,5.19648783 19.3224017,5.21197531 C19.1152073,5.31915066 18.9086763,5.30466603 18.6939183,5.22086872 C18.6620576,5.20843687 18.6328325,5.19564599 18.6006654,5.18105502 C18.4394695,5.11546938 18.2846309,5.06753532 18.1365915,5.04232952 C17.7415971,4.96197402 17.3578102,5.06378907 17.051656,5.32621284 L17.046624,5.33098744 L17.1856424,5.55157847 C18.0964209,7.0577136 18.6880009,8.98631362 18.5914984,10.988329 L18.5672508,11.3423168 C18.518886,12.3590196 18.336046,13.2889191 17.9959883,14.1391815 C17.4227031,15.6418626 16.5311196,16.5912538 15.4105898,16.2529712 L15.278,16.207 C15.204042,16.2889459 15.1247235,16.3618831 15.0410669,16.4278107 L14.9126231,16.5212291 C13.2906651,17.9150353 10.9315401,19.0281897 7.99389616,19.2 L7.17106258,19.2 C3.43360072,19.2 1.02132454,17.63803 0.534391412,16.0333683 L0.513,15.954 L0.504265285,15.9449232 C-0.110228462,15.1972878 0.264421351,10.4760569 2.09599684,6.99794495 L2.22026541,6.76796973 C2.29571954,6.63016882 2.43695112,6.39220857 2.63659846,6.08729923 C2.9688861,5.57981633 3.34471126,5.07232148 3.75709487,4.59788661 C4.2749895,4.0020645 4.81413532,3.50121679 5.3386949,3.15177019 C5.36355777,3.12648036 5.4278064,3.07827062 5.50910569,3.02364741 L5.559,2.991 L5.5530361,2.96941337 C5.48899059,2.69876461 5.47862138,2.4784725 5.54146387,2.2521942 L5.58811106,2.11525813 C5.68308256,1.86409186 5.94349142,1.57994703 6.25873284,1.38755406 C6.58654657,1.18748816 7.23187921,0.95895859 7.69473739,0.883035787 C8.37505518,0.763266442 9.38159553,0.78076773 10.1590439,0.886175571 Z M6.59801776,3.85068129 C6.46732353,3.85068129 6.2240354,3.97828097 6.07844768,4.1001814 C5.59811888,4.42589962 5.12194443,4.87010868 4.65860433,5.40361803 C4.52372819,5.55892011 4.37448327,5.74624534 4.22515758,5.94252901 L4.04684241,6.18089332 C3.57610889,6.82012555 3.16307203,7.45661922 3.27592159,7.33459023 C1.39280393,10.7336939 1.18786427,14.1190682 1.66513528,15.5784041 C1.72944314,15.8645824 2.24255786,16.4352772 2.98506717,16.8902532 C4.03558482,17.5339627 5.43381914,17.9303112 7.15636912,17.9630362 L7.95282724,17.9633776 C10.5671194,17.8104156 12.6011819,16.8513512 14.1270746,15.5866906 L14.2005419,15.5269075 L14.2189125,15.5136158 C14.591184,15.2751975 14.6855045,14.9945722 14.5299888,14.3127204 C14.1480256,12.8500475 13.2023047,10.9705228 11.4802274,8.76564869 C10.6761315,7.73569508 9.84271439,6.77270459 8.9812637,5.88185595 C8.26651717,5.13999817 7.48191474,4.46126051 6.65303256,3.86947602 C6.6343697,3.85523851 6.62003281,3.85068129 6.59801776,3.85068129 Z M8.0520431,2.14478343 C7.34750556,2.24716005 6.81392621,2.48276912 6.75769294,2.58286729 C6.75315545,2.59094425 6.75172186,2.59912409 6.75788522,2.63367631 L6.761,2.653 C6.92447955,2.67441039 7.07755879,2.72514333 7.22081781,2.80306173 L7.36053304,2.88992896 C8.25106173,3.52400396 9.08393795,4.2496146 9.84209216,5.05104835 C10.7498631,5.98954517 11.620838,6.99715009 12.4127624,8.02643665 C14.2357617,10.3660968 15.255676,12.4067536 15.6810213,14.0171728 C15.7810435,14.3986973 15.8140553,14.7531702 15.7838468,15.0855202 L15.779624,15.1139874 L15.7923351,15.1170186 C16.0195271,15.1453183 16.2337261,14.9383655 16.4514,14.5090146 L16.5168229,14.3735502 C16.5998938,14.1934825 16.8522658,13.5389313 16.8131724,13.6336744 L16.800624,13.6629874 L16.8933423,13.4088509 C17.1021765,12.7846983 17.2487406,12.0003637 17.2861365,11.2776414 C17.4525549,9.34169753 16.8847303,7.51332101 15.9618076,5.9792161 C15.8725231,5.8278532 15.7620551,5.66138642 15.6942132,5.57820575 C14.7595226,4.31701776 13.5999579,3.42705248 12.3136888,2.84260842 C11.4827868,2.46507019 10.794487,2.2853603 10.1559862,2.18983638 C9.43796126,2.09113972 8.59553714,2.05880421 8.0520431,2.14478343 Z M16.4823653,4.32067121 L16.364,4.418 L16.393,4.454 L16.5100007,4.3621392 C17.0306065,3.97118443 17.6106194,3.7900296 18.1665334,3.88918284 L18.233,3.904 L18.2063581,3.87419362 C18.1376794,3.79892884 18.0675642,3.72412847 18.0165076,3.68190508 L17.972563,3.65173005 C17.800955,3.56958653 17.0606024,3.86572493 16.4823653,4.32067121 Z"
id="形状结合"
fill="currentColor"
fillRule="nonzero"></path>
</g>
</svg>
</IconSpan>
)
}
export function FolderIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<IconSpan>
<svg width="16px" height="16px" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" {...props}>
<title>folder</title>
<path
d="M396.5,185.7l22.7,27.2a36.1,36.1,0,0,0,27.7,12.7H906.8c29.4,0,53.2,22.8,53.2,50.9V800.1c0,28.1-23.8,50.9-53.2,50.9H117.2C87.8,851,64,828.2,64,800.1V223.9c0-28.1,23.8-50.9,53.2-50.9H368.8A36.1,36.1,0,0,1,396.5,185.7Z"
style={{ fill: '#9fddff' }}
/>
<path
d="M64,342.5V797.8c0,29.4,24,53.2,53.6,53.2H906.4c29.6,0,53.6-23.8,53.6-53.2V342.5Z"
style={{ fill: '#74c6ff' }}
/>
</svg>
</IconSpan>
)
}

View File

@@ -23,7 +23,7 @@ const Container = styled.div`
`
const Icon = styled(ToolOutlined)`
color: #d97757;
color: var(--color-primary);
font-size: 15px;
margin-right: 6px;
`

View File

@@ -4,15 +4,25 @@ import styled from 'styled-components'
interface IndicatorLightProps {
color: string
size?: number
shadow?: boolean
style?: React.CSSProperties
animation?: boolean
}
const Light = styled.div<{ color: string }>`
width: 8px;
height: 8px;
const Light = styled.div<{
color: string
size: number
shadow?: boolean
style?: React.CSSProperties
animation?: boolean
}>`
width: ${({ size }) => size}px;
height: ${({ size }) => size}px;
border-radius: 50%;
background-color: ${({ color }) => color};
box-shadow: 0 0 6px ${({ color }) => color};
animation: pulse 2s infinite;
box-shadow: ${({ shadow, color }) => (shadow ? `0 0 6px ${color}` : 'none')};
animation: ${({ animation }) => (animation ? 'pulse 2s infinite' : 'none')};
@keyframes pulse {
0% {
@@ -27,9 +37,9 @@ const Light = styled.div<{ color: string }>`
}
`
const IndicatorLight: React.FC<IndicatorLightProps> = ({ color }) => {
const IndicatorLight: React.FC<IndicatorLightProps> = ({ color, size = 8, shadow = true, style, animation = true }) => {
const actualColor = color === 'green' ? '#22c55e' : color
return <Light color={actualColor} />
return <Light color={actualColor} size={size} shadow={shadow} style={style} animation={animation} />
}
export default IndicatorLight

View File

@@ -8,17 +8,20 @@ interface ListItemProps {
subtitle?: string
titleStyle?: React.CSSProperties
onClick?: () => void
rightContent?: ReactNode
style?: React.CSSProperties
}
const ListItem = ({ active, icon, title, subtitle, titleStyle, onClick }: ListItemProps) => {
const ListItem = ({ active, icon, title, subtitle, titleStyle, onClick, rightContent, style }: ListItemProps) => {
return (
<ListItemContainer className={active ? 'active' : ''} onClick={onClick}>
<ListItemContainer className={active ? 'active' : ''} onClick={onClick} style={style}>
<ListItemContent>
{icon && <IconWrapper>{icon}</IconWrapper>}
<TextContainer>
<TitleText style={titleStyle}>{title}</TitleText>
{subtitle && <SubtitleText>{subtitle}</SubtitleText>}
</TextContainer>
{rightContent && <RightContentWrapper>{rightContent}</RightContentWrapper>}
</ListItemContent>
</ListItemContainer>
)
@@ -84,4 +87,8 @@ const SubtitleText = styled.div`
color: var(--color-text-3);
`
const RightContentWrapper = styled.div`
margin-left: auto;
`
export default ListItem

View File

@@ -0,0 +1,429 @@
import {
CloseOutlined,
CodeOutlined,
CopyOutlined,
ExportOutlined,
MinusOutlined,
PushpinOutlined,
ReloadOutlined
} from '@ant-design/icons'
import { isMac, isWindows } from '@renderer/config/constant'
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
import { useBridge } from '@renderer/hooks/useBridge'
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
import { useMinapps } from '@renderer/hooks/useMinapps'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { MinAppType } from '@renderer/types'
import { delay } from '@renderer/utils'
import { Avatar, Drawer, Tooltip } from 'antd'
import { WebviewTag } from 'electron'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import BeatLoader from 'react-spinners/BeatLoader'
import styled from 'styled-components'
import WebviewContainer from './WebviewContainer'
interface AppExtraInfo {
canPinned: boolean
isPinned: boolean
canOpenExternalLink: boolean
}
type AppInfo = MinAppType & AppExtraInfo
/** The main container for MinApp popup */
const MinappPopupContainer: React.FC = () => {
const { openedKeepAliveMinapps, openedOneOffMinapp, currentMinappId, minappShow } = useRuntime()
const { closeMinapp, hideMinappPopup } = useMinappPopup()
const { pinned, updatePinnedMinapps } = useMinapps()
const { t } = useTranslation()
/** control the drawer open or close */
const [isPopupShow, setIsPopupShow] = useState(true)
/** whether the current minapp is ready */
const [isReady, setIsReady] = useState(false)
/** the current REAL url of the minapp
* different from the app preset url, because user may navigate in minapp */
const [currentUrl, setCurrentUrl] = useState<string | null>(null)
/** store the last minapp id and show status */
const lastMinappId = useRef<string | null>(null)
const lastMinappShow = useRef<boolean>(false)
/** store the webview refs, one of the key to make them keepalive */
const webviewRefs = useRef<Map<string, WebviewTag | null>>(new Map())
/** indicate whether the webview has loaded */
const webviewLoadedRefs = useRef<Map<string, boolean>>(new Map())
const isInDevelopment = process.env.NODE_ENV === 'development'
useBridge()
/** set the popup display status */
useEffect(() => {
if (minappShow) {
// init the current url
if (currentMinappId && currentAppInfo) {
setCurrentUrl(currentAppInfo.url)
}
setIsPopupShow(true)
if (webviewLoadedRefs.current.get(currentMinappId)) {
setIsReady(true)
/** the case that open the minapp from sidebar */
} else if (lastMinappId.current !== currentMinappId && lastMinappShow.current === minappShow) {
setIsReady(false)
}
} else {
setIsPopupShow(false)
setIsReady(false)
}
return () => {
/** renew the last minapp id and show status */
lastMinappId.current = currentMinappId
lastMinappShow.current = minappShow
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [minappShow, currentMinappId])
useEffect(() => {
if (!webviewRefs.current) return
/** set the webview display status
* DO NOT use the state to set the display status,
* to AVOID the re-render of the webview container
*/
webviewRefs.current.forEach((webviewRef, appid) => {
if (!webviewRef) return
webviewRef.style.display = appid === currentMinappId ? 'inline-flex' : 'none'
})
//delete the extra webviewLoadedRefs
webviewLoadedRefs.current.forEach((_, appid) => {
if (!webviewRefs.current.has(appid)) {
webviewLoadedRefs.current.delete(appid)
}
})
}, [currentMinappId])
/** only the keepalive minapp can be minimized */
const canMinimize = !(openedOneOffMinapp && openedOneOffMinapp.id == currentMinappId)
/** combine the openedKeepAliveMinapps and openedOneOffMinapp */
const combinedApps = useMemo(() => {
return [...openedKeepAliveMinapps, ...(openedOneOffMinapp ? [openedOneOffMinapp] : [])]
}, [openedKeepAliveMinapps, openedOneOffMinapp])
/** get the extra info of the apps */
const appsExtraInfo = useMemo(() => {
return combinedApps.reduce(
(acc, app) => ({
...acc,
[app.id]: {
canPinned: DEFAULT_MIN_APPS.some((item) => item.id === app.id),
isPinned: pinned.some((item) => item.id === app.id),
canOpenExternalLink: app.url.startsWith('http://') || app.url.startsWith('https://')
}
}),
{} as Record<string, AppExtraInfo>
)
}, [combinedApps, pinned])
/** get the current app info with extra info */
let currentAppInfo: AppInfo | null = null
if (currentMinappId) {
const currentApp = combinedApps.find((item) => item.id === currentMinappId) as MinAppType
currentAppInfo = { ...currentApp, ...appsExtraInfo[currentApp.id] }
}
/** will close the popup and delete the webview */
const handlePopupClose = async (appid: string) => {
setIsPopupShow(false)
await delay(0.3)
webviewLoadedRefs.current.delete(appid)
closeMinapp(appid)
}
/** will hide the popup and remain the webviews */
const handlePopupMinimize = async () => {
setIsPopupShow(false)
await delay(0.3)
hideMinappPopup()
}
/** the callback function to set the webviews ref */
const handleWebviewSetRef = (appid: string, element: WebviewTag | null) => {
webviewRefs.current.set(appid, element)
if (!webviewRefs.current.has(appid)) {
webviewRefs.current.set(appid, null)
return
}
if (element) {
webviewRefs.current.set(appid, element)
} else {
webviewRefs.current.delete(appid)
}
}
/** the callback function to set the webviews loaded indicator */
const handleWebviewLoaded = (appid: string) => {
webviewLoadedRefs.current.set(appid, true)
if (appid == currentMinappId) {
setTimeout(() => setIsReady(true), 200)
}
}
/** the callback function to handle the webview navigate to new url */
const handleWebviewNavigate = (appid: string, url: string) => {
if (appid === currentMinappId) {
setCurrentUrl(url)
}
}
/** will open the devtools of the minapp */
const handleOpenDevTools = (appid: string) => {
const webview = webviewRefs.current.get(appid)
if (webview) {
webview.openDevTools()
}
}
/** only reload the original url */
const handleReload = (appid: string) => {
const webview = webviewRefs.current.get(appid)
if (webview) {
const url = combinedApps.find((item) => item.id === appid)?.url
if (url) {
webview.src = url
}
}
}
/** open the giving url in browser */
const handleOpenLink = (url: string) => {
window.api.openWebsite(url)
}
/** toggle the pin status of the minapp */
const handleTogglePin = (appid: string) => {
const app = combinedApps.find((item) => item.id === appid)
if (!app) return
const newPinned = appsExtraInfo[appid].isPinned ? pinned.filter((item) => item.id !== appid) : [...pinned, app]
updatePinnedMinapps(newPinned)
}
/** Title bar of the popup */
const Title = ({ appInfo, url }: { appInfo: AppInfo | null; url: string | null }) => {
if (!appInfo) return null
const handleCopyUrl = (event: any, url: string) => {
//don't show app-wide context menu
event.preventDefault()
navigator.clipboard
.writeText(url)
.then(() => {
window.message.success('URL ' + t('message.copy.success'))
})
.catch(() => {
window.message.error('URL ' + t('message.copy.failed'))
})
}
return (
<TitleContainer style={{ justifyContent: 'space-between' }}>
<Tooltip
title={
<TitleTextTooltip>
{url ?? appInfo.url} <br />
<CopyOutlined className="icon-copy" />
{t('minapp.popup.rightclick_copyurl')}
</TitleTextTooltip>
}
mouseEnterDelay={0.8}
placement="rightBottom"
styles={{
root: {
maxWidth: '400px'
}
}}>
<TitleText onContextMenu={(e) => handleCopyUrl(e, url ?? appInfo.url)}>{appInfo.name}</TitleText>
</Tooltip>
<ButtonsGroup className={isWindows ? 'windows' : ''}>
<Tooltip title={t('minapp.popup.refresh')} mouseEnterDelay={0.8} placement="bottom">
<Button onClick={() => handleReload(appInfo.id)}>
<ReloadOutlined />
</Button>
</Tooltip>
{appInfo.canPinned && (
<Tooltip
title={appInfo.isPinned ? t('minapp.sidebar.remove.title') : t('minapp.sidebar.add.title')}
mouseEnterDelay={0.8}
placement="bottom">
<Button onClick={() => handleTogglePin(appInfo.id)} className={appInfo.isPinned ? 'pinned' : ''}>
<PushpinOutlined style={{ fontSize: 16 }} />
</Button>
</Tooltip>
)}
{appInfo.canOpenExternalLink && (
<Tooltip title={t('minapp.popup.openExternal')} mouseEnterDelay={0.8} placement="bottom">
<Button onClick={() => handleOpenLink(url ?? appInfo.url)}>
<ExportOutlined />
</Button>
</Tooltip>
)}
{isInDevelopment && (
<Tooltip title={t('minapp.popup.devtools')} mouseEnterDelay={0.8} placement="bottom">
<Button onClick={() => handleOpenDevTools(appInfo.id)}>
<CodeOutlined />
</Button>
</Tooltip>
)}
{canMinimize && (
<Tooltip title={t('minapp.popup.minimize')} mouseEnterDelay={0.8} placement="bottom">
<Button onClick={() => handlePopupMinimize()}>
<MinusOutlined />
</Button>
</Tooltip>
)}
<Tooltip title={t('minapp.popup.close')} mouseEnterDelay={0.8} placement="bottom">
<Button onClick={() => handlePopupClose(appInfo.id)}>
<CloseOutlined />
</Button>
</Tooltip>
</ButtonsGroup>
</TitleContainer>
)
}
/** group the webview containers with Memo, one of the key to make them keepalive */
const WebviewContainerGroup = useMemo(() => {
return combinedApps.map((app) => (
<WebviewContainer
key={app.id}
appid={app.id}
url={app.url}
onSetRefCallback={handleWebviewSetRef}
onLoadedCallback={handleWebviewLoaded}
onNavigateCallback={handleWebviewNavigate}
/>
))
// because the combinedApps is enough
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [combinedApps])
return (
<Drawer
title={<Title appInfo={currentAppInfo} url={currentUrl} />}
placement="bottom"
onClose={handlePopupMinimize}
open={isPopupShow}
destroyOnClose={false}
mask={false}
rootClassName="minapp-drawer"
maskClassName="minapp-mask"
height={'100%'}
maskClosable={false}
closeIcon={null}
style={{ marginLeft: 'var(--sidebar-width)' }}>
{!isReady && (
<EmptyView>
<Avatar
src={currentAppInfo?.logo}
size={80}
style={{ border: '1px solid var(--color-border)', marginTop: -150 }}
/>
<BeatLoader color="var(--color-text-2)" size="10px" style={{ marginTop: 15 }} />
</EmptyView>
)}
{WebviewContainerGroup}
</Drawer>
)
}
const TitleContainer = styled.div`
display: flex;
flex-direction: row;
align-items: center;
padding-left: ${isMac ? '20px' : '10px'};
padding-right: 10px;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: transparent;
`
const TitleText = styled.div`
font-weight: bold;
font-size: 14px;
color: var(--color-text-1);
margin-right: 10px;
-webkit-app-region: no-drag;
`
const TitleTextTooltip = styled.span`
font-size: 0.8rem;
.icon-copy {
font-size: 0.7rem;
padding-right: 5px;
}
`
const ButtonsGroup = styled.div`
display: flex;
flex-direction: row;
align-items: center;
gap: 5px;
-webkit-app-region: no-drag;
&.windows {
margin-right: ${isWindows ? '130px' : 0};
background-color: var(--color-background-mute);
border-radius: 50px;
padding: 0 3px;
overflow: hidden;
}
`
const Button = styled.div`
cursor: pointer;
width: 30px;
height: 30px;
border-radius: 5px;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
color: var(--color-text-2);
transition: all 0.2s ease;
font-size: 14px;
&:hover {
color: var(--color-text-1);
background-color: var(--color-background-mute);
}
&.pinned {
color: var(--color-primary);
background-color: var(--color-primary-bg);
}
`
const EmptyView = styled.div`
display: flex;
flex: 1;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
background-color: var(--color-background);
`
export default MinappPopupContainer

View File

@@ -0,0 +1,11 @@
import MinappPopupContainer from '@renderer/components/MinApp/MinappPopupContainer'
import { useRuntime } from '@renderer/hooks/useRuntime'
const TopViewMinappContainer = () => {
const { openedKeepAliveMinapps, openedOneOffMinapp } = useRuntime()
const isCreate = openedKeepAliveMinapps.length > 0 || openedOneOffMinapp !== null
return <>{isCreate && <MinappPopupContainer />}</>
}
export default TopViewMinappContainer

View File

@@ -0,0 +1,92 @@
import { WebviewTag } from 'electron'
import { memo, useEffect, useRef } from 'react'
/**
* WebviewContainer is a component that renders a webview element.
* It is used in the MinAppPopupContainer component.
* The webcontent can be remain in memory
*/
const WebviewContainer = memo(
({
appid,
url,
onSetRefCallback,
onLoadedCallback,
onNavigateCallback
}: {
appid: string
url: string
onSetRefCallback: (appid: string, element: WebviewTag | null) => void
onLoadedCallback: (appid: string) => void
onNavigateCallback: (appid: string, url: string) => void
}) => {
const webviewRef = useRef<WebviewTag | null>(null)
const setRef = (appid: string) => {
onSetRefCallback(appid, null)
return (element: WebviewTag | null) => {
onSetRefCallback(appid, element)
if (element) {
webviewRef.current = element
} else {
webviewRef.current = null
}
}
}
useEffect(() => {
if (!webviewRef.current) return
const handleNewWindow = (event: any) => {
event.preventDefault()
if (webviewRef.current?.loadURL) {
webviewRef.current.loadURL(event.url)
}
}
const handleLoaded = () => {
onLoadedCallback(appid)
}
const handleNavigate = (event: any) => {
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)
// we set the url when the webview is ready
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)
}
// because the appid and url are enough, no need to add onLoadedCallback
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appid, url])
return (
<webview
key={appid}
ref={setRef(appid)}
style={WebviewStyle}
allowpopups={'true' as any}
partition="persist:webview"
nodeintegration={'true' as any}
/>
)
}
)
const WebviewStyle: React.CSSProperties = {
width: 'calc(100vw - var(--sidebar-width))',
height: 'calc(100vh - var(--navbar-height))',
backgroundColor: 'white',
display: 'inline-flex'
}
export default WebviewContainer

View File

@@ -1,282 +0,0 @@
import { CloseOutlined, CodeOutlined, ExportOutlined, PushpinOutlined, ReloadOutlined } from '@ant-design/icons'
import { isMac, isWindows } from '@renderer/config/constant'
import { AppLogo } from '@renderer/config/env'
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
import { useBridge } from '@renderer/hooks/useBridge'
import { useMinapps } from '@renderer/hooks/useMinapps'
import store from '@renderer/store'
import { setMinappShow } from '@renderer/store/runtime'
import { MinAppType } from '@renderer/types'
import { delay } from '@renderer/utils'
import { Avatar, Drawer } from 'antd'
import { WebviewTag } from 'electron'
import { useEffect, useRef, useState } from 'react'
import BeatLoader from 'react-spinners/BeatLoader'
import styled from 'styled-components'
import { TopView } from '../TopView'
interface Props {
app: MinAppType
resolve: (data: any) => void
}
const PopupContainer: React.FC<Props> = ({ app, resolve }) => {
const { pinned, updatePinnedMinapps } = useMinapps()
const isPinned = pinned.some((p) => p.id === app.id)
const [open, setOpen] = useState(true)
const [opened, setOpened] = useState(false)
const [isReady, setIsReady] = useState(false)
const webviewRef = useRef<WebviewTag | null>(null)
useBridge()
const canOpenExternalLink = app.url.startsWith('http://') || app.url.startsWith('https://')
const canPinned = DEFAULT_MIN_APPS.some((i) => i.id === app?.id)
const onClose = async (_delay = 0.3) => {
setOpen(false)
await delay(_delay)
resolve({})
}
MinApp.onClose = onClose
const openDevTools = () => {
if (webviewRef.current) {
webviewRef.current.openDevTools()
}
}
const onReload = () => {
if (webviewRef.current) {
webviewRef.current.src = app.url
}
}
const onOpenLink = () => {
if (webviewRef.current) {
const currentUrl = webviewRef.current.getURL()
window.api.openWebsite(currentUrl)
}
}
const onTogglePin = () => {
const newPinned = isPinned ? pinned.filter((item) => item.id !== app.id) : [...pinned, app]
updatePinnedMinapps(newPinned)
}
const isInDevelopment = process.env.NODE_ENV === 'development'
const Title = () => {
return (
<TitleContainer style={{ justifyContent: 'space-between' }}>
<TitleText>{app.name}</TitleText>
<ButtonsGroup className={isWindows ? 'windows' : ''}>
<Button onClick={onReload}>
<ReloadOutlined />
</Button>
{canPinned && (
<Button onClick={onTogglePin} className={isPinned ? 'pinned' : ''}>
<PushpinOutlined style={{ fontSize: 16 }} />
</Button>
)}
{canOpenExternalLink && (
<Button onClick={onOpenLink}>
<ExportOutlined />
</Button>
)}
{isInDevelopment && (
<Button onClick={openDevTools}>
<CodeOutlined />
</Button>
)}
<Button onClick={() => onClose()}>
<CloseOutlined />
</Button>
</ButtonsGroup>
</TitleContainer>
)
}
useEffect(() => {
const webview = webviewRef.current
if (webview) {
const handleNewWindow = (event: any) => {
event.preventDefault()
if (webview.loadURL) {
webview.loadURL(event.url)
}
}
const onLoaded = () => setIsReady(true)
webview.addEventListener('new-window', handleNewWindow)
webview.addEventListener('did-finish-load', onLoaded)
return () => {
webview.removeEventListener('new-window', handleNewWindow)
webview.removeEventListener('did-finish-load', onLoaded)
}
}
return () => {}
}, [opened])
useEffect(() => {
setTimeout(() => setOpened(true), 350)
}, [])
return (
<Drawer
title={<Title />}
placement="bottom"
onClose={() => onClose()}
open={open}
mask={true}
rootClassName="minapp-drawer"
maskClassName="minapp-mask"
height={'100%'}
maskClosable={false}
closeIcon={null}
style={{ marginLeft: 'var(--sidebar-width)' }}>
{!isReady && (
<EmptyView>
<Avatar src={app.logo} size={80} style={{ border: '1px solid var(--color-border)', marginTop: -150 }} />
<BeatLoader color="var(--color-text-2)" size="10" style={{ marginTop: 15 }} />
</EmptyView>
)}
{opened && (
<webview
src={app.url}
ref={webviewRef}
style={WebviewStyle}
allowpopups={'true' as any}
partition="persist:webview"
nodeintegration={true}
/>
)}
</Drawer>
)
}
const WebviewStyle: React.CSSProperties = {
width: 'calc(100vw - var(--sidebar-width))',
height: 'calc(100vh - var(--navbar-height))',
backgroundColor: 'white',
display: 'inline-flex'
}
const TitleContainer = styled.div`
display: flex;
flex-direction: row;
align-items: center;
padding-left: ${isMac ? '20px' : '10px'};
padding-right: 10px;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: transparent;
`
const TitleText = styled.div`
font-weight: bold;
font-size: 14px;
color: var(--color-text-1);
margin-right: 10px;
user-select: none;
`
const ButtonsGroup = styled.div`
display: flex;
flex-direction: row;
align-items: center;
gap: 5px;
-webkit-app-region: no-drag;
&.windows {
margin-right: ${isWindows ? '130px' : 0};
background-color: var(--color-background-mute);
border-radius: 50px;
padding: 0 3px;
overflow: hidden;
}
`
const Button = styled.div`
cursor: pointer;
width: 30px;
height: 30px;
border-radius: 5px;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
color: var(--color-text-2);
transition: all 0.2s ease;
font-size: 14px;
&:hover {
color: var(--color-text-1);
background-color: var(--color-background-mute);
}
&.pinned {
color: var(--color-primary);
background-color: var(--color-primary-bg);
}
`
const EmptyView = styled.div`
display: flex;
flex: 1;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
background-color: var(--color-background);
`
export default class MinApp {
static topviewId = 0
static onClose = () => {}
static app: MinAppType | null = null
static async start(app: MinAppType) {
if (app?.id && MinApp.app?.id === app?.id) {
return
}
if (MinApp.app) {
// @ts-ignore delay params
await MinApp.onClose(0)
await delay(0)
}
if (!app.logo) {
app.logo = AppLogo
}
MinApp.app = app
store.dispatch(setMinappShow(true))
return new Promise<any>((resolve) => {
TopView.show(
<PopupContainer
app={app}
resolve={(v) => {
resolve(v)
this.close()
}}
/>,
'MinApp'
)
})
}
static close() {
TopView.hide('MinApp')
store.dispatch(setMinappShow(false))
MinApp.app = null
MinApp.onClose = () => {}
}
}

View File

@@ -0,0 +1,250 @@
import { FolderIcon as NutstoreFolderIcon } from '@renderer/components/Icons/NutstoreIcons'
import { Button, Input } from 'antd'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import { HStack } from './Layout'
interface NewFolderProps {
onConfirm: (name: string) => void
onCancel: () => void
className?: string
}
const NewFolderContainer = styled.div`
display: flex;
align-items: center;
gap: 8px;
padding: 0 4px;
`
const FolderIcon = styled(NutstoreFolderIcon)`
width: 40px;
height: 40px;
`
function NewFolder(props: NewFolderProps) {
const { onConfirm, onCancel } = props
const [name, setName] = useState('')
const { t } = useTranslation()
return (
<NewFolderContainer>
<FolderIcon className={props.className}></FolderIcon>
<Input type="text" style={{ flex: 1 }} autoFocus value={name} onChange={(e) => setName(e.target.value)} />
<Button type="primary" size="small" onClick={() => onConfirm(name)}>
{t('settings.data.nutstore.new_folder.button.confirm')}
</Button>
<Button type="default" size="small" onClick={() => onCancel()}>
{t('settings.data.nutstore.new_folder.button.cancel')}
</Button>
</NewFolderContainer>
)
}
interface FolderProps {
name: string
path: string
onClick: (path: string) => void
}
const FolderContainer = styled.div`
display: flex;
gap: 8px;
align-items: center;
max-width: 100%;
padding: 0 4px;
&:hover {
background-color: var(--color-background-soft);
}
.nutstore-pathname {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
`
function Folder(props: FolderProps) {
return (
<FolderContainer onClick={() => props.onClick(props.path)}>
<FolderIcon></FolderIcon>
<span className="nutstore-pathname">{props.name}</span>
</FolderContainer>
)
}
interface FileListProps {
path: string
fs: Nutstore.Fs
onClick: (file: Nutstore.FileStat) => void
}
function FileList(props: FileListProps) {
const [files, setFiles] = useState<Nutstore.FileStat[]>([])
const folders = files.filter((file) => file.isDir).sort((a, b) => a.basename.localeCompare(b.basename, ['zh']))
useEffect(() => {
async function fetchFiles() {
try {
const items = await props.fs.ls(props.path)
setFiles(items)
} catch (error) {
if (error instanceof Error) {
console.error(error)
window.modal.error({
content: error.message,
centered: true
})
}
}
}
fetchFiles()
}, [props.path, props.fs])
return (
<>
{folders.map((folder) => (
<Folder key={folder.path} name={folder.basename} path={folder.path} onClick={() => props.onClick(folder)} />
))}
</>
)
}
const SingleFileListContainer = styled.div`
height: 300px;
overflow: hidden;
.scroll-container {
height: 100%;
display: flex;
flex-direction: column;
overflow-y: auto;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.new-folder {
margin-top: 4px;
}
`
const Container = styled.div`
display: flex;
flex-direction: column;
gap: 16px;
.nutstore-current-path-container {
display: flex;
align-items: center;
gap: 8px;
.nutstore-current-path {
word-break: break-all;
}
}
.nutstore-path-operater {
display: flex;
align-items: center;
gap: 8px;
}
`
interface Props {
fs: Nutstore.Fs
onConfirm: (path: string) => void
onCancel: () => void
}
export function NutstorePathSelector(props: Props) {
const { t } = useTranslation()
const [stack, setStack] = useState<string[]>(['/'])
const [showNewFolder, setShowNewFolder] = useState(false)
const cwd = stack.at(-1)
const enter = useCallback((path: string) => {
setStack((prev) => [...prev, path])
}, [])
const pop = useCallback(() => {
setStack((prev) => (prev.length > 1 ? prev.slice(0, -1) : prev))
}, [])
const handleNewFolder = useCallback(
async (name: string) => {
const target = (cwd ?? '/') + (cwd && cwd !== '/' ? '/' : '') + name
await props.fs.mkdirs(target)
setShowNewFolder(false)
enter(target)
},
[cwd, props.fs, enter]
)
return (
<>
<Container>
<SingleFileListContainer>
<div className="scroll-container">
{showNewFolder && (
<NewFolder className="new-folder" onConfirm={handleNewFolder} onCancel={() => setShowNewFolder(false)} />
)}
<FileList path={cwd ?? ''} fs={props.fs} onClick={(f) => enter(f.path)} />
</div>
</SingleFileListContainer>
<div className="nutstore-current-path-container">
<span>{t('settings.data.nutstore.pathSelector.currentPath')}</span>
<span className="nutstore-current-path">{cwd ?? '/'}</span>
</div>
</Container>
<NustorePathSelectorFooter
returnPrev={pop}
mkdir={() => setShowNewFolder(true)}
cancel={props.onCancel}
confirm={() => props.onConfirm(cwd ?? '')}
/>
</>
)
}
const FooterContainer = styled(HStack)`
background: transparent;
margin-top: 12px;
padding: 0;
border-top: none;
border-radius: 0;
`
interface FooterProps {
returnPrev: () => void
mkdir: () => void
cancel: () => void
confirm: () => void
}
export function NustorePathSelectorFooter(props: FooterProps) {
const { t } = useTranslation()
return (
<FooterContainer justifyContent="space-between">
<HStack gap={8} alignItems="center">
<Button onClick={props.returnPrev}>{t('settings.data.nutstore.pathSelector.return')}</Button>
<Button size="small" type="link" onClick={props.mkdir}>
{t('settings.data.nutstore.new_folder.button')}
</Button>
</HStack>
<HStack gap={8} alignItems="center">
<Button type="default" onClick={props.cancel}>
{t('settings.data.nutstore.new_folder.button.cancel')}
</Button>
<Button type="primary" onClick={props.confirm}>
{t('backup.confirm.button')}
</Button>
</HStack>
</FooterContainer>
)
}

View File

@@ -1,36 +1,223 @@
import i18n from '@renderer/i18n'
import store from '@renderer/store'
import { exportMarkdownToObsidian } from '@renderer/utils/export'
import { Form, Input, Modal, Select } from 'antd'
import React, { useState } from 'react'
import { Alert, Empty, Form, Input, Modal, Select, Spin, TreeSelect } from 'antd'
import React, { useEffect, useState } from 'react'
const { Option } = Select
interface ObsidianExportDialogProps {
title: string
markdown: string
open: boolean // 使用 open 属性替代 visible
open: boolean
onClose: (success: boolean) => void
obsidianTags: string | null
processingMethod: string | '3' //默认新增(存在就覆盖)
}
interface FileInfo {
path: string
type: 'folder' | 'markdown'
name: string
}
// 转换文件信息数组为树形结构
const convertToTreeData = (files: FileInfo[]) => {
const treeData: any[] = [
{
title: i18n.t('chat.topics.export.obsidian_root_directory'),
value: '',
isLeaf: false,
selectable: true
}
]
// 记录已创建的节点路径
const pathMap: Record<string, any> = {
'': treeData[0]
}
// 先按类型分组,确保先处理文件夹
const folders = files.filter((file) => file.type === 'folder')
const mdFiles = files.filter((file) => file.type === 'markdown')
// 按路径排序,确保父文件夹先被创建
const sortedFolders = [...folders].sort((a, b) => a.path.split('/').length - b.path.split('/').length)
// 先处理所有文件夹,构建目录结构
for (const folder of sortedFolders) {
const parts = folder.path.split('/')
let currentPath = ''
let parentPath = ''
// 遍历文件夹路径的每一部分,确保创建完整路径
for (let i = 0; i < parts.length; i++) {
const part = parts[i]
// 构建当前路径
currentPath = currentPath ? `${currentPath}/${part}` : part
// 如果这个路径节点还没创建
if (!pathMap[currentPath]) {
const node = {
title: part,
value: currentPath,
key: currentPath,
isLeaf: false,
selectable: true,
children: []
}
// 获取父节点将当前节点添加到父节点的children中
const parentNode = pathMap[parentPath]
if (parentNode) {
if (!parentNode.children) {
parentNode.children = []
}
parentNode.children.push(node)
}
pathMap[currentPath] = node
}
// 更新父路径为当前路径,为下一级做准备
parentPath = currentPath
}
}
// 然后处理md文件
for (const file of mdFiles) {
const fullPath = file.path
const dirPath = fullPath.substring(0, fullPath.lastIndexOf('/'))
const fileName = file.name
// 获取父文件夹节点
const parentNode = pathMap[dirPath] || pathMap['']
// 创建文件节点
const fileNode = {
title: fileName,
value: fullPath,
isLeaf: true,
selectable: true,
icon: <span style={{ marginRight: 4 }}>📄</span>
}
// 添加到父节点
if (!parentNode.children) {
parentNode.children = []
}
parentNode.children.push(fileNode)
}
return treeData
}
const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
title,
markdown,
obsidianTags,
processingMethod,
open,
onClose
onClose,
obsidianTags,
processingMethod
}) => {
const defaultObsidianVault = store.getState().settings.defaultObsidianVault
const [state, setState] = useState({
title: title,
title,
tags: obsidianTags || '',
createdAt: new Date().toISOString().split('T')[0],
source: 'Cherry Studio',
processingMethod: processingMethod
processingMethod: processingMethod,
folder: ''
})
const [vaults, setVaults] = useState<Array<{ path: string; name: string }>>([])
const [files, setFiles] = useState<FileInfo[]>([])
const [fileTreeData, setFileTreeData] = useState<any[]>([])
const [selectedVault, setSelectedVault] = useState<string>('')
const [loading, setLoading] = useState<boolean>(false)
const [error, setError] = useState<string | null>(null)
// 处理文件数据转为树形结构
useEffect(() => {
if (files.length > 0) {
const treeData = convertToTreeData(files)
setFileTreeData(treeData)
} else {
setFileTreeData([
{
title: i18n.t('chat.topics.export.obsidian_root_directory'),
value: '',
isLeaf: false,
selectable: true
}
])
}
}, [files])
// 组件加载时获取Vault列表
useEffect(() => {
const fetchVaults = async () => {
try {
setLoading(true)
setError(null)
const vaultsData = await window.obsidian.getVaults()
if (vaultsData.length === 0) {
setError(i18n.t('chat.topics.export.obsidian_no_vaults'))
setLoading(false)
return
}
setVaults(vaultsData)
// 如果没有选择的vault使用默认值或第一个
const vaultToUse = defaultObsidianVault || vaultsData[0]?.name
if (vaultToUse) {
setSelectedVault(vaultToUse)
// 获取选中vault的文件和文件夹
const filesData = await window.obsidian.getFiles(vaultToUse)
setFiles(filesData)
}
} catch (error) {
console.error('获取Obsidian Vault失败:', error)
setError(i18n.t('chat.topics.export.obsidian_fetch_error'))
} finally {
setLoading(false)
}
}
fetchVaults()
}, [defaultObsidianVault])
// 当选择的vault变化时获取其文件和文件夹
useEffect(() => {
if (selectedVault) {
const fetchFiles = async () => {
try {
setLoading(true)
setError(null)
const filesData = await window.obsidian.getFiles(selectedVault)
setFiles(filesData)
} catch (error) {
console.error('获取Obsidian文件失败:', error)
setError(i18n.t('chat.topics.export.obsidian_fetch_folders_error'))
} finally {
setLoading(false)
}
}
fetchFiles()
}
}, [selectedVault])
const handleOk = async () => {
if (!selectedVault) {
setError(i18n.t('chat.topics.export.obsidian_no_vault_selected'))
return
}
//构建content 并复制到粘贴板
let content = ''
if (state.processingMethod !== '3') {
@@ -45,10 +232,18 @@ const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
}
if (content === '') {
window.message.error(i18n.t('chat.topics.export.obsidian_export_failed'))
return
}
await navigator.clipboard.writeText(content)
markdown = ''
exportMarkdownToObsidian(state)
// 导出到Obsidian
exportMarkdownToObsidian({
...state,
folder: state.folder,
vault: selectedVault
})
onClose(true)
}
@@ -60,18 +255,56 @@ const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
setState((prevState) => ({ ...prevState, [key]: value }))
}
const handleVaultChange = (value: string) => {
setSelectedVault(value)
// 文件夹会通过useEffect自动获取
setState((prevState) => ({
...prevState,
folder: ''
}))
}
// 处理文件选择
const handleFileSelect = (value: string) => {
// 更新folder值
handleChange('folder', value)
// 检查是否选中md文件
if (value) {
const selectedFile = files.find((file) => file.path === value)
if (selectedFile) {
if (selectedFile.type === 'markdown') {
// 如果是md文件自动设置标题为文件名并设置处理方式为1(追加)
const fileName = selectedFile.name
const titleWithoutExt = fileName.endsWith('.md') ? fileName.substring(0, fileName.length - 3) : fileName
handleChange('title', titleWithoutExt)
handleChange('processingMethod', '1')
} else {
// 如果是文件夹自动设置标题为话题名并设置处理方式为3(新建)
handleChange('processingMethod', '3')
handleChange('title', title)
}
}
}
}
return (
<Modal
title={i18n.t('chat.topics.export.obsidian_atributes')}
open={open} // 使用 open 属性
open={open}
onOk={handleOk}
onCancel={handleCancel}
width={600}
closable
maskClosable
centered
okButtonProps={{ type: 'primary' }}
okButtonProps={{
type: 'primary',
disabled: vaults.length === 0 || loading || !!error
}}
okText={i18n.t('chat.topics.export.obsidian_btn')}>
{error && <Alert message={error} type="error" showIcon style={{ marginBottom: 16 }} />}
<Form layout="horizontal" labelCol={{ span: 6 }} wrapperCol={{ span: 18 }} labelAlign="left">
<Form.Item label={i18n.t('chat.topics.export.obsidian_title')}>
<Input
@@ -80,6 +313,55 @@ const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
placeholder={i18n.t('chat.topics.export.obsidian_title_placeholder')}
/>
</Form.Item>
<Form.Item label={i18n.t('chat.topics.export.obsidian_vault')}>
{vaults.length > 0 ? (
<Select
loading={loading}
value={selectedVault}
onChange={handleVaultChange}
placeholder={i18n.t('chat.topics.export.obsidian_vault_placeholder')}
style={{ width: '100%' }}>
{vaults.map((vault) => (
<Option key={vault.name} value={vault.name}>
{vault.name}
</Option>
))}
</Select>
) : (
<Empty
description={
loading
? i18n.t('chat.topics.export.obsidian_loading')
: i18n.t('chat.topics.export.obsidian_no_vaults')
}
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
)}
</Form.Item>
<Form.Item label={i18n.t('chat.topics.export.obsidian_path')}>
<Spin spinning={loading}>
{selectedVault ? (
<TreeSelect
value={state.folder}
onChange={handleFileSelect}
placeholder={i18n.t('chat.topics.export.obsidian_path_placeholder')}
style={{ width: '100%' }}
showSearch
dropdownStyle={{ maxHeight: 400, overflow: 'auto' }}
treeDefaultExpandAll={false}
treeNodeFilterProp="title"
treeData={fileTreeData}></TreeSelect>
) : (
<Empty
description={i18n.t('chat.topics.export.obsidian_select_vault_first')}
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
)}
</Spin>
</Form.Item>
<Form.Item label={i18n.t('chat.topics.export.obsidian_tags')}>
<Input
value={state.tags}
@@ -101,6 +383,7 @@ const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
placeholder={i18n.t('chat.topics.export.obsidian_source_placeholder')}
/>
</Form.Item>
<Form.Item label={i18n.t('chat.topics.export.obsidian_operate')}>
<Select
value={state.processingMethod}

View File

@@ -9,7 +9,7 @@ import { Agent, Assistant } from '@renderer/types'
import { uuid } from '@renderer/utils'
import { Divider, Input, InputRef, Modal, Tag } from 'antd'
import { take } from 'lodash'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@@ -30,6 +30,8 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
const inputRef = useRef<InputRef>(null)
const systemAgents = useSystemAgents()
const loadingRef = useRef(false)
const [selectedIndex, setSelectedIndex] = useState(0)
const containerRef = useRef<HTMLDivElement>(null)
const agents = useMemo(() => {
const allAgents = [...userAgents, ...systemAgents] as Agent[]
@@ -52,25 +54,80 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
return filtered
}, [assistants, defaultAssistant, searchText, systemAgents, userAgents])
const onCreateAssistant = async (agent: Agent) => {
if (loadingRef.current) {
return
// 重置选中索引当搜索或列表内容变更时
useEffect(() => {
setSelectedIndex(0)
}, [agents.length, searchText])
const onCreateAssistant = useCallback(
async (agent: Agent) => {
if (loadingRef.current) {
return
}
loadingRef.current = true
let assistant: Assistant
if (agent.id === 'default') {
assistant = { ...agent, id: uuid() }
addAssistant(assistant)
} else {
assistant = await createAssistantFromAgent(agent)
}
setTimeout(() => EventEmitter.emit(EVENT_NAMES.SHOW_ASSISTANTS), 0)
resolve(assistant)
setOpen(false)
},
[resolve, addAssistant, setOpen]
) // 添加函数内使用的依赖项
// 键盘导航处理
useEffect(() => {
if (!open) return
const handleKeyDown = (e: KeyboardEvent) => {
const displayedAgents = take(agents, 100)
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
setSelectedIndex((prev) => (prev >= displayedAgents.length - 1 ? 0 : prev + 1))
break
case 'ArrowUp':
e.preventDefault()
setSelectedIndex((prev) => (prev <= 0 ? displayedAgents.length - 1 : prev - 1))
break
case 'Enter':
// 如果焦点在输入框且有搜索内容,则默认选择第一项
if (document.activeElement === inputRef.current?.input && searchText.trim()) {
e.preventDefault()
onCreateAssistant(displayedAgents[selectedIndex])
}
// 否则选择当前选中项
else if (selectedIndex >= 0 && selectedIndex < displayedAgents.length) {
e.preventDefault()
onCreateAssistant(displayedAgents[selectedIndex])
}
break
}
}
loadingRef.current = true
let assistant: Assistant
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [open, selectedIndex, agents, searchText, onCreateAssistant])
if (agent.id === 'default') {
assistant = { ...agent, id: uuid() }
addAssistant(assistant)
} else {
assistant = await createAssistantFromAgent(agent)
// 确保选中项在可视区域
useEffect(() => {
if (containerRef.current) {
const agentItems = containerRef.current.querySelectorAll('.agent-item')
if (agentItems[selectedIndex]) {
agentItems[selectedIndex].scrollIntoView({
behavior: 'smooth',
block: 'nearest'
})
}
}
setTimeout(() => EventEmitter.emit(EVENT_NAMES.SHOW_ASSISTANTS), 0)
resolve(assistant)
setOpen(false)
}
}, [selectedIndex])
const onCancel = () => {
setOpen(false)
@@ -121,12 +178,13 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
/>
</HStack>
<Divider style={{ margin: 0, borderBlockStartWidth: 0.5 }} />
<Container>
{take(agents, 100).map((agent) => (
<Container ref={containerRef}>
{take(agents, 100).map((agent, index) => (
<AgentItem
key={agent.id}
onClick={() => onCreateAssistant(agent)}
className={agent.id === 'default' ? 'default' : ''}>
className={`agent-item ${agent.id === 'default' ? 'default' : ''} ${index === selectedIndex ? 'keyboard-selected' : ''}`}
onMouseEnter={() => setSelectedIndex(index)}>
<HStack
alignItems="center"
gap={5}
@@ -161,9 +219,14 @@ const AgentItem = styled.div`
margin-bottom: 8px;
cursor: pointer;
overflow: hidden;
border: 1px solid transparent;
&.default {
background-color: var(--color-background-mute);
}
&.keyboard-selected {
background-color: var(--color-background-mute);
border: 1px solid var(--color-primary);
}
.anticon {
font-size: 16px;
color: var(--color-icon);

View File

@@ -0,0 +1,60 @@
import { Modal } from 'antd'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { NutstorePathSelector } from '../NutstorePathSelector'
import { TopView } from '../TopView'
interface Props {
fs: Nutstore.Fs
resolve: (data: string | null) => void
}
const PopupContainer: React.FC<Props> = ({ resolve, fs }) => {
const [open, setOpen] = useState(true)
const { t } = useTranslation()
const onCancel = () => {
setOpen(false)
}
const onClose = () => {
resolve(null)
}
return (
<Modal
open={open}
title={t('settings.data.nutstore.pathSelector.title')}
transitionName="ant-move-down"
afterClose={onClose}
onCancel={onClose}
footer={null}
centered>
<NutstorePathSelector fs={fs} onConfirm={resolve} onCancel={onCancel} />
</Modal>
)
}
const TopViewKey = 'NutstorePathPopup'
export default class NutstorePathPopup {
static topviewId = 0
static hide() {
TopView.hide(TopViewKey)
}
static show(fs: Nutstore.Fs) {
return new Promise<any>((resolve) => {
TopView.show(
<PopupContainer
fs={fs}
resolve={(v) => {
resolve(v)
TopView.hide(TopViewKey)
}}
/>,
TopViewKey
)
})
}
}

View File

@@ -1,6 +1,4 @@
import ObsidianExportDialog from '@renderer/components/ObsidianExportDialog'
import i18n from '@renderer/i18n'
import store from '@renderer/store'
import { createRoot } from 'react-dom/client'
interface ObsidianExportOptions {
@@ -17,14 +15,6 @@ interface ObsidianExportOptions {
* @returns
*/
const showObsidianExportDialog = async (options: ObsidianExportOptions): Promise<boolean> => {
const obsidianValut = store.getState().settings.obsidianValut
const obsidianFolder = store.getState().settings.obsidianFolder
if (!obsidianValut || !obsidianFolder) {
window.message.error(i18n.t('chat.topics.export.obsidian_not_configured'))
return false
}
return new Promise<boolean>((resolve) => {
const div = document.createElement('div')
document.body.appendChild(div)
@@ -35,12 +25,12 @@ const showObsidianExportDialog = async (options: ObsidianExportOptions): Promise
document.body.removeChild(div)
resolve(success)
}
const obsidianTags = store.getState().settings.obsidianTages
// 不再从store中获取tag配置
root.render(
<ObsidianExportDialog
title={options.title}
markdown={options.markdown}
obsidianTags={obsidianTags}
obsidianTags=""
processingMethod={options.processingMethod}
open={true}
onClose={handleClose}
@@ -49,8 +39,6 @@ const showObsidianExportDialog = async (options: ObsidianExportOptions): Promise
})
}
const ObsidianExportPopup = {
export default {
show: showObsidianExportDialog
}
export default ObsidianExportPopup

View File

@@ -1,5 +1,5 @@
import { throttle } from 'lodash'
import { FC, forwardRef, useCallback, useEffect, useRef, useState } from 'react'
import { FC, useCallback, useEffect, useRef, useState } from 'react'
import styled from 'styled-components'
interface Props extends React.HTMLAttributes<HTMLDivElement> {
@@ -7,7 +7,7 @@ interface Props extends React.HTMLAttributes<HTMLDivElement> {
ref?: any
}
const Scrollbar: FC<Props> = forwardRef<HTMLDivElement, Props>((props, ref) => {
const Scrollbar: FC<Props> = ({ ref, ...props }: Props & { ref?: React.RefObject<HTMLDivElement | null> }) => {
const [isScrolling, setIsScrolling] = useState(false)
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
@@ -35,7 +35,7 @@ const Scrollbar: FC<Props> = forwardRef<HTMLDivElement, Props>((props, ref) => {
{props.children}
</Container>
)
})
}
const Container = styled.div<{ isScrolling: boolean; right?: boolean }>`
overflow-y: auto;

View File

@@ -1,3 +1,4 @@
import TopViewMinappContainer from '@renderer/components/MinApp/TopViewMinappContainer'
import { useAppInit } from '@renderer/hooks/useAppInit'
import { message, Modal } from 'antd'
import React, { PropsWithChildren, useCallback, useEffect, useRef, useState } from 'react'
@@ -76,6 +77,7 @@ const TopViewContainer: React.FC<Props> = ({ children }) => {
{children}
{messageContextHolder}
{modalContextHolder}
<TopViewMinappContainer />
{elements.map(({ element: Element, id }) => (
<FullScreenContainer key={`TOPVIEW_${id}`}>
{typeof Element === 'function' ? <Element /> : Element}

View File

@@ -0,0 +1,103 @@
import { DeleteOutlined, ImportOutlined } from '@ant-design/icons'
import { VStack } from '@renderer/components/Layout'
import { Variable } from '@renderer/types'
import { Button, Input, Tooltip } from 'antd'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface VariableListProps {
variables: Variable[]
setVariables: (variables: Variable[]) => void
onUpdate?: (variables: Variable[]) => void
onInsertVariable?: (name: string) => void
}
const VariableList: React.FC<VariableListProps> = ({ variables, setVariables, onUpdate, onInsertVariable }) => {
const { t } = useTranslation()
const deleteVariable = (id: string) => {
const updatedVariables = variables.filter((v) => v.id !== id)
setVariables(updatedVariables)
if (onUpdate) {
onUpdate(updatedVariables)
}
}
const updateVariable = (id: string, field: 'name' | 'value', value: string) => {
// Only update the local state when typing, don't call the parent's onUpdate
const updatedVariables = variables.map((v) => (v.id === id ? { ...v, [field]: value } : v))
setVariables(updatedVariables)
}
// This function will be called when input loses focus
const handleInputBlur = () => {
if (onUpdate) {
onUpdate(variables)
}
}
return (
<VariablesContainer>
{variables.length === 0 ? (
<EmptyText>{t('common.no_variables_added')}</EmptyText>
) : (
<VStack gap={8} width="100%">
{variables.map((variable) => (
<VariableItem key={variable.id}>
<Input
placeholder={t('common.variable_name')}
value={variable.name}
onChange={(e) => updateVariable(variable.id, 'name', e.target.value)}
onBlur={handleInputBlur}
style={{ width: '30%' }}
/>
<Input
placeholder={t('common.value')}
value={variable.value}
onChange={(e) => updateVariable(variable.id, 'value', e.target.value)}
onBlur={handleInputBlur}
style={{ flex: 1 }}
/>
{onInsertVariable && (
<Tooltip title={t('common.insert_variable_into_prompt')}>
<Button type="text" onClick={() => onInsertVariable(variable.name)}>
<ImportOutlined />
</Button>
</Tooltip>
)}
<Button type="text" danger icon={<DeleteOutlined />} onClick={() => deleteVariable(variable.id)} />
</VariableItem>
))}
</VStack>
)}
</VariablesContainer>
)
}
const VariablesContainer = styled.div`
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
overflow-y: auto;
max-height: 200px;
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 12px;
`
const VariableItem = styled.div`
display: flex;
align-items: center;
gap: 8px;
width: 100%;
`
const EmptyText = styled.div`
color: var(--color-text-2);
opacity: 0.6;
font-style: italic;
`
export default VariableList

View File

@@ -0,0 +1,237 @@
import { backupToWebdav, restoreFromWebdav } from '@renderer/services/BackupService'
import { formatFileSize } from '@renderer/utils'
import { Input, Modal, Select, Spin } from 'antd'
import dayjs from 'dayjs'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
interface BackupFile {
fileName: string
modifiedTime: string
size: number
}
interface WebdavModalProps {
isModalVisible: boolean
handleBackup: () => void
handleCancel: () => void
backuping: boolean
customFileName: string
setCustomFileName: (value: string) => void
}
export function useWebdavBackupModal({ backupMethod }: { backupMethod?: typeof backupToWebdav } = {}) {
const [customFileName, setCustomFileName] = useState('')
const [isModalVisible, setIsModalVisible] = useState(false)
const [backuping, setBackuping] = useState(false)
const handleBackup = async () => {
setBackuping(true)
try {
await (backupMethod ?? backupToWebdav)({ showMessage: true, customFileName })
} finally {
setBackuping(false)
setIsModalVisible(false)
}
}
const handleCancel = () => {
setIsModalVisible(false)
}
const showBackupModal = useCallback(async () => {
// 获取默认文件名
const deviceType = await window.api.system.getDeviceType()
const timestamp = dayjs().format('YYYYMMDDHHmmss')
const defaultFileName = `cherry-studio.${timestamp}.${deviceType}.zip`
setCustomFileName(defaultFileName)
setIsModalVisible(true)
}, [])
return {
isModalVisible,
handleBackup,
handleCancel,
backuping,
customFileName,
setCustomFileName,
showBackupModal
}
}
export function WebdavBackupModal({
isModalVisible,
handleBackup,
handleCancel,
backuping,
customFileName,
setCustomFileName
}: WebdavModalProps) {
const { t } = useTranslation()
return (
<Modal
title={t('settings.data.webdav.backup.modal.title')}
open={isModalVisible}
onOk={handleBackup}
onCancel={handleCancel}
okButtonProps={{ loading: backuping }}>
<Input
value={customFileName}
onChange={(e) => setCustomFileName(e.target.value)}
placeholder={t('settings.data.webdav.backup.modal.filename.placeholder')}
/>
</Modal>
)
}
interface WebdavRestoreModalProps {
isRestoreModalVisible: boolean
handleRestore: () => void
handleCancel: () => void
restoring: boolean
selectedFile: string | null
setSelectedFile: (value: string | null) => void
loadingFiles: boolean
backupFiles: BackupFile[]
}
interface UseWebdavRestoreModalProps {
webdavHost: string | undefined
webdavUser: string | undefined
webdavPass: string | undefined
webdavPath: string | undefined
restoreMethod?: typeof restoreFromWebdav
}
export function useWebdavRestoreModal({
webdavHost,
webdavUser,
webdavPass,
webdavPath,
restoreMethod
}: UseWebdavRestoreModalProps) {
const { t } = useTranslation()
const [isRestoreModalVisible, setIsRestoreModalVisible] = useState(false)
const [restoring, setRestoring] = useState(false)
const [selectedFile, setSelectedFile] = useState<string | null>(null)
const [loadingFiles, setLoadingFiles] = useState(false)
const [backupFiles, setBackupFiles] = useState<BackupFile[]>([])
const showRestoreModal = useCallback(async () => {
if (!webdavHost || !webdavUser || !webdavPass || !webdavPath) {
window.message.error({ content: t('message.error.invalid.webdav'), key: 'webdav-error' })
return
}
setIsRestoreModalVisible(true)
setLoadingFiles(true)
try {
const files = await window.api.backup.listWebdavFiles({
webdavHost,
webdavUser,
webdavPass,
webdavPath
})
setBackupFiles(files)
} catch (error: any) {
window.message.error({ content: error.message, key: 'list-files-error' })
} finally {
setLoadingFiles(false)
}
}, [webdavHost, webdavUser, webdavPass, webdavPath, t])
const handleRestore = useCallback(async () => {
if (!selectedFile || !webdavHost || !webdavUser || !webdavPass || !webdavPath) {
window.message.error({
content: !selectedFile ? t('message.error.no.file.selected') : t('message.error.invalid.webdav'),
key: 'restore-error'
})
return
}
window.modal.confirm({
title: t('settings.data.webdav.restore.confirm.title'),
content: t('settings.data.webdav.restore.confirm.content'),
centered: true,
onOk: async () => {
setRestoring(true)
try {
await (restoreMethod ?? restoreFromWebdav)(selectedFile)
setIsRestoreModalVisible(false)
} catch (error: any) {
window.message.error({ content: error.message, key: 'restore-error' })
} finally {
setRestoring(false)
}
}
})
}, [selectedFile, webdavHost, webdavUser, webdavPass, webdavPath, t, restoreMethod])
const handleCancel = () => {
setIsRestoreModalVisible(false)
}
return {
isRestoreModalVisible,
handleRestore,
handleCancel,
restoring,
selectedFile,
setSelectedFile,
loadingFiles,
backupFiles,
showRestoreModal
}
}
export function WebdavRestoreModal({
isRestoreModalVisible,
handleRestore,
handleCancel,
restoring,
selectedFile,
setSelectedFile,
loadingFiles,
backupFiles
}: WebdavRestoreModalProps) {
const { t } = useTranslation()
return (
<Modal
title={t('settings.data.webdav.restore.modal.title')}
open={isRestoreModalVisible}
onOk={handleRestore}
onCancel={handleCancel}
okButtonProps={{ loading: restoring }}
width={600}>
<div style={{ position: 'relative' }}>
<Select
style={{ width: '100%' }}
placeholder={t('settings.data.webdav.restore.modal.select.placeholder')}
value={selectedFile}
onChange={setSelectedFile}
options={backupFiles.map(formatFileOption)}
loading={loadingFiles}
showSearch
filterOption={(input, option) => (option?.label ?? '').toLowerCase().includes(input.toLowerCase())}
/>
{loadingFiles && (
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)' }}>
<Spin />
</div>
)}
</div>
</Modal>
)
}
function formatFileOption(file: BackupFile) {
const date = dayjs(file.modifiedTime).format('YYYY-MM-DD HH:mm:ss')
const size = formatFileSize(file.size)
return {
label: `${file.fileName} (${date}, ${size})`,
value: file.fileName
}
}

View File

@@ -1,9 +1,10 @@
import { isMac } from '@renderer/config/constant'
import { isMac, isWindows } from '@renderer/config/constant'
import useNavBackgroundColor from '@renderer/hooks/useNavBackgroundColor'
import { FC, PropsWithChildren } from 'react'
import type { FC, PropsWithChildren } from 'react'
import type { HTMLAttributes } from 'react'
import styled from 'styled-components'
type Props = PropsWithChildren & JSX.IntrinsicElements['div']
type Props = PropsWithChildren & HTMLAttributes<HTMLDivElement>
export const Navbar: FC<Props> = ({ children, ...props }) => {
const backgroundColor = useNavBackgroundColor()
@@ -62,4 +63,6 @@ const NavbarRightContainer = styled.div`
display: flex;
align-items: center;
padding: 0 12px;
padding-right: ${isWindows ? '140px' : 12};
justify-content: flex-end;
`

View File

@@ -9,35 +9,36 @@ import { isMac } from '@renderer/config/constant'
import { AppLogo, UserAvatar } from '@renderer/config/env'
import { useTheme } from '@renderer/context/ThemeProvider'
import useAvatar from '@renderer/hooks/useAvatar'
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
import { useMinapps } from '@renderer/hooks/useMinapps'
import useNavBackgroundColor from '@renderer/hooks/useNavBackgroundColor'
import { modelGenerating, useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings'
import { isEmoji } from '@renderer/utils'
import type { MenuProps } from 'antd'
import { Tooltip } from 'antd'
import { Avatar } from 'antd'
import { Dropdown } from 'antd'
import { FC } from 'react'
import { Avatar, Dropdown, Tooltip } from 'antd'
import { FC, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useLocation, useNavigate } from 'react-router-dom'
import styled from 'styled-components'
import DragableList from '../DragableList'
import MinAppIcon from '../Icons/MinAppIcon'
import MinApp from '../MinApp'
import UserPopup from '../Popups/UserPopup'
const Sidebar: FC = () => {
const { pathname } = useLocation()
const avatar = useAvatar()
const { minappShow } = useRuntime()
const { t } = useTranslation()
const navigate = useNavigate()
const { hideMinappPopup, openMinapp } = useMinappPopup()
const { minappShow, currentMinappId } = useRuntime()
const { sidebarIcons } = useSettings()
const { theme, toggleTheme } = useTheme()
const { pinned } = useMinapps()
const { pathname } = useLocation()
const navigate = useNavigate()
const { theme, settingTheme, toggleTheme } = useTheme()
const avatar = useAvatar()
const { t } = useTranslation()
const onEditUser = () => UserPopup.show()
const backgroundColor = useNavBackgroundColor()
@@ -49,9 +50,10 @@ const Sidebar: FC = () => {
navigate(path)
}
const docsId = 'cherrystudio-docs'
const onOpenDocs = () => {
MinApp.start({
id: 'docs',
openMinapp({
id: docsId,
name: t('docs.title'),
url: 'https://docs.cherry-ai.com/',
logo: AppLogo
@@ -66,9 +68,10 @@ const Sidebar: FC = () => {
<AvatarImg src={avatar || UserAvatar} draggable={false} className="nodrag" onClick={onEditUser} />
)}
<MainMenusContainer>
<Menus onClick={MinApp.onClose}>
<Menus onClick={hideMinappPopup}>
<MainMenus />
</Menus>
<SidebarOpenedMinappTabs />
{showPinnedApps && (
<AppsContainer>
<Divider />
@@ -80,14 +83,14 @@ const Sidebar: FC = () => {
</MainMenusContainer>
<Menus>
<Tooltip title={t('docs.title')} mouseEnterDelay={0.8} placement="right">
<Icon
theme={theme}
onClick={onOpenDocs}
className={minappShow && MinApp.app?.url === 'https://docs.cherry-ai.com/' ? 'active' : ''}>
<Icon theme={theme} onClick={onOpenDocs} className={minappShow && currentMinappId === docsId ? 'active' : ''}>
<QuestionCircleOutlined />
</Icon>
</Tooltip>
<Tooltip title={t('settings.theme.title')} mouseEnterDelay={0.8} placement="right">
<Tooltip
title={t('settings.theme.title') + ': ' + t(`settings.theme.${settingTheme}`)}
mouseEnterDelay={0.8}
placement="right">
<Icon theme={theme} onClick={() => toggleTheme()}>
{theme === 'dark' ? (
<i className="iconfont icon-theme icon-dark1" />
@@ -99,7 +102,7 @@ const Sidebar: FC = () => {
<Tooltip title={t('settings.title')} mouseEnterDelay={0.8} placement="right">
<StyledLink
onClick={async () => {
minappShow && (await MinApp.close())
hideMinappPopup()
await modelGenerating()
await to('/settings/provider')
}}>
@@ -114,6 +117,7 @@ const Sidebar: FC = () => {
}
const MainMenus: FC = () => {
const { hideMinappPopup } = useMinappPopup()
const { t } = useTranslation()
const { pathname } = useLocation()
const { sidebarIcons } = useSettings()
@@ -152,7 +156,7 @@ const MainMenus: FC = () => {
<Tooltip key={icon} title={t(`${icon}.title`)} mouseEnterDelay={0.8} placement="right">
<StyledLink
onClick={async () => {
minappShow && (await MinApp.close())
hideMinappPopup()
await modelGenerating()
navigate(path)
}}>
@@ -165,11 +169,103 @@ const MainMenus: FC = () => {
})
}
/** Tabs of opened minapps in sidebar */
const SidebarOpenedMinappTabs: FC = () => {
const { minappShow, openedKeepAliveMinapps, currentMinappId } = useRuntime()
const { openMinappKeepAlive, hideMinappPopup, closeMinapp, closeAllMinapps } = useMinappPopup()
const { showOpenedMinappsInSidebar } = useSettings() // 获取控制显示的设置
const { theme } = useTheme()
const { t } = useTranslation()
const handleOnClick = (app) => {
if (minappShow && currentMinappId === app.id) {
hideMinappPopup()
} else {
openMinappKeepAlive(app)
}
}
// animation for minapp switch indicator
useEffect(() => {
//hacky way to get the height of the icon
const iconDefaultHeight = 40
const iconDefaultOffset = 17
const container = document.querySelector('.TabsContainer') as HTMLElement
const activeIcon = document.querySelector('.TabsContainer .opened-active') as HTMLElement
let indicatorTop = 0,
indicatorRight = 0
if (minappShow && activeIcon && container) {
indicatorTop = activeIcon.offsetTop + activeIcon.offsetHeight / 2 - 4 // 4 is half of the indicator's height (8px)
indicatorRight = 0
} else {
indicatorTop =
((openedKeepAliveMinapps.length > 0 ? openedKeepAliveMinapps.length : 1) / 2) * iconDefaultHeight +
iconDefaultOffset -
4
indicatorRight = -50
}
container.style.setProperty('--indicator-top', `${indicatorTop}px`)
container.style.setProperty('--indicator-right', `${indicatorRight}px`)
}, [currentMinappId, openedKeepAliveMinapps, minappShow])
// 检查是否需要显示已打开小程序组件
const isShowOpened = showOpenedMinappsInSidebar && openedKeepAliveMinapps.length > 0
// 如果不需要显示,返回空容器保持动画效果但不显示内容
if (!isShowOpened) return <TabsContainer className="TabsContainer" />
return (
<TabsContainer className="TabsContainer">
<Divider />
<TabsWrapper>
<Menus>
{openedKeepAliveMinapps.map((app) => {
const menuItems: MenuProps['items'] = [
{
key: 'closeApp',
label: t('minapp.sidebar.close.title'),
onClick: () => {
closeMinapp(app.id)
}
},
{
key: 'closeAllApp',
label: t('minapp.sidebar.closeall.title'),
onClick: () => {
closeAllMinapps()
}
}
]
const isActive = minappShow && currentMinappId === app.id
return (
<Tooltip key={app.id} title={app.name} mouseEnterDelay={0.8} placement="right">
<StyledLink>
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']} overlayStyle={{ zIndex: 10000 }}>
<Icon
theme={theme}
onClick={() => handleOnClick(app)}
className={`${isActive ? 'opened-active' : ''}`}>
<MinAppIcon size={20} app={app} style={{ borderRadius: 6 }} />
</Icon>
</Dropdown>
</StyledLink>
</Tooltip>
)
})}
</Menus>
</TabsWrapper>
</TabsContainer>
)
}
const PinnedApps: FC = () => {
const { pinned, updatePinnedMinapps } = useMinapps()
const { t } = useTranslation()
const { minappShow } = useRuntime()
const { minappShow, openedKeepAliveMinapps, currentMinappId } = useRuntime()
const { theme } = useTheme()
const { openMinappKeepAlive } = useMinappPopup()
return (
<DragableList list={pinned} onUpdate={updatePinnedMinapps} listStyle={{ marginBottom: 5 }}>
@@ -184,12 +280,15 @@ const PinnedApps: FC = () => {
}
}
]
const isActive = minappShow && MinApp.app?.id === app.id
const isActive = minappShow && currentMinappId === app.id
return (
<Tooltip key={app.id} title={app.name} mouseEnterDelay={0.8} placement="right">
<StyledLink>
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']}>
<Icon theme={theme} onClick={() => MinApp.start(app)} className={isActive ? 'active' : ''}>
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']} overlayStyle={{ zIndex: 10000 }}>
<Icon
theme={theme}
onClick={() => openMinappKeepAlive(app)}
className={`${isActive ? 'active' : ''} ${openedKeepAliveMinapps.some((item) => item.id === app.id) ? 'opened-animation' : ''}`}>
<MinAppIcon size={20} app={app} style={{ borderRadius: 6 }} />
</Icon>
</Dropdown>
@@ -262,6 +361,7 @@ const Icon = styled.div<{ theme: string }>`
justify-content: center;
align-items: center;
border-radius: 50%;
box-sizing: border-box;
-webkit-app-region: none;
border: 0.5px solid transparent;
.iconfont,
@@ -290,6 +390,39 @@ const Icon = styled.div<{ theme: string }>`
color: var(--color-icon-white);
}
}
@keyframes borderBreath {
0% {
opacity: 0.1;
}
50% {
opacity: 1;
}
100% {
opacity: 0.1;
}
}
&.opened-animation {
position: relative;
}
&.opened-animation::after {
content: '';
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
border-radius: inherit;
opacity: 0;
will-change: opacity;
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;
}
`
const StyledLink = styled.div`
@@ -320,4 +453,37 @@ const Divider = styled.div`
border-bottom: 0.5px solid var(--color-border);
`
const TabsContainer = styled.div`
display: flex;
flex-direction: column;
align-items: center;
-webkit-app-region: none;
position: relative;
width: 100%;
&::after {
content: '';
position: absolute;
right: var(--indicator-right, 0);
top: var(--indicator-top, 0);
width: 4px;
height: 8px;
background-color: var(--color-primary);
transition:
top 0.3s cubic-bezier(0.4, 0, 0.2, 1),
right 0.3s ease-in-out;
border-radius: 2px;
}
&::-webkit-scrollbar {
display: none;
}
`
const TabsWrapper = styled.div`
background-color: rgba(128, 128, 128, 0.1);
border-radius: 20px;
overflow: hidden;
`
export default Sidebar

View File

@@ -49,9 +49,7 @@ import DeepSeekProviderLogo from '@renderer/assets/images/providers/deepseek.png
import GroqProviderLogo from '@renderer/assets/images/providers/groq.png?url'
import OpenAiProviderLogo from '@renderer/assets/images/providers/openai.png?url'
import SiliconFlowProviderLogo from '@renderer/assets/images/providers/silicon.png?url'
import MinApp from '@renderer/components/MinApp'
import { MinAppType } from '@renderer/types'
export const DEFAULT_MIN_APPS: MinAppType[] = [
{
id: 'openai',
@@ -395,8 +393,3 @@ export const DEFAULT_MIN_APPS: MinAppType[] = [
bodered: true
}
]
export function startMinAppById(id: string) {
const app = DEFAULT_MIN_APPS.find((app) => app?.id === id)
app && MinApp.start(app)
}

View File

@@ -142,6 +142,7 @@ const visionAllowedModels = [
'minicpm',
'gemini-1\\.5',
'gemini-2\\.0',
'gemini-2\\.5',
'gemini-exp',
'claude-3',
'vision',
@@ -149,12 +150,14 @@ const visionAllowedModels = [
'qwen-vl',
'qwen2-vl',
'qwen2.5-vl',
'qwen2.5-omni',
'qvq',
'internvl2',
'grok-vision-beta',
'pixtral',
'gpt-4(?:-[\\w-]+)',
'gpt-4o(?:-[\\w-]+)?',
'gpt-4.5(?:-[\\w-]+)',
'chatgpt-4o(?:-[\\w-]+)?',
'o1(?:-[\\w-]+)?',
'deepseek-vl(?:[\\w-]+)?',
@@ -162,7 +165,15 @@ const visionAllowedModels = [
'gemma-3(?:-[\\w-]+)'
]
const visionExcludedModels = ['gpt-4-\\d+-preview', 'gpt-4-turbo-preview', 'gpt-4-32k', 'gpt-4-\\d+']
const visionExcludedModels = [
'gpt-4-\\d+-preview',
'gpt-4-turbo-preview',
'gpt-4-32k',
'gpt-4-\\d+',
'o1-mini',
'o1-preview',
'AIDC-AI/Marco-o1'
]
export const VISION_REGEX = new RegExp(
`\\b(?!(?:${visionExcludedModels.join('|')})\\b)(${visionAllowedModels.join('|')})\\b`,
'i'
@@ -173,7 +184,7 @@ export const TEXT_TO_IMAGE_REGEX = /flux|diffusion|stabilityai|sd-|dall|cogview|
// Reasoning models
export const REASONING_REGEX =
/^(o\d+(?:-[\w-]+)?|.*\b(?:reasoner|thinking)\b.*|.*-[rR]\d+.*|.*\bqwq(?:-[\w-]+)?\b.*|.*\bhunyuan-t1(?:-[\w-]+)?\b.*)$/i
/^(o\d+(?:-[\w-]+)?|.*\b(?:reasoner|thinking)\b.*|.*-[rR]\d+.*|.*\bqwq(?:-[\w-]+)?\b.*|.*\bhunyuan-t1(?:-[\w-]+)?\b.*|.*\bglm-zero-preview\b.*)$/i
// Embedding models
export const EMBEDDING_REGEX =
@@ -190,15 +201,23 @@ export const FUNCTION_CALLING_MODELS = [
'gpt-4o-mini',
'gpt-4',
'gpt-4.5',
'o1(?:-[\\w-]+)?',
'claude',
'qwen',
'hunyuan',
'deepseek',
'glm-4(?:-[\\w-]+)?',
'learnlm(?:-[\\w-]+)?',
'gemini(?:-[\\w-]+)?' // 提前排除了gemini的嵌入模型
]
const FUNCTION_CALLING_EXCLUDED_MODELS = ['aqa(?:-[\\w-]+)?', 'imagen(?:-[\\w-]+)?']
const FUNCTION_CALLING_EXCLUDED_MODELS = [
'aqa(?:-[\\w-]+)?',
'imagen(?:-[\\w-]+)?',
'o1-mini',
'o1-preview',
'AIDC-AI/Marco-o1'
]
export const FUNCTION_CALLING_REGEX = new RegExp(
`\\b(?!(?:${FUNCTION_CALLING_EXCLUDED_MODELS.join('|')})\\b)(?:${FUNCTION_CALLING_MODELS.join('|')})\\b`,
@@ -1212,7 +1231,140 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
group: 'Step 1'
}
],
doubao: [],
doubao: [
{
id: 'doubao-1-5-vision-pro-32k-250115',
provider: 'doubao',
name: 'doubao-1.5-vision-pro',
group: 'Doubao-1.5-vision-pro'
},
{
id: 'doubao-1-5-pro-32k-250115',
provider: 'doubao',
name: 'doubao-1.5-pro-32k',
group: 'Doubao-1.5-pro'
},
{
id: 'doubao-1-5-pro-32k-character-250228',
provider: 'doubao',
name: 'doubao-1.5-pro-32k-character',
group: 'Doubao-1.5-pro'
},
{
id: 'doubao-1-5-pro-256k-250115',
provider: 'doubao',
name: 'Doubao-1.5-pro-256k',
group: 'Doubao-1.5-pro'
},
{
id: 'deepseek-r1-250120',
provider: 'doubao',
name: 'DeepSeek-R1',
group: 'DeepSeek'
},
{
id: 'deepseek-r1-distill-qwen-32b-250120',
provider: 'doubao',
name: 'DeepSeek-R1-Distill-Qwen-32B',
group: 'DeepSeek'
},
{
id: 'deepseek-r1-distill-qwen-7b-250120',
provider: 'doubao',
name: 'DeepSeek-R1-Distill-Qwen-7B',
group: 'DeepSeek'
},
{
id: 'deepseek-v3-250324',
provider: 'doubao',
name: 'DeepSeek-V3',
group: 'DeepSeek'
},
{
id: 'deepseek-v3-250324',
provider: 'doubao',
name: 'DeepSeek-V3',
group: 'DeepSeek'
},
{
id: 'doubao-pro-32k-241215',
provider: 'doubao',
name: 'Doubao-pro-32k',
group: 'Doubao-pro'
},
{
id: 'doubao-pro-32k-functioncall-241028',
provider: 'doubao',
name: 'Doubao-pro-32k-functioncall-241028',
group: 'Doubao-pro'
},
{
id: 'doubao-pro-32k-character-241215',
provider: 'doubao',
name: 'Doubao-pro-32k-character-241215',
group: 'Doubao-pro'
},
{
id: 'doubao-pro-256k-241115',
provider: 'doubao',
name: 'Doubao-pro-256k',
group: 'Doubao-pro'
},
{
id: 'doubao-lite-4k-character-240828',
provider: 'doubao',
name: 'Doubao-lite-4k-character-240828',
group: 'Doubao-lite'
},
{
id: 'doubao-lite-32k-240828',
provider: 'doubao',
name: 'Doubao-lite-32k',
group: 'Doubao-lite'
},
{
id: 'doubao-lite-32k-character-241015',
provider: 'doubao',
name: 'Doubao-lite-32k-character-241015',
group: 'Doubao-lite'
},
{
id: 'doubao-lite-128k-240828',
provider: 'doubao',
name: 'Doubao-lite-128k',
group: 'Doubao-lite'
},
{
id: 'doubao-1-5-lite-32k-250115',
provider: 'doubao',
name: 'Doubao-1.5-lite-32k',
group: 'Doubao-lite'
},
{
id: 'doubao-embedding-large-text-240915',
provider: 'doubao',
name: 'Doubao-embedding-large',
group: 'Doubao-embedding'
},
{
id: 'doubao-embedding-text-240715',
provider: 'doubao',
name: 'Doubao-embedding',
group: 'Doubao-embedding'
},
{
id: 'doubao-embedding-vision-241215',
provider: 'doubao',
name: 'Doubao-embedding-vision',
group: 'Doubao-embedding'
},
{
id: 'doubao-vision-lite-32k-241015',
provider: 'doubao',
name: 'Doubao-vision-lite-32k',
group: 'Doubao-vision-lite-32k'
}
],
minimax: [
{
id: 'abab6.5s-chat',
@@ -1541,15 +1693,15 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
group: 'Llama3'
},
{
id: 'mixtral-8x7b-32768',
id: 'mistral-saba-24b',
provider: 'groq',
name: 'Mixtral 8x7B',
group: 'Mixtral'
name: 'Mistral Saba 24B',
group: 'Mistral'
},
{
id: 'gemma-7b-it',
id: 'gemma-9b-it',
provider: 'groq',
name: 'Gemma 7B',
name: 'Gemma 9B',
group: 'Gemma'
}
],
@@ -1939,6 +2091,17 @@ export const TEXT_TO_IMAGES_MODELS_SUPPORT_IMAGE_ENHANCEMENT = [
export const GENERATE_IMAGE_MODELS = ['gemini-2.0-flash-exp-image-generation', 'gemini-2.0-flash-exp']
export const GEMINI_SEARCH_MODELS = [
'gemini-2.0-flash',
'gemini-2.0-flash-lite',
'gemini-2.0-flash-exp',
'gemini-2.0-flash-001',
'gemini-2.0-pro-exp-02-05',
'gemini-2.0-pro-exp',
'gemini-2.5-pro-exp',
'gemini-2.5-pro-exp-03-25'
]
export function isTextToImageModel(model: Model): boolean {
return TEXT_TO_IMAGE_REGEX.test(model.id)
}
@@ -1986,6 +2149,18 @@ export function isOpenAIoSeries(model: Model): boolean {
return ['o1', 'o1-2024-12-17'].includes(model.id) || model.id.includes('o3')
}
export function isSupportedResoningEffortModel(model?: Model): boolean {
if (!model) {
return false
}
if (model.id.includes('claude-3-7-sonnet') || model.id.includes('claude-3.7-sonnet') || isOpenAIoSeries(model)) {
return true
}
return false
}
export function isReasoningModel(model?: Model): boolean {
if (!model) {
return false
@@ -1999,6 +2174,10 @@ export function isReasoningModel(model?: Model): boolean {
return true
}
if (model.id.includes('gemini-2.5-pro-exp')) {
return true
}
return REASONING_REGEX.test(model.id) || model.type?.includes('reasoning') || false
}
@@ -2027,32 +2206,25 @@ export function isWebSearchModel(model: Model): boolean {
return false
}
if (provider.id === 'aihubmix') {
const models = ['gemini-2.0-flash-search', 'gemini-2.0-flash-exp-search', 'gemini-2.0-pro-exp-02-05-search']
return models.includes(model?.id)
}
if (provider?.type === 'openai') {
if (model?.id?.includes('gemini-2.0-flash-exp')) {
if (GEMINI_SEARCH_MODELS.includes(model?.id)) {
return true
}
}
if (provider.id === 'gemini' || provider?.type === 'gemini') {
const models = [
'gemini-2.0-flash',
'gemini-2.0-flash-exp',
'gemini-2.0-flash-001',
'gemini-2.0-pro-exp-02-05',
'gemini-2.0-pro-exp'
]
return models.includes(model?.id)
return GEMINI_SEARCH_MODELS.includes(model?.id)
}
if (provider.id === 'hunyuan') {
return model?.id !== 'hunyuan-lite'
}
if (provider.id === 'aihubmix') {
const models = ['gemini-2.0-flash-search', 'gemini-2.0-flash-exp-search', 'gemini-2.0-pro-exp-02-05-search']
return models.includes(model?.id)
}
if (provider.id === 'zhipu') {
return model?.id?.startsWith('glm-4-')
}

View File

@@ -1,12 +1,12 @@
import isPropValid from '@emotion/is-prop-valid'
import { ReactNode } from 'react'
import type { ReactNode } from 'react'
import { StyleSheetManager as StyledComponentsStyleSheetManager } from 'styled-components'
interface StyleSheetManagerProps {
children: ReactNode
}
const StyleSheetManager = ({ children }: StyleSheetManagerProps): JSX.Element => {
const StyleSheetManager = ({ children }: StyleSheetManagerProps): React.ReactElement => {
return (
<StyledComponentsStyleSheetManager
shouldForwardProp={(prop, element) => {

View File

@@ -1,16 +1,11 @@
import { useTheme } from '@renderer/context/ThemeProvider'
import { useMermaid } from '@renderer/hooks/useMermaid'
import { useSettings } from '@renderer/hooks/useSettings'
import { CodeStyleVarious, ThemeMode } from '@renderer/types'
import React, { createContext, PropsWithChildren, useContext, useEffect, useMemo, useState } from 'react'
import {
BundledLanguage,
bundledLanguages,
BundledTheme,
bundledThemes,
createHighlighter,
HighlighterGeneric
} from 'shiki'
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'
interface SyntaxHighlighterContextType {
codeToHtml: (code: string, language: string) => Promise<string>
@@ -51,42 +46,47 @@ export const SyntaxHighlighterProvider: React.FC<PropsWithChildren> = ({ childre
initHighlighter()
}, [highlighterTheme])
const codeToHtml = async (code: string, language: string) => {
if (!highlighter) return ''
const codeToHtml = useCallback(
async (_code: string, language: string) => {
{
if (!highlighter) return ''
const languageMap: Record<string, string> = {
vab: 'vb'
}
const languageMap: Record<string, string> = {
vab: 'vb'
}
const mappedLanguage = languageMap[language] || language
const mappedLanguage = languageMap[language] || language
code = code?.trimEnd() ?? ''
const escapedCode = code?.replace(/[<>]/g, (char) => ({ '<': '&lt;', '>': '&gt;' })[char]!)
const code = _code?.trimEnd() ?? ''
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 {
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>`
}
}
return highlighter.codeToHtml(code, {
lang: mappedLanguage,
theme: highlighterTheme
})
} catch (error) {
console.warn(`Error highlighting code for language '${mappedLanguage}':`, error)
return `<pre style="padding: 10px"><code>${escapedCode}</code></pre>`
}
}
},
[highlighter, highlighterTheme]
)
return highlighter.codeToHtml(code, {
lang: mappedLanguage,
theme: highlighterTheme
})
} catch (error) {
console.warn(`Error highlighting code for language '${mappedLanguage}':`, error)
return `<pre style="padding: 10px"><code>${escapedCode}</code></pre>`
}
}
return <SyntaxHighlighterContext.Provider value={{ codeToHtml }}>{children}</SyntaxHighlighterContext.Provider>
return <SyntaxHighlighterContext value={{ codeToHtml }}>{children}</SyntaxHighlighterContext>
}
export const useSyntaxHighlighter = () => {
const context = useContext(SyntaxHighlighterContext)
const context = use(SyntaxHighlighterContext)
if (!context) {
throw new Error('useSyntaxHighlighter must be used within a SyntaxHighlighterProvider')
}

View File

@@ -1,15 +1,17 @@
import { isMac } from '@renderer/config/constant'
import { useSettings } from '@renderer/hooks/useSettings'
import { ThemeMode } from '@renderer/types'
import React, { createContext, PropsWithChildren, useContext, useEffect, useState } from 'react'
import React, { createContext, PropsWithChildren, use, useEffect, useState } from 'react'
interface ThemeContextType {
theme: ThemeMode
settingTheme: ThemeMode
toggleTheme: () => void
}
const ThemeContext = createContext<ThemeContextType>({
theme: ThemeMode.light,
settingTheme: ThemeMode.light,
toggleTheme: () => {}
})
@@ -55,7 +57,7 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children, defaultT
}
})
return <ThemeContext.Provider value={{ theme: _theme, toggleTheme }}>{children}</ThemeContext.Provider>
return <ThemeContext value={{ theme: _theme, settingTheme: theme, toggleTheme }}>{children}</ThemeContext>
}
export const useTheme = () => useContext(ThemeContext)
export const useTheme = () => use(ThemeContext)

View File

@@ -11,14 +11,13 @@ import { useEffect } from 'react'
import { useDefaultModel } from './useAssistant'
import useFullScreenNotice from './useFullScreenNotice'
import { useInitMCPServers } from './useMCPServers'
import { useRuntime } from './useRuntime'
import { useSettings } from './useSettings'
import useUpdateHandler from './useUpdateHandler'
export function useAppInit() {
const dispatch = useAppDispatch()
const { proxyUrl, language, windowStyle, manualUpdateCheck, proxyMode, customCss } = useSettings()
const { proxyUrl, language, windowStyle, autoCheckUpdate, proxyMode, customCss } = useSettings()
const { minappShow } = useRuntime()
const { setDefaultModel, setTopicNamingModel, setTranslateModel } = useDefaultModel()
const avatar = useLiveQuery(() => db.settings.get('image://avatar'))
@@ -26,7 +25,6 @@ export function useAppInit() {
useUpdateHandler()
useFullScreenNotice()
useInitMCPServers()
useEffect(() => {
avatar?.value && dispatch(setAvatar(avatar.value))
@@ -36,13 +34,13 @@ export function useAppInit() {
document.getElementById('spinner')?.remove()
runAsyncFunction(async () => {
const { isPackaged } = await window.api.getAppInfo()
if (isPackaged && !manualUpdateCheck) {
if (isPackaged && autoCheckUpdate) {
await delay(2)
const { updateInfo } = await window.api.checkForUpdate()
dispatch(setUpdateState({ info: updateInfo }))
}
})
}, [dispatch, manualUpdateCheck])
}, [dispatch, autoCheckUpdate])
useEffect(() => {
if (proxyMode === 'system') {

View File

@@ -17,6 +17,7 @@ import {
} from '@renderer/store/assistants'
import { setDefaultModel, setTopicNamingModel, setTranslateModel } from '@renderer/store/llm'
import { Assistant, AssistantSettings, Model, Topic } from '@renderer/types'
import { useCallback } from 'react'
import { TopicManager } from './useTopic'
@@ -69,7 +70,10 @@ export function useAssistant(id: string) {
updateTopic: (topic: Topic) => dispatch(updateTopic({ assistantId: assistant.id, topic })),
updateTopics: (topics: Topic[]) => dispatch(updateTopics({ assistantId: assistant.id, topics })),
removeAllTopics: () => dispatch(removeAllTopics({ assistantId: assistant.id })),
setModel: (model: Model) => dispatch(setModel({ assistantId: assistant.id, model })),
setModel: useCallback(
(model: Model) => dispatch(setModel({ assistantId: assistant.id, model })),
[dispatch, assistant.id]
),
updateAssistant: (assistant: Assistant) => dispatch(updateAssistant(assistant)),
updateAssistantSettings: (settings: Partial<AssistantSettings>) => {
dispatch(updateAssistantSettings({ assistantId: assistant.id, settings }))

View File

@@ -1,7 +1,6 @@
import store, { useAppSelector } from '@renderer/store'
import { setMCPServers } from '@renderer/store/mcp'
import store, { useAppDispatch, useAppSelector } from '@renderer/store'
import { addMCPServer, deleteMCPServer, setMCPServers, updateMCPServer } from '@renderer/store/mcp'
import { MCPServer } from '@renderer/types'
import { useEffect } from 'react'
const ipcRenderer = window.electron.ipcRenderer
@@ -12,83 +11,17 @@ ipcRenderer.on('mcp:servers-changed', (_event, servers) => {
export const useMCPServers = () => {
const mcpServers = useAppSelector((state) => state.mcp.servers)
const activedMcpServers = useAppSelector((state) => state.mcp.servers?.filter((server) => server.isActive))
const addMCPServer = async (server: MCPServer) => {
try {
await window.api.mcp.addServer(server)
// Main process will send back updated servers via mcp:servers-changed
} catch (error) {
console.error('Failed to add MCP server:', error)
throw error
}
}
const updateMCPServer = async (server: MCPServer) => {
try {
await window.api.mcp.updateServer(server)
// Main process will send back updated servers via mcp:servers-changed
} catch (error) {
console.error('Failed to update MCP server:', error)
throw error
}
}
const deleteMCPServer = async (name: string) => {
try {
await window.api.mcp.deleteServer(name)
// Main process will send back updated servers via mcp:servers-changed
} catch (error) {
console.error('Failed to delete MCP server:', error)
throw error
}
}
const setMCPServerActive = async (name: string, isActive: boolean) => {
try {
await window.api.mcp.setServerActive(name, isActive)
// Main process will send back updated servers via mcp:servers-changed
} catch (error) {
console.error('Failed to set MCP server active status:', error)
throw error
}
}
const getActiveMCPServers = () => {
return mcpServers.filter((server) => server.isActive)
}
const activedMcpServers = mcpServers.filter((server) => server.isActive)
const dispatch = useAppDispatch()
return {
mcpServers,
activedMcpServers,
addMCPServer,
updateMCPServer,
deleteMCPServer,
setMCPServerActive,
getActiveMCPServers
addMCPServer: (server: MCPServer) => dispatch(addMCPServer(server)),
updateMCPServer: (server: MCPServer) => dispatch(updateMCPServer(server)),
deleteMCPServer: (id: string) => dispatch(deleteMCPServer(id)),
setMCPServerActive: (server: MCPServer, isActive: boolean) => dispatch(updateMCPServer({ ...server, isActive })),
getActiveMCPServers: () => mcpServers.filter((server) => server.isActive),
updateMcpServers: (servers: MCPServer[]) => dispatch(setMCPServers(servers))
}
}
export const useInitMCPServers = () => {
const mcpServers = useAppSelector((state) => state.mcp.servers)
// const dispatch = useAppDispatch()
// Send servers to main process when they change in Redux
useEffect(() => {
ipcRenderer.send('mcp:servers-from-renderer', mcpServers)
}, [mcpServers])
// Initial load of MCP servers from main process
// useEffect(() => {
// const loadServers = async () => {
// try {
// const servers = await window.api.mcp.listServers()
// dispatch(setMCPServers(servers))
// } catch (error) {
// console.error('Failed to load MCP servers:', error)
// }
// }
// loadServers()
// }, [dispatch])
}

View File

@@ -1,17 +1,19 @@
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { estimateMessageUsage } from '@renderer/services/TokenService'
import store, { useAppDispatch, useAppSelector } from '@renderer/store'
import {
clearStreamMessage,
clearTopicMessages,
commitStreamMessage,
deleteMessageAction,
resendMessage,
selectDisplayCount,
selectTopicLoading,
selectTopicMessages,
setStreamMessage,
setTopicLoading,
updateMessage,
updateMessages
updateMessages,
updateMessageThunk
} from '@renderer/store/messages'
import type { Assistant, Message, Topic } from '@renderer/types'
import { abortCompletion } from '@renderer/utils/abortController'
@@ -26,17 +28,15 @@ import { TopicManager } from './useTopic'
*/
export function useMessageOperations(topic: Topic) {
const dispatch = useAppDispatch()
const messages = useAppSelector((state) => selectTopicMessages(state, topic.id))
/**
* 删除单个消息
*/
const deleteMessage = useCallback(
async (message: Message) => {
const newMessages = messages.filter((m) => m.id !== message.id)
await dispatch(updateMessages(topic, newMessages))
async (id: string) => {
await dispatch(deleteMessageAction(topic, id))
},
[dispatch, topic, messages]
[dispatch, topic]
)
/**
@@ -44,10 +44,9 @@ export function useMessageOperations(topic: Topic) {
*/
const deleteGroupMessages = useCallback(
async (askId: string) => {
const newMessages = messages.filter((m) => m.askId !== askId)
await dispatch(updateMessages(topic, newMessages))
await dispatch(deleteMessageAction(topic, askId, 'askId'))
},
[dispatch, topic, messages]
[dispatch, topic]
)
/**
@@ -55,13 +54,17 @@ export function useMessageOperations(topic: Topic) {
*/
const editMessage = useCallback(
async (messageId: string, updates: Partial<Message>) => {
await dispatch(
updateMessage({
topicId: topic.id,
messageId,
updates
})
)
// 如果更新包含内容变更,重新计算 token
if ('content' in updates) {
const messages = store.getState().messages.messagesByTopic[topic.id]
const message = messages?.find((m) => m.id === messageId)
if (message) {
const updatedMessage = { ...message, ...updates }
const usage = await estimateMessageUsage(updatedMessage)
updates.usage = usage
}
}
await dispatch(updateMessageThunk(topic.id, messageId, updates))
},
[dispatch, topic.id]
)
@@ -148,7 +151,6 @@ export function useMessageOperations(topic: Topic) {
EventEmitter.emit(EVENT_NAMES.NEW_CONTEXT)
}, [])
const loading = useAppSelector((state) => selectTopicLoading(state, topic.id))
const displayCount = useAppSelector(selectDisplayCount)
// /**
// * 获取当前消息列表
@@ -200,8 +202,6 @@ export function useMessageOperations(topic: Topic) {
)
return {
messages,
loading,
displayCount,
updateMessages: updateMessagesAction,
deleteMessage,
@@ -219,3 +219,13 @@ export function useMessageOperations(topic: Topic) {
resumeMessage
}
}
export const useTopicMessages = (topic: Topic) => {
const messages = useAppSelector((state) => selectTopicMessages(state, topic.id))
return messages
}
export const useTopicLoading = (topic: Topic) => {
const loading = useAppSelector((state) => selectTopicLoading(state, topic.id))
return loading
}

View File

@@ -0,0 +1,117 @@
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings' // 使用设置中的值
import { useAppDispatch } from '@renderer/store'
import {
setCurrentMinappId,
setMinappShow,
setOpenedKeepAliveMinapps,
setOpenedOneOffMinapp
} from '@renderer/store/runtime'
import { MinAppType } from '@renderer/types'
/**
* Usage:
*
* To control the minapp popup, you can use the following hooks:
* import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
*
* in the component:
* const { openMinapp, openMinappKeepAlive, openMinappById,
* closeMinapp, hideMinappPopup, closeAllMinapps } = useMinappPopup()
*
* To use some key states of the minapp popup:
* import { useRuntime } from '@renderer/hooks/useRuntime'
* const { openedKeepAliveMinapps, openedOneOffMinapp, minappShow } = useRuntime()
*/
export const useMinappPopup = () => {
const dispatch = useAppDispatch()
const { openedKeepAliveMinapps, openedOneOffMinapp, minappShow } = useRuntime()
const { maxKeepAliveMinapps } = useSettings() // 使用设置中的值
/** Open a minapp (popup shows and minapp loaded) */
const openMinapp = (app: MinAppType, keepAlive: boolean = false) => {
if (keepAlive) {
// 如果小程序已经打开,只切换显示
if (openedKeepAliveMinapps.some((item) => item.id === app.id)) {
dispatch(setCurrentMinappId(app.id))
dispatch(setMinappShow(true))
return
}
// 如果缓存数量未达上限,添加到缓存列表
if (openedKeepAliveMinapps.length < maxKeepAliveMinapps) {
dispatch(setOpenedKeepAliveMinapps([app, ...openedKeepAliveMinapps]))
} else {
// 缓存数量达到上限,移除最后一个,添加新的
dispatch(setOpenedKeepAliveMinapps([app, ...openedKeepAliveMinapps.slice(0, maxKeepAliveMinapps - 1)]))
}
dispatch(setOpenedOneOffMinapp(null))
dispatch(setCurrentMinappId(app.id))
dispatch(setMinappShow(true))
return
}
//if the minapp is not keep alive, open it as one-off minapp
dispatch(setOpenedOneOffMinapp(app))
dispatch(setCurrentMinappId(app.id))
dispatch(setMinappShow(true))
return
}
/** a wrapper of openMinapp(app, true) */
const openMinappKeepAlive = (app: MinAppType) => {
openMinapp(app, true)
}
/** Open a minapp by id (look up the minapp in DEFAULT_MIN_APPS) */
const openMinappById = (id: string, keepAlive: boolean = false) => {
import('@renderer/config/minapps').then(({ DEFAULT_MIN_APPS }) => {
const app = DEFAULT_MIN_APPS.find((app) => app?.id === id)
if (app) {
openMinapp(app, keepAlive)
}
})
}
/** Close a minapp immediately (popup hides and minapp unloaded) */
const closeMinapp = (appid: string) => {
if (openedKeepAliveMinapps.some((item) => item.id === appid)) {
dispatch(setOpenedKeepAliveMinapps(openedKeepAliveMinapps.filter((item) => item.id !== appid)))
} else if (openedOneOffMinapp?.id === appid) {
dispatch(setOpenedOneOffMinapp(null))
}
dispatch(setCurrentMinappId(''))
dispatch(setMinappShow(false))
return
}
/** Close all minapps (popup hides and all minapps unloaded) */
const closeAllMinapps = () => {
dispatch(setOpenedKeepAliveMinapps([]))
dispatch(setOpenedOneOffMinapp(null))
dispatch(setCurrentMinappId(''))
dispatch(setMinappShow(false))
}
/** Hide the minapp popup (only one-off minapp unloaded) */
const hideMinappPopup = () => {
if (!minappShow) return
if (openedOneOffMinapp) {
dispatch(setOpenedOneOffMinapp(null))
dispatch(setCurrentMinappId(''))
}
dispatch(setMinappShow(false))
}
return {
openMinapp,
openMinappKeepAlive,
openMinappById,
closeMinapp,
hideMinappPopup,
closeAllMinapps
}
}

View File

@@ -0,0 +1,24 @@
import { useCallback } from 'react'
export function useNutstoreSSO() {
const nutstoreSSOHandler = useCallback(() => {
return new Promise<string>((resolve, reject) => {
const removeListener = window.api.protocol.onReceiveData(async (data) => {
try {
const url = new URL(data.url)
const params = new URLSearchParams(url.search)
const encryptedToken = params.get('s')
if (!encryptedToken) return reject(null)
resolve(encryptedToken)
} catch (error) {
console.error('解析URL失败:', error)
reject(null)
} finally {
removeListener()
}
})
})
}, [])
return nutstoreSSOHandler
}

View File

@@ -11,6 +11,8 @@ import { useEffect, useState } from 'react'
import { useAssistant } from './useAssistant'
import { getStoreSetting } from './useSettings'
const renamingTopics = new Set<string>()
let _activeTopic: Topic
let _setActiveTopic: (topic: Topic) => void
@@ -54,35 +56,45 @@ export async function getTopicById(topicId: string) {
}
export const autoRenameTopic = async (assistant: Assistant, topicId: string) => {
const topic = await getTopicById(topicId)
const enableTopicNaming = getStoreSetting('enableTopicNaming')
if (isEmpty(topic.messages)) {
if (renamingTopics.has(topicId)) {
return
}
if (topic.isNameManuallyEdited) {
return
}
try {
renamingTopics.add(topicId)
if (!enableTopicNaming) {
const topicName = topic.messages[0]?.content.substring(0, 50)
if (topicName) {
const data = { ...topic, name: topicName } as Topic
_setActiveTopic(data)
store.dispatch(updateTopic({ assistantId: assistant.id, topic: data }))
const topic = await getTopicById(topicId)
const enableTopicNaming = getStoreSetting('enableTopicNaming')
if (isEmpty(topic.messages)) {
return
}
return
}
if (topic && topic.name === i18n.t('chat.default.topic.name') && topic.messages.length >= 2) {
const { fetchMessagesSummary } = await import('@renderer/services/ApiService')
const summaryText = await fetchMessagesSummary({ messages: topic.messages, assistant })
if (summaryText) {
const data = { ...topic, name: summaryText }
_setActiveTopic(data)
store.dispatch(updateTopic({ assistantId: assistant.id, topic: data }))
if (topic.isNameManuallyEdited) {
return
}
if (!enableTopicNaming) {
const topicName = topic.messages[0]?.content.substring(0, 50)
if (topicName) {
const data = { ...topic, name: topicName } as Topic
_setActiveTopic(data)
store.dispatch(updateTopic({ assistantId: assistant.id, topic: data }))
}
return
}
if (topic && topic.name === i18n.t('chat.default.topic.name') && topic.messages.length >= 2) {
const { fetchMessagesSummary } = await import('@renderer/services/ApiService')
const summaryText = await fetchMessagesSummary({ messages: topic.messages, assistant })
if (summaryText) {
const data = { ...topic, name: summaryText }
_setActiveTopic(data)
store.dispatch(updateTopic({ assistantId: assistant.id, topic: data }))
}
}
} finally {
renamingTopics.delete(topicId)
}
}

View File

@@ -32,7 +32,9 @@
"title": "Agents"
},
"assistants": {
"title": "Assistants",
"abbr": "Assistant",
"settings.title": "Assistant Settings",
"clear.content": "Clearing the topic will delete all topics and files in the assistant. Are you sure you want to continue?",
"clear.title": "Clear topics",
"copy.title": "Copy Assistant",
@@ -44,6 +46,11 @@
"search": "Search assistants...",
"settings.default_model": "Default Model",
"settings.knowledge_base": "Knowledge Base Settings",
"settings.mcp": "MCP Servers",
"settings.mcp.enableFirst": "Enable this server in MCP settings first",
"settings.mcp.title": "MCP Settings",
"settings.mcp.noServersAvailable": "No MCP servers available. Add servers in settings",
"settings.mcp.description": "Default enabled MCP servers",
"settings.model": "Model Settings",
"settings.preset_messages": "Preset Messages",
"settings.prompt": "Prompt Settings",
@@ -53,7 +60,7 @@
"settings.reasoning_effort.medium": "medium",
"settings.reasoning_effort.off": "off",
"settings.reasoning_effort.tip": "Only supports OpenAI o-series and Anthropic reasoning models",
"title": "Assistants"
"settings.more": "Assistant Settings"
},
"auth": {
"error": "API key automatically obtained failed, please get it manually",
@@ -143,7 +150,10 @@
"history": "Chat History",
"last": "Already at the last message",
"next": "Next Message",
"prev": "Previous Message"
"prev": "Previous Message",
"top": "Back to top",
"bottom": "Back to bottom",
"close": "Close"
},
"resend": "Resend",
"save": "Save",
@@ -180,13 +190,16 @@
"topics.export.md": "Export as markdown",
"topics.export.notion": "Export to Notion",
"topics.export.obsidian": "Export to Obsidian",
"topics.export.obsidian_vault": "Vault",
"topics.export.obsidian_vault_placeholder": "Please select the vault name",
"topics.export.obsidian_path": "Path",
"topics.export.obsidian_path_placeholder": "Please select the path",
"topics.export.obsidian_atributes": "Configure Note Attributes",
"topics.export.obsidian_btn": "Confirm",
"topics.export.obsidian_created": "Creation Time",
"topics.export.obsidian_created_placeholder": "Please select the creation time",
"topics.export.obsidian_export_failed": "Export failed",
"topics.export.obsidian_export_success": "Export success",
"topics.export.obsidian_not_configured": "Obsidian not configured",
"topics.export.obsidian_operate": "Operation Method",
"topics.export.obsidian_operate_append": "Append",
"topics.export.obsidian_operate_new_or_overwrite": "Create New (Overwrite if it exists)",
@@ -199,6 +212,13 @@
"topics.export.obsidian_title": "Title",
"topics.export.obsidian_title_placeholder": "Please enter the title",
"topics.export.obsidian_title_required": "The title cannot be empty",
"topics.export.obsidian_no_vaults": "No Obsidian vaults found",
"topics.export.obsidian_loading": "Loading...",
"topics.export.obsidian_fetch_error": "Failed to fetch Obsidian vaults",
"topics.export.obsidian_fetch_folders_error": "Failed to fetch folder structure",
"topics.export.obsidian_no_vault_selected": "Please select a vault first",
"topics.export.obsidian_select_vault_first": "Please select a vault first",
"topics.export.obsidian_root_directory": "Root Directory",
"topics.export.title": "Export",
"topics.export.word": "Export as Word",
"topics.export.yuque": "Export to Yuque",
@@ -211,7 +231,11 @@
"topics.prompt.tips": "Topic Prompts: Additional supplementary prompts provided for the current topic",
"topics.title": "Topics",
"topics.unpinned": "Unpinned Topics",
"translate": "Translate"
"translate": "Translate",
"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"
},
"code_block": {
"collapse": "Collapse",
@@ -262,7 +286,13 @@
"select": "Select",
"topics": "Topics",
"warning": "Warning",
"you": "You"
"you": "You",
"variable_name": "Variable Name",
"value": "Value",
"no_variables_added": "No variables added",
"insert_variable_into_prompt": "Insert variable into prompt",
"variables": "Variables",
"variables_help": "Add variables that need to be replaced in the text, triggered by {{variable_name}} in the replacement document"
},
"docs": {
"title": "Docs"
@@ -288,7 +318,8 @@
"description": "Failed to render formula. Please check if the formula format is correct",
"title": "Render Error"
},
"user_message_not_found": "Cannot find original user message to resend"
"user_message_not_found": "Cannot find original user message to resend",
"unknown": "Unknown error"
},
"export": {
"assistant": "Assistant",
@@ -469,6 +500,8 @@
"error.invalid.webdav": "Invalid WebDAV settings",
"error.joplin.export": "Failed to export to Joplin. Please keep Joplin running and check connection status or configuration",
"error.joplin.no_config": "Joplin Authorization Token or URL is not configured",
"error.invalid.nutstore": "Invalid Nutstore settings",
"error.invalid.nutstore_token": "Invalid Nutstore Token",
"error.markdown.export.preconf": "Failed to export the Markdown file to the preconfigured path",
"error.markdown.export.specified": "Failed to export the Markdown file",
"error.notion.export": "Failed to export to Notion. Please check connection status and configuration according to documentation",
@@ -518,11 +551,27 @@
"upgrade.success.content": "Please restart the application to complete the upgrade",
"upgrade.success.title": "Upgrade successfully",
"warn.notion.exporting": "Exporting to Notion, please do not request export repeatedly!",
"warning.rate.limit": "Too many requests. Please wait {{seconds}} seconds before trying again."
"warning.rate.limit": "Too many requests. Please wait {{seconds}} seconds before trying again.",
"error.siyuan.export": "Failed to export to Siyuan Note, please check connection status and configuration according to documentation",
"error.siyuan.no_config": "Siyuan Note API address or token is not configured",
"success.siyuan.export": "Successfully exported to Siyuan Note",
"warn.yuque.exporting": "Exporting to Yuque, please do not request export repeatedly!",
"warn.siyuan.exporting": "Exporting to Siyuan Note, please do not request export repeatedly!"
},
"minapp": {
"popup": {
"refresh": "Refresh",
"close": "Close MinApp",
"minimize": "Minimize MinApp",
"devtools": "Developer Tools",
"openExternal": "Open in Browser",
"rightclick_copyurl": "Right-click to copy URL"
},
"sidebar.add.title": "Add to sidebar",
"sidebar.remove.title": "Remove from sidebar",
"sidebar.close.title": "Close",
"sidebar.closeall.title": "Close All",
"sidebar.hide.title": "Hide MinApp",
"title": "MinApp"
},
"miniwindow": {
@@ -537,15 +586,19 @@
},
"footer": {
"copy_last_message": "Press C to copy",
"esc": "Press ESC {{action}}",
"esc_back": "back",
"esc_close": "close the window"
"backspace_clear": "Backspace to clear",
"esc": "ESC to {{action}}",
"esc_back": "return",
"esc_close": "close"
},
"input": {
"placeholder": {
"empty": "Ask {{model}} for help...",
"title": "What do you want to do with this text?"
}
},
"tooltip": {
"pin": "Keep Window on Top"
}
},
"models": {
@@ -786,15 +839,6 @@
"notion.split_size_help": "Recommended: 90 for Free plan, 24990 for Plus plan, default is 90",
"notion.split_size_placeholder": "Enter block limit per page (default 90)",
"notion.title": "Notion Configuration",
"obsidian": {
"folder": "Folder",
"folder_placeholder": "Please enter the folder name",
"tags": "Global Tags",
"tags_placeholder": "Please enter the tag name, separate multiple tags with commas",
"title": "Obsidian Configuration",
"vault": "Vault",
"vault_placeholder": "Please enter the vault name"
},
"title": "Data Settings",
"webdav": {
"autoSync": "Auto Backup",
@@ -839,16 +883,66 @@
"title": "Yuque Configuration",
"token": "Yuque Token",
"token_placeholder": "Please enter the Yuque Token"
}
},
"obsidian": {
"title": "Obsidian Configuration",
"default_vault": "Default Obsidian Vault",
"default_vault_placeholder": "Please select the default Obsidian vault",
"default_vault_loading": "Loading Obsidian vault...",
"default_vault_no_vaults": "No Obsidian vaults found",
"default_vault_fetch_error": "Failed to fetch Obsidian vault",
"default_vault_export_failed": "Export failed"
},
"siyuan": {
"title": "Siyuan Note Configuration",
"api_url": "Siyuan Note API URL",
"api_url_placeholder": "e.g.: http://127.0.0.1:6806",
"token": "Siyuan Note Token",
"token.help": "Get Siyuan Note Token",
"token_placeholder": "Please enter Siyuan Note Token",
"box_id": "Siyuan Note Box ID",
"box_id_placeholder": "Please enter Siyuan Note Box ID",
"root_path": "Siyuan Note Root Path",
"root_path_placeholder": "e.g.: /CherryStudio",
"check": {
"title": "Connection Check",
"button": "Check",
"empty_config": "Please fill in the API address and token",
"success": "Connection successful",
"fail": "Connection failed, please check API address and token",
"error": "Connection error, please check network connection"
}
},
"nutstore": {
"title": "Nutstore Configuration",
"isLogin": "Logged in",
"notLogin": "Not logged in",
"login.button": "Login",
"logout.button": "Logout",
"logout.title": "Are you sure you want to logout from Nutstore?",
"logout.content": "After logout, you will not be able to backup to Nutstore or restore from Nutstore.",
"checkConnection.name": "Check Connection",
"checkConnection.success": "Connected to Nutstore",
"checkConnection.fail": "Nutstore connection failed",
"username": "Nutstore Username",
"path": "Nutstore Storage Path",
"path.placeholder": "Enter Nutstore storage path",
"backup.button": "Backup to Nutstore",
"restore.button": "Restore from Nutstore",
"pathSelector.title": "Nutstore Storage Path",
"pathSelector.return": "Return",
"pathSelector.currentPath": "Current Path",
"new_folder.button.confirm": "Confirm",
"new_folder.button.cancel": "Cancel",
"new_folder.button": "New Folder"
},
"message_title.use_topic_naming.title": "Use topic naming model to create titles for exported messages",
"message_title.use_topic_naming.help": "When enabled, use topic naming model to create titles for exported messages. This will also affect all Markdown export methods."
},
"display.assistant.title": "Assistant Settings",
"display.custom.css": "Custom CSS",
"display.custom.css.cherrycss": "Get from cherrycss.com",
"display.custom.css.placeholder": "/* Put custom CSS here */",
"display.minApp.disabled": "Hidden MinApp",
"display.minApp.empty": "Drag minApp from the left to hide them here",
"display.minApp.title": "MinApp Settings",
"display.minApp.visible": "Visible MinApp",
"display.sidebar.chat.hiddenMessage": "Assistants are basic functions, not supported for hiding",
"display.sidebar.disabled": "Hide icons",
"display.sidebar.empty": "Drag the hidden feature from the left side here",
@@ -861,6 +955,20 @@
"display.sidebar.visible": "Show icons",
"display.title": "Display Settings",
"display.topic.title": "Topic Settings",
"miniapps": {
"title": "Mini Apps Settings",
"disabled": "Hidden Mini Apps",
"empty": "Drag mini apps from the left to hide them",
"visible": "Visible Mini Apps",
"cache_settings": "Cache Settings",
"cache_title": "Mini App Cache Limit",
"cache_description": "Set the maximum number of active mini apps to keep in memory",
"reset_tooltip": "Reset to default",
"display_title": "Mini App Display Settings",
"sidebar_title": "Sidebar Active Mini Apps Display",
"sidebar_description": "Show active mini apps in the sidebar",
"cache_change_notice": "Changes will take effect when the number of open mini apps reaches the set value"
},
"font_size.title": "Message font size",
"general": "General Settings",
"general.avatar.reset": "Reset Avatar",
@@ -869,7 +977,7 @@
"general.display.title": "Display Settings",
"general.emoji_picker": "Emoji Picker",
"general.image_upload": "Image Upload",
"general.manually_check_update.title": "Turn off update checking",
"general.auto_check_update.title": "Auto update checking",
"general.reset.button": "Reset",
"general.reset.title": "Data Reset",
"general.restore.button": "Restore",
@@ -897,10 +1005,7 @@
"argsTooltip": "Each argument on a new line",
"baseUrlTooltip": "Remote server base URL",
"command": "Command",
"commandRequired": "Please enter a command",
"config_description": "Configure Model Context Protocol servers",
"confirmDelete": "Delete Server",
"confirmDeleteMessage": "Are you sure you want to delete the server?",
"deleteError": "Failed to delete server",
"deleteSuccess": "Server deleted successfully",
"dependenciesInstall": "Install Dependencies",
@@ -911,7 +1016,8 @@
"editServer": "Edit Server",
"env": "Environment Variables",
"envTooltip": "Format: KEY=value, one per line",
"findMore": "Find More MCP Servers",
"findMore": "Find More MCP",
"searchNpx": "Search MCP",
"install": "Install",
"installError": "Failed to install dependencies",
"installSuccess": "Dependencies installed successfully",
@@ -921,8 +1027,8 @@
"jsonSaveSuccess": "JSON configuration has been saved.",
"missingDependencies": "is Missing, please install it to continue.",
"name": "Name",
"nameRequired": "Please enter a server name",
"noServers": "No servers configured",
"newServer": "MCP Server",
"npx_list": {
"actions": "Actions",
"desc": "Search and add npm packages as MCP servers",
@@ -938,14 +1044,29 @@
"usage": "Usage",
"version": "Version"
},
"errors": {
"32000": "MCP server failed to start, please check the parameters according to the tutorial"
},
"serverPlural": "servers",
"serverSingular": "server",
"title": "MCP Servers",
"toggleError": "Toggle failed",
"startError": "Start failed",
"type": "Type",
"updateError": "Failed to update server",
"updateSuccess": "Server updated successfully",
"url": "URL"
"url": "URL",
"editMcpJson": "Edit MCP Configuration",
"installHelp": "Get Installation Help",
"tools": {
"inputSchema": "Input Schema",
"availableTools": "Available Tools",
"noToolsAvailable": "No tools available"
},
"deleteServer": "Delete Server",
"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"
},
"messages.divider": "Show divider between messages",
"messages.grid_columns": "Message grid display columns",
@@ -1057,7 +1178,7 @@
"docs_more_details": "for more details",
"get_api_key": "Get API Key",
"is_not_support_array_content": "Enable compatible mode",
"no_models": "Please add models first before checking the API connection",
"no_models_for_check": "No models available for checking (e.g. chat models)",
"not_checked": "Not Checked",
"remove_duplicate_keys": "Remove Duplicate Keys",
"remove_invalid_keys": "Remove Invalid Keys",
@@ -1095,7 +1216,7 @@
"reset_defaults_confirm": "Are you sure you want to reset all shortcuts?",
"reset_to_default": "Reset to Default",
"search_message": "Search Message",
"show_app": "Show App",
"show_app": "Show/Hide App",
"show_settings": "Open Settings",
"title": "Keyboard Shortcuts",
"toggle_new_context": "Clear Context",

View File

@@ -32,7 +32,9 @@
"title": "エージェント"
},
"assistants": {
"title": "アシスタント",
"abbr": "アシスタント",
"settings.title": "アシスタント設定",
"clear.content": "トピックをクリアすると、アシスタント内のすべてのトピックとファイルが削除されます。続行しますか?",
"clear.title": "トピックをクリア",
"copy.title": "アシスタントをコピー",
@@ -42,6 +44,11 @@
"save.success": "保存に成功しました",
"save.title": "エージェントに保存",
"search": "アシスタントを検索...",
"settings.mcp": "MCP サーバー",
"settings.mcp.enableFirst": "まず MCP 設定でこのサーバーを有効にしてください",
"settings.mcp.title": "MCP 設定",
"settings.mcp.noServersAvailable": "利用可能な MCP サーバーがありません。設定でサーバーを追加してください",
"settings.mcp.description": "デフォルトで有効な MCP サーバー",
"settings.default_model": "デフォルトモデル",
"settings.knowledge_base": "ナレッジベース設定",
"settings.model": "モデル設定",
@@ -53,7 +60,7 @@
"settings.reasoning_effort.medium": "中程度",
"settings.reasoning_effort.off": "オフ",
"settings.reasoning_effort.tip": "OpenAIのoシリーズとAnthropicの推論モデルのみサポートしています",
"title": "アシスタント"
"settings.more": "アシスタント設定"
},
"auth": {
"error": "APIキーの自動取得に失敗しました。手動で取得してください",
@@ -143,7 +150,10 @@
"history": "チャット履歴",
"last": "最後のメッセージです",
"next": "次のメッセージ",
"prev": "前のメッセージ"
"prev": "前のメッセージ",
"top": "トップに戻る",
"bottom": "下部に戻る",
"close": "閉じる"
},
"resend": "再送信",
"save": "保存",
@@ -180,13 +190,16 @@
"topics.export.md": "Markdownとしてエクスポート",
"topics.export.notion": "Notion にエクスポート",
"topics.export.obsidian": "Obsidian にエクスポート",
"topics.export.obsidian_vault": "保管庫",
"topics.export.obsidian_vault_placeholder": "保管庫名を選択してください",
"topics.export.obsidian_path": "パス",
"topics.export.obsidian_path_placeholder": "パスを選択してください",
"topics.export.obsidian_atributes": "ノートの属性を設定",
"topics.export.obsidian_btn": "確定",
"topics.export.obsidian_created": "作成日時",
"topics.export.obsidian_created_placeholder": "作成日時を選択してください",
"topics.export.obsidian_export_failed": "エクスポート失敗",
"topics.export.obsidian_export_success": "エクスポート成功",
"topics.export.obsidian_not_configured": "Obsidian 未設定",
"topics.export.obsidian_operate": "処理方法",
"topics.export.obsidian_operate_append": "追加",
"topics.export.obsidian_operate_new_or_overwrite": "新規作成(既に存在する場合は上書き)",
@@ -199,6 +212,13 @@
"topics.export.obsidian_title": "タイトル",
"topics.export.obsidian_title_placeholder": "タイトルを入力してください",
"topics.export.obsidian_title_required": "タイトルは空白にできません",
"topics.export.obsidian_no_vaults": "Obsidianの保管庫が見つかりません",
"topics.export.obsidian_loading": "読み込み中...",
"topics.export.obsidian_fetch_error": "Obsidianの保管庫の取得に失敗しました",
"topics.export.obsidian_fetch_folders_error": "フォルダ構造の取得に失敗しました",
"topics.export.obsidian_no_vault_selected": "保管庫を選択してください",
"topics.export.obsidian_select_vault_first": "最初に保管庫を選択してください",
"topics.export.obsidian_root_directory": "ルートディレクトリ",
"topics.export.title": "エクスポート",
"topics.export.word": "Wordとしてエクスポート",
"topics.export.yuque": "語雀にエクスポート",
@@ -211,7 +231,11 @@
"topics.prompt.tips": "トピック提示語:現在のトピックに対して追加の補足提示語を提供",
"topics.title": "トピック",
"topics.unpinned": "固定解除",
"translate": "翻訳"
"translate": "翻訳",
"topics.export.siyuan": "思源笔记にエクスポート",
"topics.export.wait_for_title_naming": "タイトルを生成中...",
"topics.export.title_naming_success": "タイトルの生成に成功しました",
"topics.export.title_naming_failed": "タイトルの生成に失敗しました。デフォルトのタイトルを使用します"
},
"code_block": {
"collapse": "折りたたむ",
@@ -262,7 +286,13 @@
"select": "選択",
"topics": "トピック",
"warning": "警告",
"you": "あなた"
"you": "あなた",
"variable_name": "変数名",
"value": "値",
"no_variables_added": "変数がありません",
"insert_variable_into_prompt": "プロンプトに変数を挿入",
"variables": "変数",
"variables_help": "テキスト内で置換が必要な変数を追加し、置換ドキュメント内で{{variable_name}}の形式でトリガーします"
},
"docs": {
"title": "ドキュメント"
@@ -288,7 +318,8 @@
"description": "数式のレンダリングに失敗しました。数式の形式が正しいか確認してください",
"title": "レンダリングエラー"
},
"user_message_not_found": "元のユーザーメッセージを見つけることができませんでした"
"user_message_not_found": "元のユーザーメッセージを見つけることができませんでした",
"unknown": "不明なエラー"
},
"export": {
"assistant": "アシスタント",
@@ -469,12 +500,13 @@
"error.invalid.webdav": "無効なWebDAV設定",
"error.joplin.export": "Joplin へのエクスポートに失敗しました。Joplin が実行中であることを確認してください",
"error.joplin.no_config": "Joplin 認証トークン または URL が設定されていません",
"error.invalid.nutstore": "無効なNutstore設定です",
"error.invalid.nutstore_token": "無効なNutstoreトークンです",
"error.markdown.export.preconf": "Markdown ファイルを事前設定されたパスにエクスポートできませんでした",
"error.markdown.export.specified": "Markdown ファイルのエクスポートに失敗しました",
"error.notion.export": "Notionへのエクスポートに失敗しました。接続状態と設定を確認してください",
"error.notion.no_api_key": "Notion ApiKey または Notion DatabaseID が設定されていません",
"error.yuque.export": "語雀へのエクスポートに失敗しました。接続状態と設定を確認してください",
"error.yuque.no_config": "語雀Token または 知識ベースID が設定されていません",
"group.delete.content": "分組メッセージを削除するとユーザーの質問と助け手の回答がすべて削除されます",
"group.delete.title": "分組メッセージを削除",
"ignore.knowledge.base": "インターネットモードが有効になっています。ナレッジベースを無視します",
@@ -518,11 +550,28 @@
"upgrade.success.content": "アップグレードを完了するためにアプリケーションを再起動してください",
"upgrade.success.title": "アップグレードに成功しました",
"warn.notion.exporting": "Notionにエクスポート中です。重複してエクスポートしないでください! ",
"warning.rate.limit": "送信が頻繁すぎます。{{seconds}} 秒待ってから再試行してください。"
"warning.rate.limit": "送信が頻繁すぎます。{{seconds}} 秒待ってから再試行してください。",
"error.siyuan.export": "思源ノートのエクスポートに失敗しました。接続状態を確認し、ドキュメントに従って設定を確認してください",
"error.siyuan.no_config": "思源ートのAPIアドレスまたはトークンが設定されていません",
"success.siyuan.export": "思源ノートへのエクスポートに成功しました",
"warn.yuque.exporting": "語雀にエクスポート中です。重複してエクスポートしないでください!",
"warn.siyuan.exporting": "思源ノートにエクスポート中です。重複してエクスポートしないでください!",
"error.yuque.no_config": "語雀のAPIアドレスまたはトークンが設定されていません"
},
"minapp": {
"popup": {
"refresh": "更新",
"close": "ミニアプリを閉じる",
"minimize": "ミニアプリを最小化",
"devtools": "開発者ツール",
"openExternal": "ブラウザで開く",
"rightclick_copyurl": "右クリックでURLをコピー"
},
"sidebar.add.title": "サイドバーに追加",
"sidebar.remove.title": "サイドバーから削除",
"sidebar.close.title": "閉じる",
"sidebar.closeall.title": "すべて閉じる",
"sidebar.hide.title": "ミニアプリを隠す",
"title": "ミニアプリ"
},
"miniwindow": {
@@ -539,13 +588,17 @@
"copy_last_message": "C キーを押してコピー",
"esc": "ESC キーを押して{{action}}",
"esc_back": "戻る",
"esc_close": "ウィンドウを閉じる"
"esc_close": "ウィンドウを閉じる",
"backspace_clear": "バックスペースを押してクリアします"
},
"input": {
"placeholder": {
"empty": "{{model}} に質問してください...",
"title": "下のテキストに対して何をしますか?"
}
},
"tooltip": {
"pin": "上部ウィンドウ"
}
},
"models": {
@@ -786,15 +839,6 @@
"notion.split_size_help": "Notion無料版ユーザーは90、有料版ユーザーは24990、デフォルトは90",
"notion.split_size_placeholder": "ページごとのブロック数制限を入力してください(デフォルト90)",
"notion.title": "Notion 設定",
"obsidian": {
"folder": "フォルダー",
"folder_placeholder": "フォルダーの名前を入力してください",
"tags": "グローバルタグ",
"tags_placeholder": "タグの名前を入力してください。複数のタグは英語のコンマで区切ってください",
"title": "Obsidian の設定",
"vault": "ヴォールト(保管庫)",
"vault_placeholder": "保管庫の名前を入力してください"
},
"title": "データ設定",
"webdav": {
"autoSync": "自動バックアップ",
@@ -839,16 +883,66 @@
"title": "Yuque設定",
"token": "Yuqueトークン",
"token_placeholder": "Yuqueトークンを入力してください"
}
},
"obsidian": {
"title": "Obsidian 設定",
"default_vault": "デフォルトの Obsidian 保管庫",
"default_vault_placeholder": "デフォルトの Obsidian 保管庫を選択してください",
"default_vault_loading": "Obsidian 保管庫を取得中...",
"default_vault_no_vaults": "Obsidian 保管庫が見つかりません",
"default_vault_fetch_error": "Obsidian 保管庫の取得に失敗しました",
"default_vault_export_failed": "エクスポートに失敗しました"
},
"siyuan": {
"title": "思源ノート設定",
"api_url": "APIアドレス",
"api_url_placeholder": "例http://127.0.0.1:6806",
"token": "APIトークン",
"token.help": "思源ノート->設定->について で取得",
"token_placeholder": "思源ノートトークンを入力してください",
"box_id": "ートブックID",
"box_id_placeholder": "ートブックIDを入力してください",
"root_path": "ドキュメントルートパス",
"root_path_placeholder": "例:/CherryStudio",
"check": {
"title": "接続チェック",
"button": "チェック",
"empty_config": "APIアドレスとトークンを入力してください",
"success": "接続成功",
"fail": "接続失敗、APIアドレスとトークンを確認してください",
"error": "接続エラー、ネットワーク接続を確認してください"
}
},
"nutstore": {
"title": "Nutstore設定",
"isLogin": "ログイン済み",
"notLogin": "未ログイン",
"login.button": "ログイン",
"logout.button": "ログアウト",
"logout.title": "Nutstoreからログアウトしますか",
"logout.content": "ログアウト後、Nutstoreへのバックアップや復元ができなくなります。",
"checkConnection.name": "接続確認",
"checkConnection.success": "Nutstoreに接続しました",
"checkConnection.fail": "Nutstore接続に失敗しました",
"username": "Nutstoreユーザー名",
"path": "Nutstoreストレージパス",
"path.placeholder": "Nutstoreストレージパスを入力",
"backup.button": "Nutstoreにバックアップ",
"restore.button": "Nutstoreから復元",
"pathSelector.title": "Nutstoreストレージパス",
"pathSelector.return": "戻る",
"pathSelector.currentPath": "現在のパス",
"new_folder.button.confirm": "確認",
"new_folder.button.cancel": "キャンセル",
"new_folder.button": "新しいフォルダー"
},
"message_title.use_topic_naming.title": "トピック命名モデルを使用してメッセージのタイトルを作成",
"message_title.use_topic_naming.help": "この設定は、すべてのMarkdownエクスポート方法に影響します。"
},
"display.assistant.title": "アシスタント設定",
"display.custom.css": "カスタムCSS",
"display.custom.css.cherrycss": "cherrycss.comから取得",
"display.custom.css.placeholder": "/* ここにカスタムCSSを入力 */",
"display.minApp.disabled": "非表示ミニプログラム",
"display.minApp.empty": "非表示にしたいアプレットを左からここまでドラッグします",
"display.minApp.title": "ミニプログラム表示設定",
"display.minApp.visible": "表示中ミニプログラム",
"display.sidebar.chat.hiddenMessage": "アシスタントは基本的な機能であり、非表示はサポートされていません",
"display.sidebar.disabled": "アイコンを非表示",
"display.sidebar.empty": "非表示にする機能を左側からここにドラッグ",
@@ -861,6 +955,20 @@
"display.sidebar.visible": "アイコンを表示",
"display.title": "表示設定",
"display.topic.title": "トピック設定",
"miniapps": {
"title": "ミニアプリ設定",
"disabled": "非表示のミニアプリ",
"empty": "非表示にするミニアプリを左側からここにドラッグしてください",
"visible": "表示するミニアプリ",
"cache_settings": "キャッシュ設定",
"cache_title": "ミニアプリのキャッシュ数",
"cache_description": "メモリに保持するアクティブなミニアプリの最大数を設定します",
"reset_tooltip": "デフォルト値にリセット",
"display_title": "ミニアプリ表示設定",
"sidebar_title": "サイドバーのアクティブなミニアプリ表示",
"sidebar_description": "サイドバーにアクティブなミニアプリを表示するかどうかを設定します",
"cache_change_notice": "設定値に達するまでミニアプリの開閉が行われた後に変更が適用されます"
},
"font_size.title": "メッセージのフォントサイズ",
"general": "一般設定",
"general.avatar.reset": "アバターをリセット",
@@ -869,7 +977,6 @@
"general.display.title": "表示設定",
"general.emoji_picker": "絵文字ピッカー",
"general.image_upload": "画像アップロード",
"general.manually_check_update.title": "更新チェックを無効にする",
"general.reset.button": "リセット",
"general.reset.title": "データをリセット",
"general.restore.button": "復元",
@@ -897,10 +1004,7 @@
"argsTooltip": "1行に1つの引数を入力してください",
"baseUrlTooltip": "リモートURLアドレス",
"command": "コマンド",
"commandRequired": "コマンドを入力してください",
"config_description": "モデルコンテキストプロトコルサーバーの設定",
"confirmDelete": "サーバーを削除",
"confirmDeleteMessage": "本当にこのサーバーを削除しますか?",
"deleteError": "サーバーの削除に失敗しました",
"deleteSuccess": "サーバーが正常に削除されました",
"dependenciesInstall": "依存関係をインストール",
@@ -911,7 +1015,8 @@
"editServer": "サーバーを編集",
"env": "環境変数",
"envTooltip": "形式: KEY=value, 1行に1つ",
"findMore": "MCP サーバーを見つける",
"findMore": "MCP を見つける",
"searchNpx": "MCP を検索",
"install": "インストール",
"installError": "依存関係のインストールに失敗しました",
"installSuccess": "依存関係のインストールに成功しました",
@@ -921,8 +1026,8 @@
"jsonSaveSuccess": "JSON設定が保存されました。",
"missingDependencies": "が不足しています。続行するにはインストールしてください。",
"name": "名前",
"nameRequired": "サーバー名を入力してください",
"noServers": "サーバーが設定されていません",
"newServer": "MCP サーバー",
"npx_list": {
"actions": "アクション",
"desc": "npm パッケージを検索して MCP サーバーとして追加",
@@ -941,11 +1046,26 @@
"serverPlural": "サーバー",
"serverSingular": "サーバー",
"title": "MCP サーバー",
"toggleError": "切り替えに失敗しました",
"startError": "起動に失敗しました",
"type": "タイプ",
"updateError": "サーバーの更新に失敗しました",
"updateSuccess": "サーバーが正常に更新されました",
"url": "URL"
"url": "URL",
"errors": {
"32000": "MCP サーバーが起動しませんでした。パラメーターを確認してください"
},
"editMcpJson": "MCP 設定を編集",
"installHelp": "インストールヘルプを取得",
"tools": {
"inputSchema": "入力スキーマ",
"availableTools": "利用可能なツール",
"noToolsAvailable": "利用可能なツールはありません"
},
"deleteServer": "サーバーを削除",
"deleteServerConfirm": "このサーバーを削除してもよろしいですか?",
"registry": "パッケージ管理レジストリ",
"registryTooltip": "デフォルトのレジストリでネットワークの問題が発生した場合、パッケージインストールに使用するレジストリを選択してください。",
"registryDefault": "デフォルト"
},
"messages.divider": "メッセージ間に区切り線を表示",
"messages.grid_columns": "メッセージグリッドの表示列数",
@@ -1057,7 +1177,7 @@
"docs_more_details": "詳細を確認",
"get_api_key": "APIキーを取得",
"is_not_support_array_content": "互換モードを有効にする",
"no_models": "API接続をチェックする前に、モデルを追加してください",
"no_models_for_check": "チェックするモデルがありません(例:会話モデル)",
"not_checked": "未チェック",
"remove_duplicate_keys": "重複キーを削除",
"remove_invalid_keys": "無効なキーを削除",
@@ -1095,7 +1215,7 @@
"reset_defaults_confirm": "すべてのショートカットをリセットしてもよろしいですか?",
"reset_to_default": "デフォルトにリセット",
"search_message": "メッセージを検索",
"show_app": "アプリを表示",
"show_app": "アプリを表示/非表示",
"show_settings": "設定を開く",
"title": "ショートカット",
"toggle_new_context": "コンテキストをクリア",
@@ -1143,7 +1263,8 @@
"title": "Tavily"
},
"title": "ウェブ検索"
}
},
"general.auto_check_update.title": "自動更新チェックを有効にする"
},
"translate": {
"any.language": "任意の言語",

View File

@@ -32,7 +32,9 @@
"title": "Агенты"
},
"assistants": {
"title": "Ассистенты",
"abbr": "Ассистент",
"settings.title": "Настройки ассистента",
"clear.content": "Очистка топика удалит все топики и файлы в ассистенте. Вы уверены, что хотите продолжить?",
"clear.title": "Очистить топики",
"copy.title": "Копировать ассистента",
@@ -42,6 +44,11 @@
"save.success": "Успешно сохранено",
"save.title": "Сохранить в агента",
"search": "Поиск ассистентов...",
"settings.mcp": "Серверы MCP",
"settings.mcp.enableFirst": "Сначала включите этот сервер в настройках MCP",
"settings.mcp.title": "Настройки MCP",
"settings.mcp.noServersAvailable": "Нет доступных серверов MCP. Добавьте серверы в настройках",
"settings.mcp.description": "Серверы MCP, включенные по умолчанию",
"settings.default_model": "Модель по умолчанию",
"settings.knowledge_base": "Настройки базы знаний",
"settings.model": "Настройки модели",
@@ -53,7 +60,7 @@
"settings.reasoning_effort.medium": "Средняя",
"settings.reasoning_effort.off": "Выключено",
"settings.reasoning_effort.tip": "Поддерживается только моделями с рассуждением OpenAI o-series и Anthropic",
"title": "Ассистенты"
"settings.more": "Настройки ассистента"
},
"auth": {
"error": "Автоматический получение ключа API не удалось, пожалуйста, получите ключ вручную",
@@ -143,7 +150,10 @@
"history": "История чата",
"last": "Уже последнее сообщение",
"next": "Следующее сообщение",
"prev": "Предыдущее сообщение"
"prev": "Предыдущее сообщение",
"top": "Вернуться наверх",
"bottom": "Вернуться вниз",
"close": "Закрыть"
},
"resend": "Переотправить",
"save": "Сохранить",
@@ -180,13 +190,16 @@
"topics.export.md": "Экспорт как markdown",
"topics.export.notion": "Экспорт в Notion",
"topics.export.obsidian": "Экспорт в Obsidian",
"topics.export.obsidian_vault": "Хранилище",
"topics.export.obsidian_vault_placeholder": "Выберите имя хранилища",
"topics.export.obsidian_path": "Путь",
"topics.export.obsidian_path_placeholder": "Выберите путь",
"topics.export.obsidian_atributes": "Настроить атрибуты заметки",
"topics.export.obsidian_btn": "Подтвердить",
"topics.export.obsidian_created": "Дата создания",
"topics.export.obsidian_created_placeholder": "Пожалуйста, выберите дату создания",
"topics.export.obsidian_export_failed": "Экспорт не удалось",
"topics.export.obsidian_export_success": "Экспорт успешно завершен",
"topics.export.obsidian_not_configured": "Obsidian не настроен",
"topics.export.obsidian_operate": "Метод обработки",
"topics.export.obsidian_operate_append": "Добавить в конец",
"topics.export.obsidian_operate_new_or_overwrite": "Создать новый (перезаписать, если уже существует)",
@@ -199,6 +212,13 @@
"topics.export.obsidian_title": "Заголовок",
"topics.export.obsidian_title_placeholder": "Пожалуйста, введите заголовок",
"topics.export.obsidian_title_required": "Заголовок не может быть пустым",
"topics.export.obsidian_no_vaults": "Хранилища Obsidian не найдены",
"topics.export.obsidian_loading": "Загрузка...",
"topics.export.obsidian_fetch_error": "Не удалось получить хранилища Obsidian",
"topics.export.obsidian_fetch_folders_error": "Не удалось получить структуру папок",
"topics.export.obsidian_no_vault_selected": "Пожалуйста, сначала выберите хранилище",
"topics.export.obsidian_select_vault_first": "Пожалуйста, сначала выберите хранилище",
"topics.export.obsidian_root_directory": "Корневая директория",
"topics.export.title": "Экспорт",
"topics.export.word": "Экспорт как Word",
"topics.export.yuque": "Экспорт в Yuque",
@@ -211,7 +231,11 @@
"topics.prompt.tips": "Тематические подсказки: Дополнительные подсказки, предоставленные для текущей темы",
"topics.title": "Топики",
"topics.unpinned": "Открепленные темы",
"translate": "Перевести"
"translate": "Перевести",
"topics.export.siyuan": "Экспорт в Siyuan Note",
"topics.export.wait_for_title_naming": "Создание заголовка...",
"topics.export.title_naming_success": "Заголовок успешно создан",
"topics.export.title_naming_failed": "Не удалось создать заголовок, используется заголовок по умолчанию"
},
"code_block": {
"collapse": "Свернуть",
@@ -262,7 +286,13 @@
"select": "Выбрать",
"topics": "Топики",
"warning": "Предупреждение",
"you": "Вы"
"you": "Вы",
"variable_name": "Имя переменной",
"value": "Значение",
"no_variables_added": "Нет переменных",
"insert_variable_into_prompt": "Вставить переменную в промпт",
"variables": "Переменные",
"variables_help": "Добавьте переменные, которые нужно заменить в тексте, замена срабатывает в формате {{variable_name}} в документе замены"
},
"docs": {
"title": "Документация"
@@ -288,7 +318,8 @@
"description": "Не удалось рендерить формулу. Пожалуйста, проверьте, правильно ли формат формулы",
"title": "Ошибка рендеринга"
},
"user_message_not_found": "Не удалось найти исходное сообщение пользователя"
"user_message_not_found": "Не удалось найти исходное сообщение пользователя",
"unknown": "Неизвестная ошибка"
},
"export": {
"assistant": "Ассистент",
@@ -469,6 +500,8 @@
"error.invalid.webdav": "Неверные настройки WebDAV",
"error.joplin.export": "Не удалось экспортировать в Joplin, пожалуйста, убедитесь, что Joplin запущен и проверьте состояние подключения или настройки",
"error.joplin.no_config": "Joplin Authorization Token или URL не настроен",
"error.invalid.nutstore": "Неверные настройки Nutstore",
"error.invalid.nutstore_token": "Неверный Nutstore токен",
"error.markdown.export.preconf": "Не удалось экспортировать файл Markdown в предуказанный путь",
"error.markdown.export.specified": "Не удалось экспортировать файл Markdown",
"error.notion.export": "Ошибка экспорта в Notion, пожалуйста, проверьте состояние подключения и настройки в документации",
@@ -518,11 +551,27 @@
"upgrade.success.content": "Пожалуйста, перезапустите приложение для завершения обновления",
"upgrade.success.title": "Обновление успешно",
"warn.notion.exporting": "Экспортируется в Notion, пожалуйста, не отправляйте повторные запросы!",
"warning.rate.limit": "Отправка слишком частая, пожалуйста, подождите {{seconds}} секунд, прежде чем попробовать снова."
"warning.rate.limit": "Отправка слишком частая, пожалуйста, подождите {{seconds}} секунд, прежде чем попробовать снова.",
"error.siyuan.export": "Ошибка экспорта в Siyuan, пожалуйста, проверьте состояние подключения и настройки в документации",
"error.siyuan.no_config": "Не настроен API адрес или токен Siyuan",
"success.siyuan.export": "Успешный экспорт в Siyuan",
"warn.yuque.exporting": "Экспортируется в Yuque, пожалуйста, не отправляйте повторные запросы!",
"warn.siyuan.exporting": "Экспортируется в Siyuan, пожалуйста, не отправляйте повторные запросы!"
},
"minapp": {
"popup": {
"refresh": "Обновить",
"close": "Закрыть встроенное приложение",
"minimize": "Свернуть встроенное приложение",
"devtools": "Инструменты разработчика",
"openExternal": "Открыть в браузере",
"rightclick_copyurl": "ПКМ → Копировать URL"
},
"sidebar.add.title": "Добавить в боковую панель",
"sidebar.remove.title": "Удалить из боковой панели",
"sidebar.close.title": "Закрыть",
"sidebar.closeall.title": "Закрыть все",
"sidebar.hide.title": "Скрыть приложение",
"title": "Встроенные приложения"
},
"miniwindow": {
@@ -539,13 +588,17 @@
"copy_last_message": "Нажмите C для копирования",
"esc": "Нажмите ESC {{action}}",
"esc_back": "возвращения",
"esc_close": "закрытия окна"
"esc_close": "закрытия окна",
"backspace_clear": "Нажмите Backspace, чтобы очистить"
},
"input": {
"placeholder": {
"empty": "Задайте вопрос {{model}}...",
"title": "Что вы хотите сделать с этим текстом?"
}
},
"tooltip": {
"pin": "Верхнее окно"
}
},
"models": {
@@ -786,15 +839,6 @@
"notion.split_size_help": "Рекомендуется 90 для пользователей бесплатной версии Notion, 24990 для пользователей премиум-версии, значение по умолчанию — 90",
"notion.split_size_placeholder": "Введите ограничение количества блоков на странице (по умолчанию 90)",
"notion.title": "Настройки Notion",
"obsidian": {
"folder": "Папка",
"folder_placeholder": "Пожалуйста, введите имя папки",
"tags": "Глобальные Теги",
"tags_placeholder": "Пожалуйста, введите имена тегов. Разделяйте несколько тегов запятыми на английском языке. В Obsidian нельзя использовать только цифры.",
"title": "Конфигурация Obsidian",
"vault": "Хранилище",
"vault_placeholder": "Пожалуйста, введите имя хранилища"
},
"title": "Настройки данных",
"webdav": {
"autoSync": "Автоматическое резервное копирование",
@@ -839,16 +883,66 @@
"title": "Настройка Yuque",
"token": "Токен Yuque",
"token_placeholder": "Введите токен Yuque"
}
},
"obsidian": {
"title": "Настройки Obsidian",
"default_vault": "Хранилище Obsidian по умолчанию",
"default_vault_placeholder": "Выберите хранилище Obsidian по умолчанию",
"default_vault_loading": "Получение хранилищ Obsidian...",
"default_vault_no_vaults": "Хранилища Obsidian не найдены",
"default_vault_fetch_error": "Не удалось получить хранилища Obsidian",
"default_vault_export_failed": "Ошибка экспорта"
},
"siyuan": {
"title": "Конфигурация SiYuan Note",
"api_url": "API адрес",
"api_url_placeholder": "Например: http://127.0.0.1:6806",
"token": "API токен",
"token.help": "Получите в SiYuan Note -> Настройки -> О программе",
"token_placeholder": "Введите токен SiYuan Note",
"box_id": "ID блокнота",
"box_id_placeholder": "Введите ID блокнота",
"root_path": "Корневой путь документа",
"root_path_placeholder": "Например: /CherryStudio",
"check": {
"title": "Проверка соединения",
"button": "Проверить",
"empty_config": "Пожалуйста, заполните API адрес и токен",
"success": "Соединение успешно",
"fail": "Не удалось подключиться, проверьте API адрес и токен",
"error": "Ошибка соединения, проверьте сетевое подключение"
}
},
"nutstore": {
"title": "Настройки Nutstore",
"isLogin": "Выполнен вход",
"notLogin": "Вход не выполнен",
"login.button": "Войти",
"logout.button": "Выйти",
"logout.title": "Вы уверены, что хотите выйти из Nutstore?",
"logout.content": "После выхода вы не сможете создавать резервные копии в Nutstore или восстанавливать данные из Nutstore.",
"checkConnection.name": "Проверить соединение",
"checkConnection.success": "Подключение к Nutstore установлено",
"checkConnection.fail": "Ошибка подключения к Nutstore",
"username": "Имя пользователя Nutstore",
"path": "Путь хранения Nutstore",
"path.placeholder": "Введите путь хранения Nutstore",
"backup.button": "Резервное копирование в Nutstore",
"restore.button": "Восстановление из Nutstore",
"pathSelector.title": "Путь хранения Nutstore",
"pathSelector.return": "Назад",
"pathSelector.currentPath": "Текущий путь",
"new_folder.button.confirm": "Подтвердить",
"new_folder.button.cancel": "Отмена",
"new_folder.button": "Новая папка"
},
"message_title.use_topic_naming.title": "Использовать модель именования тем для создания заголовков сообщений",
"message_title.use_topic_naming.help": "Этот параметр влияет на все методы экспорта в Markdown, такие как Notion, Yuque и т.д."
},
"display.assistant.title": "Настройки ассистентов",
"display.custom.css": "Пользовательский CSS",
"display.custom.css.cherrycss": "Получить из cherrycss.com",
"display.custom.css.placeholder": "/* Здесь введите пользовательский CSS */",
"display.minApp.disabled": "скрытый апплет",
"display.minApp.empty": "Перетащите апплет, который хотите скрыть, слева сюда",
"display.minApp.title": "Настройки отображения мини программы",
"display.minApp.visible": "Отображаемый апплет",
"display.sidebar.chat.hiddenMessage": "Помощник является базовой функцией и не поддерживает скрытие",
"display.sidebar.disabled": "Скрыть иконки",
"display.sidebar.empty": "Перетащите скрываемую функцию с левой стороны сюда",
@@ -861,6 +955,20 @@
"display.sidebar.visible": "Показывать иконки",
"display.title": "Настройки отображения",
"display.topic.title": "Настройки топиков",
"miniapps": {
"title": "Настройки мини-приложений",
"disabled": "Скрытые мини-приложения",
"empty": "Перетащите мини-приложения слева, чтобы скрыть их",
"visible": "Отображаемые мини-приложения",
"cache_settings": "Настройки кэша",
"cache_title": "Количество кэшируемых мини-приложений",
"cache_description": "Установить максимальное количество активных мини-приложений в памяти",
"reset_tooltip": "Сбросить до значения по умолчанию",
"display_title": "Настройки отображения мини-приложений",
"sidebar_title": "Отображение активных мини-приложений в боковой панели",
"sidebar_description": "Настройка отображения активных мини-приложений в боковой панели",
"cache_change_notice": "Изменения вступят в силу, когда количество открытых мини-приложений достигнет установленного значения"
},
"font_size.title": "Размер шрифта сообщений",
"general": "Общие настройки",
"general.avatar.reset": "Сброс аватара",
@@ -869,7 +977,6 @@
"general.display.title": "Настройки отображения",
"general.emoji_picker": "Выбор эмодзи",
"general.image_upload": "Загрузка изображений",
"general.manually_check_update.title": "Отключить проверку обновлений",
"general.reset.button": "Сброс",
"general.reset.title": "Сброс данных",
"general.restore.button": "Восстановление",
@@ -897,10 +1004,7 @@
"argsTooltip": "Каждый аргумент с новой строки",
"baseUrlTooltip": "Адрес удаленного URL",
"command": "Команда",
"commandRequired": "Пожалуйста, введите команду",
"config_description": "Настройка серверов протокола контекста модели",
"confirmDelete": "Удалить сервер",
"confirmDeleteMessage": "Вы уверены, что хотите удалить этот сервер?",
"deleteError": "Не удалось удалить сервер",
"deleteSuccess": "Сервер успешно удален",
"dependenciesInstall": "Установить зависимости",
@@ -911,7 +1015,8 @@
"editServer": "Редактировать сервер",
"env": "Переменные окружения",
"envTooltip": "Формат: KEY=value, по одной на строку",
"findMore": "Найти больше MCP серверов",
"findMore": "Найти больше MCP",
"searchNpx": "Найти MCP",
"install": "Установить",
"installError": "Не удалось установить зависимости",
"installSuccess": "Зависимости успешно установлены",
@@ -921,8 +1026,8 @@
"jsonSaveSuccess": "JSON конфигурация сохранена",
"missingDependencies": "отсутствует, пожалуйста, установите для продолжения.",
"name": "Имя",
"nameRequired": "Пожалуйста, введите имя сервера",
"noServers": "Серверы не настроены",
"newServer": "MCP сервер",
"npx_list": {
"actions": "Действия",
"desc": "Поиск и добавление npm пакетов в качестве MCP серверов",
@@ -938,14 +1043,29 @@
"usage": "Использование",
"version": "Версия"
},
"errors": {
"32000": "MCP сервер не запущен, пожалуйста, проверьте параметры"
},
"serverPlural": "серверы",
"serverSingular": "сервер",
"title": "Серверы MCP",
"toggleError": "Переключение не удалось",
"startError": "Запуск не удалось",
"type": "Тип",
"updateError": "Ошибка обновления сервера",
"updateSuccess": "Сервер успешно обновлен",
"url": "URL"
"url": "URL",
"editMcpJson": "Редактировать MCP",
"installHelp": "Получить помощь по установке",
"tools": {
"inputSchema": "входные параметры",
"availableTools": "доступные инструменты",
"noToolsAvailable": "нет доступных инструментов"
},
"deleteServer": "Удалить сервер",
"deleteServerConfirm": "Вы уверены, что хотите удалить этот сервер?",
"registry": "Реестр пакетов",
"registryTooltip": "Выберите реестр для установки пакетов, если возникают проблемы с сетью при использовании реестра по умолчанию.",
"registryDefault": "По умолчанию"
},
"messages.divider": "Показывать разделитель между сообщениями",
"messages.grid_columns": "Количество столбцов сетки сообщений",
@@ -1057,7 +1177,7 @@
"docs_more_details": "для получения дополнительной информации",
"get_api_key": "Получить ключ API",
"is_not_support_array_content": "Включить совместимый режим",
"no_models": "Пожалуйста, добавьте модели перед проверкой соединения с API",
"no_models_for_check": "Нет моделей для проверки (например, диалоговые модели)",
"not_checked": "Не проверено",
"remove_duplicate_keys": "Удалить дубликаты ключей",
"remove_invalid_keys": "Удалить недействительные ключи",
@@ -1095,7 +1215,7 @@
"reset_defaults_confirm": "Вы уверены, что хотите сбросить все горячие клавиши?",
"reset_to_default": "Сбросить настройки по умолчанию",
"search_message": "Поиск сообщения",
"show_app": "Показать приложение",
"show_app": "Показать/скрыть приложение",
"show_settings": "Открыть настройки",
"title": "Горячие клавиши",
"toggle_new_context": "Очистить контекст",
@@ -1143,7 +1263,8 @@
"title": "Tavily"
},
"title": "Поиск в Интернете"
}
},
"general.auto_check_update.title": "Включить автоматическую проверку обновлений"
},
"translate": {
"any.language": "Любой язык",

View File

@@ -32,7 +32,9 @@
"title": "智能体"
},
"assistants": {
"title": "助手",
"abbr": "助手",
"settings.title": "助手设置",
"clear.content": "清空话题会删除助手下所有话题和文件,确定要继续吗?",
"clear.title": "清空话题",
"copy.title": "复制助手",
@@ -42,6 +44,11 @@
"save.success": "保存成功",
"save.title": "保存到智能体",
"search": "搜索助手",
"settings.mcp": "MCP 服务器",
"settings.mcp.enableFirst": "请先在 MCP 设置中启用此服务器",
"settings.mcp.title": "MCP 设置",
"settings.mcp.noServersAvailable": "无可用 MCP 服务器。请在设置中添加服务器",
"settings.mcp.description": "默认启用的 MCP 服务器",
"settings.default_model": "默认模型",
"settings.knowledge_base": "知识库设置",
"settings.model": "模型设置",
@@ -53,7 +60,7 @@
"settings.reasoning_effort.medium": "中",
"settings.reasoning_effort.off": "关",
"settings.reasoning_effort.tip": "仅支持 OpenAI o-series 和 Anthropic 推理模型",
"title": "助手"
"settings.more": "助手设置"
},
"auth": {
"error": "自动获取密钥失败,请手动获取",
@@ -143,7 +150,10 @@
"history": "聊天历史",
"last": "已经是最后一条消息",
"next": "下一条消息",
"prev": "上一条消息"
"prev": "上一条消息",
"top": "回到顶部",
"bottom": "回到底部",
"close": "关闭"
},
"resend": "重新发送",
"save": "保存",
@@ -180,13 +190,16 @@
"topics.export.md": "导出为 Markdown",
"topics.export.notion": "导出到 Notion",
"topics.export.obsidian": "导出到 Obsidian",
"topics.export.obsidian_vault": "保管库",
"topics.export.obsidian_vault_placeholder": "请选择保管库名称",
"topics.export.obsidian_path": "路径",
"topics.export.obsidian_path_placeholder": "请选择路径",
"topics.export.obsidian_atributes": "配置笔记属性",
"topics.export.obsidian_btn": "确定",
"topics.export.obsidian_created": "创建时间",
"topics.export.obsidian_created_placeholder": "请选择创建时间",
"topics.export.obsidian_export_failed": "导出失败",
"topics.export.obsidian_export_success": "导出成功",
"topics.export.obsidian_not_configured": "Obsidian 未配置",
"topics.export.obsidian_export_failed": "导出到Obsidian失败",
"topics.export.obsidian_export_success": "导出到Obsidian成功",
"topics.export.obsidian_operate": "处理方式",
"topics.export.obsidian_operate_append": "追加",
"topics.export.obsidian_operate_new_or_overwrite": "新建(如果存在就覆盖)",
@@ -199,6 +212,13 @@
"topics.export.obsidian_title": "标题",
"topics.export.obsidian_title_placeholder": "请输入标题",
"topics.export.obsidian_title_required": "标题不能为空",
"topics.export.obsidian_no_vaults": "未找到Obsidian保管库",
"topics.export.obsidian_loading": "加载中...",
"topics.export.obsidian_fetch_error": "获取Obsidian保管库失败",
"topics.export.obsidian_fetch_folders_error": "获取文件夹结构失败",
"topics.export.obsidian_no_vault_selected": "请先选择一个保管库",
"topics.export.obsidian_select_vault_first": "请先选择保管库",
"topics.export.obsidian_root_directory": "根目录",
"topics.export.title": "导出",
"topics.export.word": "导出为 Word",
"topics.export.yuque": "导出到语雀",
@@ -211,7 +231,11 @@
"topics.prompt.tips": "话题提示词: 针对当前话题提供额外的补充提示词",
"topics.title": "话题",
"topics.unpinned": "取消固定",
"translate": "翻译"
"translate": "翻译",
"topics.export.siyuan": "导出到思源笔记",
"topics.export.wait_for_title_naming": "正在生成标题...",
"topics.export.title_naming_success": "标题生成成功",
"topics.export.title_naming_failed": "标题生成失败,使用默认标题"
},
"code_block": {
"collapse": "收起",
@@ -262,7 +286,13 @@
"select": "选择",
"topics": "话题",
"warning": "警告",
"you": "用户"
"you": "用户",
"variable_name": "变量名称",
"value": "值",
"no_variables_added": "没有添加变量",
"insert_variable_into_prompt": "插入变量到提示词",
"variables": "变量",
"variables_help": "添加需要替换的变量名字和值即可"
},
"docs": {
"title": "帮助文档"
@@ -288,7 +318,8 @@
"description": "渲染公式失败,请检查公式格式是否正确",
"title": "渲染错误"
},
"user_message_not_found": "无法找到原始用户消息"
"user_message_not_found": "无法找到原始用户消息",
"unknown": "未知错误"
},
"export": {
"assistant": "助手",
@@ -469,6 +500,8 @@
"error.invalid.webdav": "无效的 WebDAV 设置",
"error.joplin.export": "导出 Joplin 失败,请保持 Joplin 已运行并检查连接状态或检查配置",
"error.joplin.no_config": "未配置 Joplin 授权令牌 或 URL",
"error.invalid.nutstore": "无效的坚果云设置",
"error.invalid.nutstore_token": "无效的坚果云 Token",
"error.markdown.export.preconf": "导出Markdown文件到预先设定的路径失败",
"error.markdown.export.specified": "导出Markdown文件失败",
"error.notion.export": "导出 Notion 错误,请检查连接状态并对照文档检查配置",
@@ -518,11 +551,27 @@
"upgrade.success.content": "重启用以完成升级",
"upgrade.success.title": "升级成功",
"warn.notion.exporting": "正在导出到 Notion, 请勿重复请求导出!",
"warning.rate.limit": "发送过于频繁,请等待 {{seconds}} 秒后再尝试"
"warning.rate.limit": "发送过于频繁,请等待 {{seconds}} 秒后再尝试",
"error.siyuan.export": "导出思源笔记失败,请检查连接状态并对照文档检查配置",
"error.siyuan.no_config": "未配置思源笔记API地址或令牌",
"success.siyuan.export": "导出到思源笔记成功",
"warn.yuque.exporting": "正在导出语雀, 请勿重复请求导出!",
"warn.siyuan.exporting": "正在导出到思源笔记,请勿重复请求导出!"
},
"minapp": {
"popup": {
"refresh": "刷新",
"close": "关闭小程序",
"minimize": "最小化小程序",
"devtools": "开发者工具",
"openExternal": "在浏览器中打开",
"rightclick_copyurl": "右键复制URL"
},
"sidebar.add.title": "添加到侧边栏",
"sidebar.remove.title": "从侧边栏移除",
"sidebar.close.title": "关闭",
"sidebar.closeall.title": "全部关闭",
"sidebar.hide.title": "隐藏小程序",
"title": "小程序"
},
"miniwindow": {
@@ -537,15 +586,19 @@
},
"footer": {
"copy_last_message": "按 C 键复制",
"backspace_clear": "按 Backspace 清空",
"esc": "按 ESC {{action}}",
"esc_back": "返回",
"esc_close": "关闭窗口"
"esc_close": "关闭"
},
"input": {
"placeholder": {
"empty": "询问 {{model}} 获取帮助...",
"title": "你想对下方文字做什么"
}
},
"tooltip": {
"pin": "窗口置顶"
}
},
"models": {
@@ -679,7 +732,7 @@
"yi": "零一万物",
"zhinao": "360智脑",
"zhipu": "智谱AI",
"voyageai":"Voyage AI"
"voyageai": "Voyage AI"
},
"restore": {
"confirm": "确定要恢复数据吗?",
@@ -763,6 +816,8 @@
"markdown_export.path_placeholder": "导出路径",
"markdown_export.select": "选择",
"markdown_export.title": "Markdown 导出",
"message_title.use_topic_naming.title": "使用话题命名模型为导出的消息创建标题",
"message_title.use_topic_naming.help": "开启后使用话题命名模型为导出的消息创建标题。该项也会影响所有通过Markdown导出的方式。",
"minute_interval_one": "{{count}} 分钟",
"minute_interval_other": "{{count}} 分钟",
"notion.api_key": "Notion 密钥",
@@ -786,15 +841,6 @@
"notion.split_size_help": "Notion免费版用户建议设置为90高级版用户建议设置为24990默认值为90",
"notion.split_size_placeholder": "请输入每页块数限制(默认90)",
"notion.title": "Notion 配置",
"obsidian": {
"folder": "文件夹",
"folder_placeholder": "请输入文件夹名称",
"tags": "全局标签",
"tags_placeholder": "请输入标签名称, 多个标签用英文逗号分隔",
"title": "Obsidian 配置",
"vault": "保管库",
"vault_placeholder": "请输入保管库名称"
},
"title": "数据设置",
"webdav": {
"autoSync": "自动备份",
@@ -839,16 +885,64 @@
"title": "语雀配置",
"token": "语雀 Token",
"token_placeholder": "请输入语雀Token"
},
"obsidian": {
"title": "Obsidian 配置",
"default_vault": "默认 Obsidian 仓库",
"default_vault_placeholder": "请选择默认 Obsidian 仓库",
"default_vault_loading": "正在获取 Obsidian 仓库...",
"default_vault_no_vaults": "未找到 Obsidian 仓库",
"default_vault_fetch_error": "获取 Obsidian 仓库失败",
"default_vault_export_failed": "导出失败"
},
"siyuan": {
"title": "思源笔记配置",
"api_url": "API地址",
"api_url_placeholder": "例如http://127.0.0.1:6806",
"token": "API令牌",
"token.help": "在思源笔记->设置->关于中获取",
"token_placeholder": "请输入思源笔记令牌",
"box_id": "笔记本ID",
"box_id_placeholder": "请输入笔记本ID",
"root_path": "文档根路径",
"root_path_placeholder": "例如:/CherryStudio",
"check": {
"title": "连接检查",
"button": "检查",
"empty_config": "请填写API地址和令牌",
"success": "连接成功",
"fail": "连接失败请检查API地址和令牌",
"error": "连接异常,请检查网络连接"
}
},
"nutstore": {
"title": "坚果云配置",
"isLogin": "已登录",
"notLogin": "未登录",
"login.button": "登录",
"logout.button": "退出登录",
"logout.title": "确定要退出坚果云登录?",
"logout.content": "退出后将无法备份至坚果云和从坚果云恢复",
"checkConnection.name": "检查连接",
"checkConnection.success": "已连接坚果云",
"checkConnection.fail": "坚果云连接失败",
"username": "坚果云用户名",
"path": "坚果云存储路径",
"path.placeholder": "请输入坚果云的存储路径",
"backup.button": "备份到坚果云",
"restore.button": "从坚果云恢复",
"pathSelector.title": "坚果云存储路径",
"pathSelector.return": "返回",
"pathSelector.currentPath": "当前路径",
"new_folder.button.confirm": "确定",
"new_folder.button.cancel": "取消",
"new_folder.button": "新建文件夹"
}
},
"display.assistant.title": "助手设置",
"display.custom.css": "自定义 CSS",
"display.custom.css.cherrycss": "从 cherrycss.com 获取",
"display.custom.css.placeholder": "/* 这里写自定义CSS */",
"display.minApp.disabled": "隐藏的小程序",
"display.minApp.empty": "把要隐藏的小程序从左侧拖拽到这里",
"display.minApp.title": "小程序显示设置",
"display.minApp.visible": "显示的小程序",
"display.sidebar.chat.hiddenMessage": "助手是基础功能,不支持隐藏",
"display.sidebar.disabled": "隐藏的图标",
"display.sidebar.empty": "把要隐藏的功能从左侧拖拽到这里",
@@ -861,6 +955,20 @@
"display.sidebar.visible": "显示的图标",
"display.title": "显示设置",
"display.topic.title": "话题设置",
"miniapps": {
"title": "小程序设置",
"disabled": "隐藏的小程序",
"empty": "把要隐藏的小程序从左侧拖拽到这里",
"visible": "显示的小程序",
"cache_settings": "缓存设置",
"cache_title": "小程序缓存数量",
"cache_description": "设置同时保持活跃状态的小程序最大数量",
"reset_tooltip": "重置为默认值",
"display_title": "小程序显示设置",
"sidebar_title": "侧边栏活跃小程序显示设置",
"sidebar_description": "设置侧边栏是否显示活跃的小程序",
"cache_change_notice": "更改将在打开的小程序增减至设定值后生效"
},
"font_size.title": "消息字体大小",
"general": "常规设置",
"general.avatar.reset": "重置头像",
@@ -869,7 +977,7 @@
"general.display.title": "显示设置",
"general.emoji_picker": "表情选择器",
"general.image_upload": "图片上传",
"general.manually_check_update.title": "关闭更新检测",
"general.auto_check_update.title": "自动检测更新",
"general.reset.button": "重置",
"general.reset.title": "重置数据",
"general.restore.button": "恢复",
@@ -897,10 +1005,7 @@
"argsTooltip": "每个参数占一行",
"baseUrlTooltip": "远程 URL 地址",
"command": "命令",
"commandRequired": "请输入命令",
"config_description": "配置模型上下文协议服务器",
"confirmDelete": "删除服务器",
"confirmDeleteMessage": "您确定要删除该服务器吗?",
"deleteError": "删除服务器失败",
"deleteSuccess": "服务器删除成功",
"dependenciesInstall": "安装依赖项",
@@ -911,7 +1016,8 @@
"editServer": "编辑服务器",
"env": "环境变量",
"envTooltip": "格式KEY=value每行一个",
"findMore": "更多 MCP 服务器",
"findMore": "更多 MCP",
"searchNpx": "搜索 MCP",
"install": "安装",
"installError": "安装依赖项失败",
"installSuccess": "依赖项安装成功",
@@ -921,8 +1027,8 @@
"jsonSaveSuccess": "JSON配置已保存",
"missingDependencies": "缺失,请安装它以继续",
"name": "名称",
"nameRequired": "请输入服务器名称",
"noServers": "未配置服务器",
"newServer": "MCP 服务器",
"npx_list": {
"actions": "操作",
"desc": "搜索并添加 npm 包作为 MCP 服务",
@@ -938,14 +1044,29 @@
"usage": "用法",
"version": "版本"
},
"errors": {
"32000": "MCP 服务器启动失败,请根据教程检查参数是否填写完整"
},
"serverPlural": "服务器",
"serverSingular": "服务器",
"title": "MCP 服务器",
"toggleError": "切换失败",
"startError": "启动失败",
"type": "类型",
"updateError": "更新服务器失败",
"updateSuccess": "服务器更新成功",
"url": "URL"
"url": "URL",
"editMcpJson": "编辑 MCP 配置",
"installHelp": "获取安装帮助",
"tools": {
"inputSchema": "输入参数",
"availableTools": "可用工具",
"noToolsAvailable": "没有可用工具"
},
"deleteServer": "删除服务器",
"deleteServerConfirm": "确定要删除此服务器吗?",
"registry": "包管理源",
"registryTooltip": "选择用于安装包的源,以解决默认源的网络问题。",
"registryDefault": "默认"
},
"messages.divider": "消息分割线",
"messages.grid_columns": "消息网格展示列数",
@@ -1057,7 +1178,7 @@
"docs_more_details": "获取更多详情",
"get_api_key": "点击这里获取密钥",
"is_not_support_array_content": "开启兼容模式",
"no_models": "请先添加模型再检查 API 连接",
"no_models_for_check": "没有可以被检查的模型(例如对话模型)",
"not_checked": "未检查",
"remove_duplicate_keys": "移除重复密钥",
"remove_invalid_keys": "删除无效密钥",
@@ -1095,7 +1216,7 @@
"reset_defaults_confirm": "确定要重置所有快捷键吗?",
"reset_to_default": "重置为默认",
"search_message": "搜索消息",
"show_app": "显示应用",
"show_app": "显示/隐藏应用",
"show_settings": "打开设置",
"title": "快捷方式",
"toggle_new_context": "清除上下文",

View File

@@ -32,7 +32,9 @@
"title": "智慧代理人"
},
"assistants": {
"title": "助手",
"abbr": "助手",
"settings.title": "助手設定",
"clear.content": "清空話題會刪除助手下所有主題和檔案,確定要繼續嗎?",
"clear.title": "清空話題",
"copy.title": "複製助手",
@@ -42,6 +44,11 @@
"save.success": "儲存成功",
"save.title": "儲存到智慧代理人",
"search": "搜尋助手...",
"settings.mcp": "MCP 伺服器",
"settings.mcp.enableFirst": "請先在 MCP 設定中啟用此伺服器",
"settings.mcp.title": "MCP 設定",
"settings.mcp.noServersAvailable": "無可用 MCP 伺服器。請在設定中新增伺服器",
"settings.mcp.description": "預設啟用的 MCP 伺服器",
"settings.default_model": "預設模型",
"settings.knowledge_base": "知識庫設定",
"settings.model": "模型設定",
@@ -52,8 +59,8 @@
"settings.reasoning_effort.low": "短",
"settings.reasoning_effort.medium": "中",
"settings.reasoning_effort.off": "關",
"settings.reasoning_effort.tip": "僅支援 OpenAI o 系列和 Anthropic 推理模型",
"title": "助手"
"settings.reasoning_effort.tip": "僅支援 OpenAI o-series 和 Anthropic 推理模型",
"settings.more": "助手設定"
},
"auth": {
"error": "自動取得金鑰失敗,請手動取得",
@@ -143,7 +150,10 @@
"history": "聊天歷史",
"last": "已經是最後一條訊息",
"next": "下一條訊息",
"prev": "上一條訊息"
"prev": "上一條訊息",
"top": "回到頂部",
"bottom": "回到底部",
"close": "關閉"
},
"resend": "重新傳送",
"save": "儲存",
@@ -180,13 +190,16 @@
"topics.export.md": "匯出為 Markdown",
"topics.export.notion": "匯出到 Notion",
"topics.export.obsidian": "匯出到 Obsidian",
"topics.export.obsidian_vault": "保管庫",
"topics.export.obsidian_vault_placeholder": "請選擇保管庫名稱",
"topics.export.obsidian_path": "路徑",
"topics.export.obsidian_path_placeholder": "請選擇路徑",
"topics.export.obsidian_atributes": "配置筆記屬性",
"topics.export.obsidian_btn": "確定",
"topics.export.obsidian_created": "建立時間",
"topics.export.obsidian_created_placeholder": "請選擇建立時間",
"topics.export.obsidian_export_failed": "匯出失敗",
"topics.export.obsidian_export_success": "匯出成功",
"topics.export.obsidian_not_configured": "Obsidian 未配置",
"topics.export.obsidian_operate": "處理方式",
"topics.export.obsidian_operate_append": "追加",
"topics.export.obsidian_operate_new_or_overwrite": "新建(如果存在就覆蓋)",
@@ -199,6 +212,13 @@
"topics.export.obsidian_title": "標題",
"topics.export.obsidian_title_placeholder": "請輸入標題",
"topics.export.obsidian_title_required": "標題不能為空",
"topics.export.obsidian_no_vaults": "未找到Obsidian保管庫",
"topics.export.obsidian_loading": "加載中...",
"topics.export.obsidian_fetch_error": "獲取Obsidian保管庫失敗",
"topics.export.obsidian_fetch_folders_error": "獲取文件夾結構失敗",
"topics.export.obsidian_no_vault_selected": "請先選擇一個保管庫",
"topics.export.obsidian_select_vault_first": "請先選擇保管庫",
"topics.export.obsidian_root_directory": "根目錄",
"topics.export.title": "匯出",
"topics.export.word": "匯出為 Word",
"topics.export.yuque": "匯出到語雀",
@@ -211,7 +231,11 @@
"topics.prompt.tips": "話題提示詞:針對目前話題提供額外的補充提示詞",
"topics.title": "話題",
"topics.unpinned": "取消固定",
"translate": "翻譯"
"translate": "翻譯",
"topics.export.siyuan": "匯出到思源筆記",
"topics.export.wait_for_title_naming": "正在生成標題...",
"topics.export.title_naming_success": "標題生成成功",
"topics.export.title_naming_failed": "標題生成失敗,使用預設標題"
},
"code_block": {
"collapse": "折疊",
@@ -262,7 +286,13 @@
"select": "選擇",
"topics": "話題",
"warning": "警告",
"you": "您"
"you": "您",
"variable_name": "變量名稱",
"value": "值",
"no_variables_added": "沒有添加變量",
"insert_variable_into_prompt": "插入變量到提示詞",
"variables": "變量",
"variables_help": "添加需要替換的變量名字和值即可"
},
"docs": {
"title": "說明文件"
@@ -288,7 +318,8 @@
"description": "渲染公式失敗,請檢查公式格式是否正確",
"title": "渲染錯誤"
},
"user_message_not_found": "無法找到原始用戶訊息"
"user_message_not_found": "無法找到原始用戶訊息",
"unknown": "未知錯誤"
},
"export": {
"assistant": "助手",
@@ -469,6 +500,8 @@
"error.invalid.webdav": "無效的 WebDAV 設定",
"error.joplin.export": "匯出 Joplin 失敗,請保持 Joplin 已運行並檢查連接狀態或檢查設定",
"error.joplin.no_config": "未設定 Joplin 授權Token 或 URL",
"error.invalid.nutstore": "無效的坚果云設定",
"error.invalid.nutstore_token": "無效的坚果云 Token",
"error.markdown.export.preconf": "導出 Markdown 文件到預先設定的路徑失敗",
"error.markdown.export.specified": "導出 Markdown 文件失敗",
"error.notion.export": "匯出 Notion 錯誤,請檢查連接狀態並對照文件檢查設定",
@@ -518,11 +551,27 @@
"upgrade.success.content": "請重新啟動程式以完成升級",
"upgrade.success.title": "升級成功",
"warn.notion.exporting": "正在匯出到 Notion請勿重複請求匯出",
"warning.rate.limit": "發送過於頻繁,請在 {{seconds}} 秒後再嘗試"
"warning.rate.limit": "發送過於頻繁,請在 {{seconds}} 秒後再嘗試",
"error.siyuan.export": "導出思源筆記失敗,請檢查連接狀態並對照文檔檢查配置",
"error.siyuan.no_config": "未配置思源筆記API地址或令牌",
"success.siyuan.export": "導出到思源筆記成功",
"warn.yuque.exporting": "正在導出語雀,請勿重複請求導出!",
"warn.siyuan.exporting": "正在導出到思源筆記,請勿重複請求導出!"
},
"minapp": {
"popup": {
"refresh": "重新整理",
"close": "關閉小工具",
"minimize": "最小化小工具",
"devtools": "開發者工具",
"openExternal": "在瀏覽器中開啟",
"rightclick_copyurl": "右鍵複製URL"
},
"sidebar.add.title": "新增到側邊欄",
"sidebar.remove.title": "從側邊欄移除",
"sidebar.close.title": "關閉",
"sidebar.closeall.title": "全部關閉",
"sidebar.hide.title": "隱藏小工具",
"title": "小工具"
},
"miniwindow": {
@@ -539,13 +588,17 @@
"copy_last_message": "按 C 鍵複製",
"esc": "按 ESC {{action}}",
"esc_back": "返回",
"esc_close": "關閉視窗"
"esc_close": "關閉視窗",
"backspace_clear": "按 Backspace 清空"
},
"input": {
"placeholder": {
"empty": "詢問 {{model}} 取得幫助...",
"title": "你想對下方文字做什麼"
}
},
"tooltip": {
"pin": "窗口置頂"
}
},
"models": {
@@ -786,15 +839,6 @@
"notion.split_size_help": "Notion 免費版使用者建議設定為 90進階版使用者建議設定為 24990預設值為 90",
"notion.split_size_placeholder": "請輸入每頁塊數限制 (預設 90)",
"notion.title": "Notion 設定",
"obsidian": {
"folder": "資料夾",
"folder_placeholder": "請輸入資料夾名稱",
"tags": "全域標籤",
"tags_placeholder": "請輸入標籤名稱多個標籤用英文逗號分隔。Obsidian 不可用純數字。",
"title": "Obsidian 設定",
"vault": "保險庫",
"vault_placeholder": "請輸入保險庫名稱"
},
"title": "資料設定",
"webdav": {
"autoSync": "自動備份",
@@ -839,16 +883,66 @@
"title": "語雀設定",
"token": "語雀 Token",
"token_placeholder": "請輸入語雀 Token"
}
},
"obsidian": {
"title": "Obsidian 設定",
"default_vault": "預設 Obsidian 倉庫",
"default_vault_placeholder": "請選擇預設 Obsidian 倉庫",
"default_vault_loading": "正在獲取 Obsidian 倉庫...",
"default_vault_no_vaults": "未找到 Obsidian 倉庫",
"default_vault_fetch_error": "獲取 Obsidian 倉庫失敗",
"default_vault_export_failed": "匯出失敗"
},
"siyuan": {
"title": "思源筆記配置",
"api_url": "API地址",
"api_url_placeholder": "例如http://127.0.0.1:6806",
"token": "API令牌",
"token.help": "在思源筆記->設置->關於中獲取",
"token_placeholder": "請輸入思源筆記令牌",
"box_id": "筆記本ID",
"box_id_placeholder": "請輸入筆記本ID",
"root_path": "文檔根路徑",
"root_path_placeholder": "例如:/CherryStudio",
"check": {
"title": "連接檢查",
"button": "檢查",
"empty_config": "請填寫API地址和令牌",
"success": "連接成功",
"fail": "連接失敗請檢查API地址和令牌",
"error": "連接異常,請檢查網絡連接"
}
},
"nutstore": {
"title": "堅果雲設定",
"isLogin": "已登入",
"notLogin": "未登入",
"login.button": "登入",
"logout.button": "退出登入",
"logout.title": "確定要退出堅果雲登入?",
"logout.content": "退出後將無法備份至堅果雲和從堅果雲恢復",
"checkConnection.name": "檢查連接",
"checkConnection.success": "已連接堅果雲",
"checkConnection.fail": "堅果雲連接失敗",
"username": "堅果雲用戶名",
"path": "堅果雲存儲路徑",
"path.placeholder": "請輸入堅果雲的存儲路徑",
"backup.button": "備份到堅果雲",
"restore.button": "從堅果雲恢復",
"pathSelector.title": "堅果雲存儲路徑",
"pathSelector.return": "返回",
"pathSelector.currentPath": "當前路徑",
"new_folder.button.confirm": "確定",
"new_folder.button.cancel": "取消",
"new_folder.button": "新建文件夾"
},
"message_title.use_topic_naming.title": "使用話題命名模型為導出的消息創建標題",
"message_title.use_topic_naming.help": "此設定會影響所有通過Markdown導出的方式如Notion、語雀等。"
},
"display.assistant.title": "助手設定",
"display.custom.css": "自訂 CSS",
"display.custom.css.cherrycss": "從 cherrycss.com 取得",
"display.custom.css.placeholder": "/* 這裡寫自訂 CSS */",
"display.minApp.disabled": "隱藏的小工具",
"display.minApp.empty": "把要隱藏的小工具從左側拖拽到這裡",
"display.minApp.title": "小工具顯示設定",
"display.minApp.visible": "顯示的小工具",
"display.sidebar.chat.hiddenMessage": "助手是基礎功能,不支援隱藏",
"display.sidebar.disabled": "隱藏的圖示",
"display.sidebar.empty": "把要隱藏的功能從左側拖拽到這裡",
@@ -861,6 +955,20 @@
"display.sidebar.visible": "顯示的圖示",
"display.title": "顯示設定",
"display.topic.title": "話題設定",
"miniapps": {
"title": "小程式設置",
"disabled": "隱藏的小程式",
"empty": "把要隱藏的小程式從左側拖拽到這裡",
"visible": "顯示的小程式",
"cache_settings": "緩存設置",
"cache_title": "小程式緩存數量",
"cache_description": "設置同時保持活躍狀態的小程式最大數量",
"reset_tooltip": "重置為預設值",
"display_title": "小程式顯示設置",
"sidebar_title": "側邊欄活躍小程式顯示設置",
"sidebar_description": "設置側邊欄是否顯示活躍的小程式",
"cache_change_notice": "更改將在打開的小程式增減至設定值後生效"
},
"font_size.title": "訊息字型大小",
"general": "一般設定",
"general.avatar.reset": "重設頭像",
@@ -869,7 +977,6 @@
"general.display.title": "顯示設定",
"general.emoji_picker": "表情選擇器",
"general.image_upload": "圖片上傳",
"general.manually_check_update.title": "關閉更新檢查",
"general.reset.button": "重設",
"general.reset.title": "資料重設",
"general.restore.button": "復原",
@@ -897,10 +1004,7 @@
"argsTooltip": "每個參數佔一行",
"baseUrlTooltip": "遠端 URL 地址",
"command": "指令",
"commandRequired": "請輸入指令",
"config_description": "設定模型上下文協議伺服器",
"confirmDelete": "刪除伺服器",
"confirmDeleteMessage": "您確定要刪除該伺服器嗎?",
"deleteError": "刪除伺服器失敗",
"deleteSuccess": "伺服器刪除成功",
"dependenciesInstall": "安裝相依套件",
@@ -911,7 +1015,8 @@
"editServer": "編輯伺服器",
"env": "環境變數",
"envTooltip": "格式KEY=value每行一個",
"findMore": "更多 MCP 伺服器",
"findMore": "更多 MCP",
"searchNpx": "搜索 MCP",
"install": "安裝",
"installError": "安裝相依套件失敗",
"installSuccess": "相依套件安裝成功",
@@ -921,8 +1026,8 @@
"jsonSaveSuccess": "JSON配置已儲存",
"missingDependencies": "缺失,請安裝它以繼續",
"name": "名稱",
"nameRequired": "請輸入伺服器名稱",
"noServers": "未設定伺服器",
"newServer": "MCP 伺服器",
"npx_list": {
"actions": "操作",
"desc": "搜索並添加 npm 包作為 MCP 服務",
@@ -938,14 +1043,29 @@
"usage": "用法",
"version": "版本"
},
"errors": {
"32000": "MCP 伺服器啟動失敗,請根據教程檢查參數是否填寫完整"
},
"serverPlural": "伺服器",
"serverSingular": "伺服器",
"title": "MCP 伺服器",
"toggleError": "切換失敗",
"startError": "啟動失敗",
"type": "類型",
"updateError": "更新伺服器失敗",
"updateSuccess": "伺服器更新成功",
"url": "URL"
"url": "URL",
"editMcpJson": "編輯 MCP 配置",
"installHelp": "獲取安裝幫助",
"tools": {
"inputSchema": "輸入參數",
"availableTools": "可用工具",
"noToolsAvailable": "沒有可用工具"
},
"deleteServer": "刪除伺服器",
"deleteServerConfirm": "確定要刪除此伺服器嗎?",
"registry": "套件管理源",
"registryTooltip": "選擇用於安裝套件的源,以解決預設源的網路問題。",
"registryDefault": "預設"
},
"messages.divider": "訊息間顯示分隔線",
"messages.grid_columns": "訊息網格展示列數",
@@ -1057,7 +1177,7 @@
"docs_more_details": "檢視更多細節",
"get_api_key": "點選這裡取得金鑰",
"is_not_support_array_content": "開啟相容模式",
"no_models": "請先新增模型再檢查 API 連接",
"no_models_for_check": "沒有可以被檢查的模型(例如對話模型)",
"not_checked": "未檢查",
"remove_duplicate_keys": "移除重複金鑰",
"remove_invalid_keys": "刪除無效金鑰",
@@ -1095,7 +1215,7 @@
"reset_defaults_confirm": "確定要重設所有快捷鍵嗎?",
"reset_to_default": "重設為預設",
"search_message": "搜尋訊息",
"show_app": "顯示應用程式",
"show_app": "顯示/隱藏應用程式",
"show_settings": "開啟設定",
"title": "快速方式",
"toggle_new_context": "清除上下文",
@@ -1143,7 +1263,8 @@
"title": "Tavily"
},
"title": "網路搜尋"
}
},
"general.auto_check_update.title": "啟用自動更新檢查"
},
"translate": {
"any.language": "任意語言",

View File

@@ -130,7 +130,10 @@
"first": "Ήδη το πρώτο μήνυμα",
"last": "Ήδη το τελευταίο μήνυμα",
"next": "Επόμενο μήνυμα",
"prev": "Προηγούμενο μήνυμα"
"prev": "Προηγούμενο μήνυμα",
"top": "Επιστροφή στην κορυφή",
"bottom": "Επιστροφή στο κάτω μέρος",
"close": "Κλείσιμο"
},
"resend": "Ξαναστείλε",
"save": "Αποθήκευση",
@@ -512,6 +515,7 @@
"minapp": {
"sidebar.add.title": "Προσθήκη στην πλευρή",
"sidebar.remove.title": "Αφαίρεση από την πλευρή",
"sidebar.hide.title": "Απόκρυψη μικροπρογράμματος",
"title": "Μικρόπρογραμμα"
},
"miniwindow": {
@@ -885,10 +889,7 @@
"argsTooltip": "Κάθε παράμετρος σε μια γραμμή",
"baseUrlTooltip": "Σύνδεσμος Απομακρυσμένης διεύθυνσης URL",
"command": "Εντολή",
"commandRequired": "Παρακαλώ εισάγετε την εντολή",
"config_description": "Ρυθμίζει το πλαίσιο συντονισμού πρωτοκόλλων διακομιστή",
"confirmDelete": "Διαγραφή διακομιστή",
"confirmDeleteMessage": "Είστε σίγουροι ότι θέλετε να διαγράψετε αυτόν τον διακομιστή;",
"deleteError": "Αποτυχία διαγραφής διακομιστή",
"deleteSuccess": "Ο διακομιστής διαγράφηκε επιτυχώς",
"dependenciesInstall": "Εγκατάσταση εξαρτήσεων",
@@ -909,7 +910,6 @@
"jsonSaveSuccess": "Η διαμορφωτική ρύθμιση JSON αποθηκεύτηκε επιτυχώς",
"missingDependencies": "Απο缺失, παρακαλώ εγκαταστήστε το για να συνεχίσετε",
"name": "Όνομα",
"nameRequired": "Παρακαλώ εισάγετε το όνομα του διακομιστή",
"noServers": "Δεν έχουν ρυθμιστεί διακομιστές",
"npx_list": {
"actions": "Ενέργειες",

View File

@@ -130,7 +130,10 @@
"first": "Ya es el primer mensaje",
"last": "Ya es el último mensaje",
"next": "Siguiente mensaje",
"prev": "Mensaje anterior"
"prev": "Mensaje anterior",
"top": "Volver arriba",
"bottom": "Volver abajo",
"close": "Cerrar"
},
"resend": "Reenviar",
"save": "Guardar",
@@ -512,6 +515,7 @@
"minapp": {
"sidebar.add.title": "Agregar al panel lateral",
"sidebar.remove.title": "Quitar del panel lateral",
"sidebar.hide.title": "Ocultar mini programa",
"title": "Mini programa"
},
"miniwindow": {
@@ -885,10 +889,7 @@
"argsTooltip": "Cada argumento en una línea",
"baseUrlTooltip": "Dirección URL remota",
"command": "Comando",
"commandRequired": "Por favor ingrese el comando",
"config_description": "Configurar modelo de contexto del protocolo del servidor",
"confirmDelete": "Eliminar servidor",
"confirmDeleteMessage": "¿Está seguro de que desea eliminar este servidor?",
"deleteError": "Fallo al eliminar servidor",
"deleteSuccess": "Servidor eliminado exitosamente",
"dependenciesInstall": "Instalar dependencias",
@@ -909,7 +910,6 @@
"jsonSaveSuccess": "Configuración JSON guardada exitosamente",
"missingDependencies": "Faltan, instalelas para continuar",
"name": "Nombre",
"nameRequired": "Por favor ingrese el nombre del servidor",
"noServers": "No se han configurado servidores",
"npx_list": {
"actions": "Acciones",

View File

@@ -130,7 +130,10 @@
"first": "Déjà premier message",
"last": "Déjà dernier message",
"next": "Prochain message",
"prev": "Précédent message"
"prev": "Précédent message",
"top": "Retour en haut",
"bottom": "Retour en bas",
"close": "Fermer"
},
"resend": "Réenvoyer",
"save": "Enregistrer",
@@ -512,6 +515,7 @@
"minapp": {
"sidebar.add.title": "Ajouter à la barre latérale",
"sidebar.remove.title": "Supprimer de la barre latérale",
"sidebar.hide.title": "Masquer le mini-programme",
"title": "Mini-programme"
},
"miniwindow": {
@@ -885,10 +889,7 @@
"argsTooltip": "Chaque argument sur une ligne",
"baseUrlTooltip": "Adresse URL distante",
"command": "Commande",
"commandRequired": "Veuillez entrer une commande",
"config_description": "Configurer le modèle du protocole de contexte du serveur",
"confirmDelete": "Supprimer le serveur",
"confirmDeleteMessage": "Êtes-vous sûr de vouloir supprimer ce serveur ?",
"deleteError": "Échec de la suppression du serveur",
"deleteSuccess": "Serveur supprimé avec succès",
"dependenciesInstall": "Installer les dépendances",
@@ -909,7 +910,6 @@
"jsonSaveSuccess": "Configuration JSON sauvegardée",
"missingDependencies": "Manquantes, veuillez les installer pour continuer",
"name": "Nom",
"nameRequired": "Veuillez entrer le nom du serveur",
"noServers": "Aucun serveur configuré",
"npx_list": {
"actions": "Actions",

View File

@@ -130,7 +130,10 @@
"first": "Esta é a primeira mensagem",
"last": "Esta é a última mensagem",
"next": "Próxima mensagem",
"prev": "Mensagem anterior"
"prev": "Mensagem anterior",
"top": "Voltar ao topo",
"bottom": "Voltar ao fundo",
"close": "Fechar"
},
"resend": "Reenviar",
"save": "Salvar",
@@ -512,6 +515,7 @@
"minapp": {
"sidebar.add.title": "Adicionar à barra lateral",
"sidebar.remove.title": "Remover da barra lateral",
"sidebar.hide.title": "Ocultar aplicativo",
"title": "Pequeno aplicativo"
},
"miniwindow": {
@@ -885,10 +889,7 @@
"argsTooltip": "Cada argumento em uma linha",
"baseUrlTooltip": "Endereço de URL remoto",
"command": "Comando",
"commandRequired": "Digite o comando",
"config_description": "Configurar modelo de protocolo de contexto do servidor",
"confirmDelete": "Excluir servidor",
"confirmDeleteMessage": "Tem certeza de que deseja excluir este servidor?",
"deleteError": "Falha ao excluir servidor",
"deleteSuccess": "Servidor excluído com sucesso",
"dependenciesInstall": "Instalar dependências",
@@ -909,7 +910,6 @@
"jsonSaveSuccess": "Configuração JSON salva com sucesso",
"missingDependencies": "Ausente, instale para continuar",
"name": "Nome",
"nameRequired": "Digite o nome do servidor",
"noServers": "Nenhum servidor configurado",
"npx_list": {
"actions": "Ações",

View File

@@ -1,13 +1,16 @@
import './assets/styles/index.scss'
import '@ant-design/v5-patch-for-react-19'
import ReactDOM from 'react-dom/client'
import { createRoot } from 'react-dom/client'
import App from './App'
import MiniApp from './windows/mini/App'
if (location.hash === '#/mini') {
document.getElementById('spinner')?.remove()
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(<MiniApp />)
const root = createRoot(document.getElementById('root') as HTMLElement)
root.render(<MiniApp />)
} else {
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(<App />)
const root = createRoot(document.getElementById('root') as HTMLElement)
root.render(<App />)
}

View File

@@ -1,14 +1,20 @@
import { EllipsisOutlined } from '@ant-design/icons'
import { Agent } from '@renderer/types'
import type { Agent } from '@renderer/types'
import { getLeadingEmoji } from '@renderer/utils'
import { Dropdown } from 'antd'
import { FC, memo } from 'react'
import { type FC, memo } from 'react'
import styled from 'styled-components'
interface Props {
agent: Agent
onClick: () => void
contextMenu?: { label: string; onClick: () => void }[]
contextMenu?: {
key: string
label: string
icon?: React.ReactNode
danger?: boolean
onClick: () => void
}[]
menuItems?: {
key: string
label: string
@@ -58,9 +64,14 @@ const AgentCard: FC<Props> = ({ agent, onClick, contextMenu, menuItems }) => {
<Dropdown
menu={{
items: contextMenu.map((item) => ({
key: item.label,
label: item.label,
onClick: () => item.onClick()
...item,
onClick: (e) => {
e.domEvent.stopPropagation()
e.domEvent.preventDefault()
setTimeout(() => {
item.onClick()
}, 0)
}
}))
}}
trigger={['contextMenu']}>

View File

@@ -2,7 +2,7 @@ import { DeleteOutlined, EditOutlined, PlusOutlined, SortAscendingOutlined } fro
import { useAgents } from '@renderer/hooks/useAgents'
import AssistantSettingsPopup from '@renderer/pages/settings/AssistantSettings'
import { createAssistantFromAgent } from '@renderer/services/AssistantService'
import { Agent } from '@renderer/types'
import type { Agent } from '@renderer/types'
import { Col, Row } from 'antd'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
@@ -45,7 +45,7 @@ const MyAgents: React.FC<Props> = ({ onClick, search }) => {
return (
<Row gutter={[20, 20]}>
{filteredAgents.map((agent) => {
const dropdownMenuItems = [
const menuItems = [
{
key: 'edit',
label: t('agents.edit.title'),
@@ -73,29 +73,9 @@ const MyAgents: React.FC<Props> = ({ onClick, search }) => {
}
]
const contextMenuItems = [
{
label: t('agents.edit.title'),
onClick: () => AssistantSettingsPopup.show({ assistant: agent })
},
{
label: t('agents.add.button'),
onClick: () => createAssistantFromAgent(agent)
},
{
label: t('common.delete'),
onClick: () => handleDelete(agent)
}
]
return (
<Col span={6} key={agent.id}>
<AgentCard
agent={agent}
onClick={() => onClick?.(agent)}
contextMenu={contextMenuItems}
menuItems={dropdownMenuItems}
/>
<AgentCard agent={agent} onClick={() => onClick?.(agent)} contextMenu={menuItems} menuItems={menuItems} />
</Col>
)
})}

View File

@@ -1,5 +1,5 @@
import MinAppIcon from '@renderer/components/Icons/MinAppIcon'
import MinApp from '@renderer/components/MinApp'
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
import { useMinapps } from '@renderer/hooks/useMinapps'
import { MinAppType } from '@renderer/types'
import type { MenuProps } from 'antd'
@@ -15,13 +15,14 @@ interface Props {
}
const App: FC<Props> = ({ app, onClick, size = 60 }) => {
const { openMinappKeepAlive } = useMinappPopup()
const { t } = useTranslation()
const { minapps, pinned, updatePinnedMinapps } = useMinapps()
const { minapps, pinned, disabled, updateMinapps, updateDisabledMinapps, updatePinnedMinapps } = useMinapps()
const isPinned = pinned.some((p) => p.id === app.id)
const isVisible = minapps.some((m) => m.id === app.id)
const handleClick = () => {
MinApp.start(app)
openMinappKeepAlive(app)
onClick?.()
}
@@ -33,6 +34,18 @@ const App: FC<Props> = ({ app, onClick, size = 60 }) => {
const newPinned = isPinned ? pinned.filter((item) => item.id !== app.id) : [...(pinned || []), app]
updatePinnedMinapps(newPinned)
}
},
{
key: 'hide',
label: t('minapp.sidebar.hide.title'),
onClick: () => {
const newMinapps = minapps.filter((item) => item.id !== app.id)
updateMinapps(newMinapps)
const newDisabled = [...(disabled || []), app]
updateDisabledMinapps(newDisabled)
const newPinned = pinned.filter((item) => item.id !== app.id)
updatePinnedMinapps(newPinned)
}
}
]

View File

@@ -0,0 +1,143 @@
import {
FileExcelFilled,
FileImageFilled,
FileMarkdownFilled,
FilePdfFilled,
FilePptFilled,
FileTextFilled,
FileUnknownFilled,
FileWordFilled,
FileZipFilled,
FolderOpenFilled,
GlobalOutlined,
LinkOutlined
} from '@ant-design/icons'
import { Flex } from 'antd'
import React, { memo } from 'react'
import styled from 'styled-components'
interface FileItemProps {
fileInfo: {
name: React.ReactNode | string
ext: string
extra?: React.ReactNode | string
actions: React.ReactNode
}
}
const getFileIcon = (type?: string) => {
if (!type) return <FileUnknownFilled />
const ext = type.toLowerCase()
if (['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'].includes(ext)) {
return <FileImageFilled />
}
if (['.doc', '.docx'].includes(ext)) {
return <FileWordFilled />
}
if (['.xls', '.xlsx'].includes(ext)) {
return <FileExcelFilled />
}
if (['.ppt', '.pptx'].includes(ext)) {
return <FilePptFilled />
}
if (ext === '.pdf') {
return <FilePdfFilled />
}
if (['.md', '.markdown'].includes(ext)) {
return <FileMarkdownFilled />
}
if (['.zip', '.rar', '.7z', '.tar', '.gz'].includes(ext)) {
return <FileZipFilled />
}
if (['.txt', '.json', '.log', '.yml', '.yaml', '.xml', '.csv'].includes(ext)) {
return <FileTextFilled />
}
if (['.url'].includes(ext)) {
return <LinkOutlined />
}
if (['.sitemap'].includes(ext)) {
return <GlobalOutlined />
}
if (['.folder'].includes(ext)) {
return <FolderOpenFilled />
}
return <FileUnknownFilled />
}
const FileItem: React.FC<FileItemProps> = ({ fileInfo }) => {
const { name, ext, extra, actions } = fileInfo
return (
<FileItemCard>
<CardContent>
<FileIcon>{getFileIcon(ext)}</FileIcon>
<Flex vertical gap={0} flex={1} style={{ width: '0px' }}>
<FileName>{name}</FileName>
{extra && <FileInfo>{extra}</FileInfo>}
</Flex>
{actions}
</CardContent>
</FileItemCard>
)
}
const FileItemCard = styled.div`
background: rgba(255, 255, 255, 0.04);
border-radius: 8px;
overflow: hidden;
border: 0.5px solid var(--color-border);
flex-shrink: 0;
transition: box-shadow 0.2s ease;
--shadow-color: rgba(0, 0, 0, 0.05);
&:hover {
box-shadow:
0 10px 15px -3px var(--shadow-color),
0 4px 6px -4px var(--shadow-color);
}
body[theme-mode='dark'] & {
--shadow-color: rgba(255, 255, 255, 0.02);
}
`
const CardContent = styled.div`
padding: 8px 16px;
display: flex;
align-items: center;
gap: 16px;
`
const FileIcon = styled.div`
color: var(--color-text-3);
font-size: 32px;
`
const FileName = styled.div`
font-size: 15px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
transition: color 0.2s ease;
span {
font-size: 15px;
}
&:hover {
color: var(--color-primary);
}
`
const FileInfo = styled.div`
font-size: 13px;
color: var(--color-text-2);
`
export default memo(FileItem)

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