Compare commits

..

153 Commits

Author SHA1 Message Date
kangfenmao
9dfa81f11f chore(version): 1.3.11 2025-05-23 17:15:54 +08:00
kangfenmao
3693e115a6 chore: update release notes and improve README assets
- Updated release notes to include new features such as TokenFlux service support, Claude 4 model integration, and fixes for various issues including Windows user startup problems and search crashes.
- Replaced outdated screenshots in README files with new images across English, Japanese, and Chinese documentation.
- Enhanced the 'Related Projects' section for better visibility.
2025-05-23 17:11:33 +08:00
kangfenmao
2f3a3c8c48 refactor: remove manual chunking logic from Vite configuration
- Eliminated custom output chunking strategy for worker files and node_modules from the electron Vite configuration.
- Streamlined the configuration for improved maintainability and clarity.
2025-05-23 17:10:23 +08:00
LiuVaayne
f6d71868cb feat: support tokenflux provider (#6358)
* feat: add Cherry Cloud provider with associated assets and localization support

* feat: add Cherry Cloud provider support with OAuth integration and IPC handling

* fix: add success message for Cherry Cloud API key update

* feat: enhance provider navigation with dynamic ID in URL and update selected provider state

* feat: implement Cherry Cloud server synchronization with token management and error handling

* feat: add CherryCloud provider configuration and token management

* feat: integrate TokenFlux provider support and update related configurations

fix: update redux-persist version to 104

refactor: remove redundant tokenflux provider model assignment in migration

fix: update migration to add TokenFlux provider instead of CherryCloud

* feat: enhance TokenFlux server synchronization with API key authentication support

* feat: update TokenFlux provider assets and add new models

* feat: update migration logic for version 106 to add TokenFlux provider

* feat: disable TokenFlux provider by default in INITIAL_PROVIDERS

* feat: add TokenFlux billing URLs to providerCharge and providerBills functions
2025-05-23 17:10:07 +08:00
beyondkmp
e5bf6916a6 chore: update electron version to 35.4.0 in package.json and yarn.lock 2025-05-23 16:28:27 +08:00
beyondkmp
7c119e2747 feat: add navigation buttons for webview history in MinApp popup (#6342)
* feat: add navigation buttons for webview history in MinApp popup

- Implemented 'Go Back' and 'Go Forward' functionality in the MinApp popup.
- Added corresponding translations for English, Japanese, Russian, and Chinese locales.
- Included icons for navigation buttons to enhance user experience.

* fix: update Russian and Traditional Chinese translations for UI elements

- Revised translations for "rightclick_copyurl", "close", and "minimize" to improve clarity and consistency in the Russian and Traditional Chinese locales.
- Ensured that the translations align better with user expectations and common usage.

* fix: update Russian translations for MinApp popup UI elements

- Revised translations for "close" and "minimize" to specify their context within the MinApp, enhancing clarity for users.
- Ensured consistency with existing translations and improved user understanding of the interface.

---------

Co-authored-by: beyondkmp <beyondkmkp@gmail.com>
2025-05-23 15:29:13 +08:00
kangfenmao
03ef52c6a7 fix: ensure correct PATH assignment in shell environment
- Updated the environment variable initialization to use a consistent type.
- Added logic to set the PATH variable correctly, ensuring it falls back to existing values if necessary.
2025-05-23 15:25:01 +08:00
kangfenmao
e21e0d238e fix: minapp search error 2025-05-23 15:18:58 +08:00
jtsang4
631fa3a42a feat: support pin topic to the top
Signed-off-by: jtsang4 <info@jtsang.me>
2025-05-23 13:58:48 +08:00
karl
ee042e11f1 fix: editing user messages is not re-sent; it can only be saved#6327 2025-05-23 12:57:54 +08:00
kangfenmao
178d164ff9 chore(version): 1.3.10 2025-05-23 11:17:12 +08:00
suyao
e5ded81d9b feat: gemini thinking summary support
- Removed unnecessary content accumulation and streamlined chunk processing logic.
- Introduced distinct handling for 'thinking' and 'text' parts, ensuring accurate onChunk calls for both types.
- Enhanced timing tracking for reasoning content, improving overall responsiveness in streaming scenarios.
2025-05-23 11:10:46 +08:00
hutchisr
c8e6872fb8 feat: Support Claude 4 (#6328) 2025-05-23 08:45:44 +08:00
one
2820f85be4 feat: toggle MCP servers on the card list (#6232) 2025-05-23 00:39:02 +08:00
suyao
1989f246fd feat: add support for 'grok' provider in web search functionality
- Enhanced `isWebSearchModel` to recognize 'grok' as a valid web search model.
- Updated `getOpenAIWebSearchParams` to return specific search parameters for 'grok'.
- Modified `OpenAIProvider` to handle citations from 'grok' in web search results.
- Added 'grok' to the `WebSearchSource` enum for citation formatting.
2025-05-22 23:28:32 +08:00
kangfenmao
3ac414e97b fix: handle proxy environment variables for bun command in MCPService
- Added logic to remove proxy environment variables when the command ends with 'bun'.
- Introduced a new private method `removeProxyEnv` to clean up the environment variables before starting the server.
2025-05-22 22:57:49 +08:00
kangfenmao
bcb93fc2d0 fix: adjusted max-width in McpDescription.tsx 2025-05-22 22:57:40 +08:00
karl
9f579334f8 fix: shiki does not load language as a fallback & themes error (#6281)
* fix: shiki does not load language as a fallback & themes error

* feat: shiki automatic loading language
2025-05-22 22:00:30 +08:00
自由的世界人
6f75b1738d fix: #6301 (#6317)
* fix: #6301

* fix: update dependencies in useUpdateHandler and add eslint comment in ContentSearch
2025-05-22 21:24:40 +08:00
SuYao
72d939721d fix: non-streaming reasoning_content (#6308)
* fix: non-streaming reasoning_content

* fix: streamline reasoning handling in OpenAIProvider
2025-05-22 20:35:47 +08:00
Pleasurecruise
7d6ef1d69a fix: handle empty block content in MessageTranslate component 2025-05-22 19:39:34 +08:00
SuYao
e162da55bd fix: enhance backup and restore functionality with skip option (#6294)
* fix: enhance backup and restore functionality with skip option

- Updated `restore` and `restoreFromWebdav` methods in `BackupManager` to include a `skipBackupFile` parameter, allowing users to skip restoring the Data directory if desired.
- Modified corresponding IPC calls in `preload` and `BackupService` to support the new parameter.
- Added success message translations in multiple languages for improved user feedback.

* fix: integrate skipBackupFile option in RestorePopup for enhanced restore functionality

* refactor: remove skipBackupFile parameter from restore methods for simplified usage

- Updated `restore` and `restoreFromWebdav` methods in `BackupManager`, `BackupService`, and `NutstoreService` to remove the `skipBackupFile` parameter, streamlining the restore process.
- Adjusted corresponding IPC calls in `preload` and `RestorePopup` to reflect the changes, ensuring consistent functionality across the application.
2025-05-22 19:38:34 +08:00
kangfenmao
fc5209723f build: add win-sign script 2025-05-22 19:33:40 +08:00
kangfenmao
75153ce83c chore: upgrade yarn version to v4.9.1 2025-05-22 15:48:20 +08:00
kangfenmao
fd1cf1331f feat: implement message location functionality and refactor multi-select handling
- Added event listeners for LOCATE_MESSAGE events to scroll to specific messages in the MessageGroup component.
- Introduced a new SelectionBox component to handle multi-select functionality, allowing users to select multiple messages with drag actions.
- Refactored Messages component to remove unused multi-select logic and improve overall structure.
- Cleaned up code by removing commented-out sections and unnecessary state management related to dragging.
2025-05-22 15:14:13 +08:00
MyPrototypeWhat
a5738fdae5 feat: add functionality to insert messages at a specific index in the… (#6299)
* feat: add functionality to insert messages at a specific index in the Redux store

- Introduced a new interface for inserting messages at a specified index.
- Implemented the insertMessageAtIndex reducer to handle message insertion.
- Updated saveMessageAndBlocksToDB to support message insertion logic.
- Modified appendAssistantResponseThunk to utilize the new insertion functionality.

* feat: integrate multi-select mode handling in MessageGroup component

- Added useChatContext hook to access multi-select mode state.
- Updated isGrouped logic to account for multi-select mode, ensuring proper message grouping behavior.
- Enhanced MessageWrapper styles for better layout management in different modes.

---------

Co-authored-by: kangfenmao <kangfenmao@qq.com>
2025-05-22 14:57:54 +08:00
chenxue
86b95ee17a fix: aihubmix provider model proxy rule (#6293)
Update AihubmixProvider.ts

Co-authored-by: zhaochenxue <zhaochenxue@bixin.cn>
2025-05-22 14:46:40 +08:00
one
587e1d9971 fix: Use effective theme for code style in SettingsTab (#6305)
* Fix: Use effective theme for code style in SettingsTab

The SettingsTab component was previously using the theme setting directly from useSettings to determine whether to apply the light or dark code style. This caused an issue when the theme was set to 'auto', as it wouldn't correctly reflect the actual system theme (light or dark).

This commit modifies SettingsTab.tsx to use the `theme` from the `useTheme` hook (which provides the effective theme) for the logic that determines the code editor and preview styles. This ensures that the code style accurately reflects your current effective theme, including when 'auto' theme is selected and the OS theme changes.

* Refactor: Remove unnecessary comments in SettingsTab

This commit removes non-essential comments that were added during a previous refactoring of `SettingsTab.tsx`. The core logic for using the effective theme for code style selection remains unchanged. This change is purely for code cleanliness.

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2025-05-22 14:45:36 +08:00
SuYao
b8e978f2a1 fix: token 取整 (#6300) 2025-05-22 14:13:28 +08:00
kangfenmao
bd8452032e fix: enhance message loading and search functionality in HistoryPage and SearchResults
- Integrated Redux dispatch to load topic messages when a message is clicked in HistoryPage.
- Updated SearchResults to utilize message blocks for improved search results, including content extraction.
- Refactored state management to accommodate new content structure in search results.
2025-05-22 11:48:38 +08:00
kangfenmao
0079f4f437 refactor: update Gemini file upload method to accept baseURL parameter
- Modified the uploadFile method in GeminiService to include baseURL in the parameters.
- Updated the corresponding calls in the preload and renderer layers to pass the baseURL along with the apiKey.
2025-05-22 10:11:26 +08:00
one
0436ea671e refactor: CodePreview fade in on the first highlighting (#6228)
* refactor(CodePreview): fade in on the first highlighting

* refactor: improve code placeholder style
2025-05-22 00:05:30 +08:00
kangfenmao
05c29b2bc1 revert: mcp run python (#6151)
This reverts commit c468c3cfd5.
2025-05-22 00:04:15 +08:00
one
3f97aef93f fix: show x-scrollbar in codeblock if unwrapped, simplify style definitions (#6266)
* fix: show x-scrollbar in codeblock if unwrapped, simplify style definitions

* chore: clean up useless code
2025-05-21 23:39:23 +08:00
SuYao
2e3adb0b1b fix: update service tier check to exclude GitHub and Copilot models
* refactor: improve shiki highlighter utils and reuse it in ShikiStreamService

* refactor: reuse shiki highlighter and markdown-it renderer

* hotfix: exclude GitHub and Copilot models

* fix: update service tier check to exclude GitHub and Copilot models

---------

Co-authored-by: one <wangan.cs@gmail.com>
2025-05-21 23:29:55 +08:00
kangfenmao
507da84b80 chore: update release notes in electron-builder.yml
- Added new features including message notification functionality and support for Google Mini Programs.
- Improved MCP capabilities to run Python code and fixed several issues related to message editing and display.
- Updated release notes to reflect these changes and enhancements.
2025-05-21 23:23:43 +08:00
suyao
b30a30efa8 refactor: enhance notification handling based on page context
- Updated NotificationProvider to truncate long messages for better display.
- Modified message sending logic in messageThunk to prevent notifications on the home page, improving user experience.
2025-05-21 23:18:55 +08:00
kangfenmao
c29ff577ed refactor: update IPC channel names for notifications
- Renamed IPC channels for notifications to improve clarity and consistency.
- Updated related handlers in the main process and preload scripts to reflect the new naming convention.
- Enhanced notification service to respect user settings before sending notifications.
2025-05-21 23:11:34 +08:00
kangfenmao
f9512d6ef2 fix: handle user cancellation and improve error reporting in file saving process
- Added a check for user cancellation in the file save dialog, rejecting the promise if canceled.
- Enhanced error handling to reject the promise with a detailed error message instead of returning null.
2025-05-21 22:40:20 +08:00
kangfenmao
326163d798 style: update border color in SearchBarContainer for improved UI consistency 2025-05-21 22:31:55 +08:00
kangfenmao
7099bee833 refactor: enhance multi-selection handling in Inputbar and MessageEditor
- Integrated `useAppSelector` in Inputbar to manage multi-selection state.
- Updated Inputbar to conditionally render based on multi-selection mode.
- Modified MessageEditor to display the resend button only for assistant messages, improving UI clarity.
2025-05-21 22:22:01 +08:00
kangfenmao
b1839b722f refactor: migrate chat context to a custom hook and enhance multi-selection functionality
- Replaced the ChatContext with a custom hook `useChatContext` for better modularity and reusability.
- Updated components to utilize the new hook, passing the active topic as an argument.
- Enhanced multi-selection logic and state management for messages, improving user experience in the chat interface.
- Removed the old ChatContext file to streamline the codebase.
2025-05-21 21:40:27 +08:00
kangfenmao
46ae4f9b55 refactor: improve ContextMenu and Message components layout
- Replaced the div in ContextMenu with a styled component for better styling control.
- Enhanced Message component to handle editing state more cleanly, separating the editor from the message display.
- Adjusted styling for the MessageEditor and FileBlocksContainer for improved layout and responsiveness.
2025-05-21 21:15:39 +08:00
one
a9a0ae87d3 refactor: reuse shiki highlighter utils (#6235)
* refactor: improve shiki highlighter utils and reuse it in ShikiStreamService

* refactor: reuse shiki highlighter and markdown-it renderer

* refactor: import shiki/markdown-it/core dynamically

* chore: update shiki
2025-05-21 19:06:56 +08:00
自由的世界人
d9661602b2 fix: increase the upper limit of issue-management operations-per-run (#6257) 2025-05-21 15:31:28 +08:00
kangfenmao
877a1f8306 chore(version): 1.3.9 2025-05-21 15:14:37 +08:00
kangfenmao
5dd508b4f4 fix: english serif font rendering issue #6224
close #6224
2025-05-21 15:09:10 +08:00
kangfenmao
406bf6a509 fix: topic prompt not working #6226
close #6226
2025-05-21 14:26:24 +08:00
kangfenmao
f0ae2aa6dc chore: remove electron-notification-state dependency and update notification settings
- Removed the electron-notification-state package from dependencies as it is no longer used.
- Updated NotificationService to eliminate Do Not Disturb handling.
- Changed default notification settings in the Redux store to false for assistant messages, backups, and knowledge embedding.
2025-05-21 14:03:24 +08:00
kangfenmao
0884caea97 fix: settings -> openAI -> summaryText undefined 2025-05-21 13:57:59 +08:00
kangfenmao
8e870710b5 fix: message content width will exceed the bubble limit in narrow layout mode 2025-05-21 13:57:39 +08:00
kangfenmao
27a92463bf fix: topic deletion button covering the text 2025-05-21 13:56:47 +08:00
kangfenmao
3298b3f403 refactor: simplify MCP service integration and cleanup logic
- Replaced singleton pattern with direct instantiation of McpService for cleaner code.
- Updated IPC handlers to use the new mcpService instance directly.
- Removed unnecessary logging in Inputbar and MCPToolsButton components to streamline functionality.
- Cleaned up error handling in McpSettings to improve user experience.
2025-05-21 13:10:27 +08:00
kangfenmao
894c20dd05 fix: enhance OpenAI service tier handling and initialize settings during migration
- Updated getServiceTier method to ensure proper handling of undefined OpenAI models.
- Added initialization for OpenAI settings in the migration process to set default values if not present.
2025-05-21 13:02:25 +08:00
kangfenmao
a0945af285 feat: add Google miniapp 2025-05-21 12:46:20 +08:00
kangfenmao
6e12d2fa2e refactor: update MultiSelectionPopup icons and improve styling
- Replaced Ant Design icons with Lucide icons for a more modern look.
- Adjusted ActionButton styling to have a circular border radius.
- Updated translation keys in Chinese locales for better formatting.
- Enhanced event handling in ChatContext to manage multi-select mode more effectively.
- Cleaned up unused imports and props in MessageGroup and MessageSelect components.
2025-05-21 12:18:31 +08:00
SuYao
2798bd9d9d feat/notification (#6144)
* WIP

* feat: integrate notification system using Electron's Notification API

- Replaced the previous notification implementation with Electron's Notification API for better integration.
- Updated notification types and structures to support new features.
- Added translations for notification messages in multiple languages.
- Cleaned up unused dependencies related to notifications.

* refactor: remove unused node-notifier dependency from Electron config

* clean: remove node-notifier from asarUnpack in Electron config

* fix: update notification type in preload script to align with new structure

* feat: Integrate NotificationService for user notifications across various components

- Implemented NotificationService in useUpdateHandler to notify users of available updates.
- Enhanced KnowledgeQueue to send success and error notifications during item processing.
- Updated BackupService to notify users upon successful restoration and backup completion.
- Added error notifications in message handling to improve user feedback on assistant responses.

This update improves user experience by providing timely notifications for important actions and events.

* feat: Refactor notification handling and integrate new notification settings

- Moved SYSTEM_MODELS to a new file for better organization.
- Enhanced notification handling across various components, including BackupService and KnowledgeQueue, to include a source attribute for better context.
- Introduced notification settings in the GeneralSettings component, allowing users to toggle notifications for assistant messages, backups, and knowledge embedding.
- Updated the Redux store to manage notification settings and added migration logic for the new settings structure.
- Improved user feedback by ensuring notifications are sent based on user preferences.

This update enhances the user experience by providing customizable notification options and clearer context for notifications.

* feat: Add 'update' source to NotificationSource type for enhanced notification context

* feat: Integrate electron-notification-state for improved notification handling

- Added electron-notification-state package to manage Do Not Disturb settings.
- Updated NotificationService to respect user DND preferences when sending notifications.
- Adjusted notification settings in various components (BackupService, KnowledgeQueue, useUpdateHandler) to ensure notifications are sent based on user preferences.
- Enhanced user feedback by allowing notifications to be silenced based on DND status.

* feat: Add notification icon to Electron notifications

* fix: import SYSTEM_MODELS in EditModelsPopup for improved model handling

* feat(i18n): add knowledge base notifications in multiple languages

- Added success and error messages for knowledge base operations in English, Japanese, Russian, Chinese (Simplified and Traditional).
- Updated the KnowledgeQueue to utilize the new notification messages for better user feedback.

* feat(notification): introduce NotificationProvider and integrate into App component

- Added NotificationProvider to manage notifications within the application.
- Updated App component to include NotificationProvider, enhancing user feedback through notifications.
- Refactored NotificationQueue to support multiple listeners for notification handling.
2025-05-21 12:11:52 +08:00
自由的世界人
d07c6ecc6b feat: add message multiple select (#6085)
* feat: add message multiple select

* fix: build error

* feat: add drag-and-drop multi-selection

* fix: code review

* Revert "fix: code review"

This reverts commit 7e29d5147c.

* fix: hide input bar display

* fix: extract the ChatContext

* fix: eventemitter

* feat: enhance multi-select functionality with message registration

* fix: history page message search

* fix: build error

* fix: remove Event Emitter

* fix: build error

* feat: add hideMenuBar prop to MessageItem and integrate MessageEditingProvider

* fix: improve message selection logic and handle drag events

* fix: update translation keys for multiple select functionality

* fix: refactor message deletion logic and enhance message selection handling

* fix: replace useSelector with useStore for message selection in ChatContext

* fix: refactor MessageGroup to utilize context for multi-select handling and message registration

* Revert "fix: refactor MessageGroup to utilize context for multi-select handling and message registration"

This reverts commit f4d1454525.

* fix: simplify MessageGroup props and utilize context for message selection handling

* fix: streamline multi-select handling by consolidating context usage and simplifying component props
2025-05-21 11:22:36 +08:00
kangfenmao
57f10bf56f fix: sse mcp 404 not found 2025-05-21 10:50:28 +08:00
one
d4fc5d6503 fix: improve CodeEditor controlled mode, prevent unnecessary trimming (#6221)
* fix: improve CodeEditor controlled mode, prevent unnecessary trimming

* fix: useEffect dependency
2025-05-21 03:51:30 +08:00
one
55c57d72ba fix: handle undefined html title (#6229) 2025-05-20 20:57:14 +08:00
LiuVaayne
c468c3cfd5 Feat/mcp run python (#6151)
* feat: add MCP Run Python server and integrate Pyodide for executing Python code

* fix: comment out unused polyfill imports in MCP Run Python server
2025-05-20 20:47:30 +08:00
beyondkmp
bb3bfafe7e refactor(PasteService): optimize handler registration logic (#6223)
- Updated registerHandler to only log and update the handler if it changes, reducing unnecessary operations.
- Removed logging from unregisterHandler for cleaner code.
2025-05-20 19:12:45 +08:00
kangfenmao
0641857f26 chore(version): 1.3.8 2025-05-20 18:41:41 +08:00
kangfenmao
0d5b9d8d62 feat: implement getFilePath method in FileManager and update KnowledgeContent to use it
- Added getFilePath method in FileManager to construct file paths based on file ID and extension.
- Updated KnowledgeContent to utilize the new getFilePath method for opening file paths, improving code clarity and maintainability.
2025-05-20 18:04:01 +08:00
kangfenmao
09952a4d3b Revert "feat: add resolveFilePath functionality to resolve restoring from different computer (#5980)"
This reverts commit 3abe0e803c.
2025-05-20 17:44:04 +08:00
kangfenmao
e8753e0cf3 refactor: improve FloatingSidebar and HomeTabs components
- Simplified the onOpenChange handler in FloatingSidebar for better readability.
- Added style prop to HomeTabs for enhanced customization.
- Adjusted Container style in HomeTabs to merge with existing border styles.
2025-05-20 17:17:57 +08:00
George Zhao
05dc5da7b0 fix: add context menu trigger to FloatingSidebar component 2025-05-20 17:10:47 +08:00
kangfenmao
12f5d9acfd feat: enhance ContentSearch and MessageTools components
- Added placeholder and adjusted styles for the Input in ContentSearch.
- Updated SearchBarContainer to use fixed positioning and improved padding.
- Refactored MessageTools to render raw content correctly and added a new MarkdownContainer for better styling.
- Minor adjustments to other components for improved layout and user experience.
2025-05-20 15:33:10 +08:00
Zhaker
30885655dd fix: knowledge icon consistency across components (#6209)
feat: knowledge icon consistency across components
2025-05-20 15:07:57 +08:00
karl
49d4ae6e9b fix(mcp): mcp result preview is missing parameters (#6165) 2025-05-20 14:46:21 +08:00
Wei
2d7caf9fc5 feat: add new model provider BurnCloud 2025-05-20 13:55:57 +08:00
fullex
5cf0a43b2c chore: update Hailuo logo (#6208)
* feat: update Hailuo model configuration with new logo property and border option

* chore: update hailuo_dark model image
2025-05-20 13:11:54 +08:00
beyondkmp
98426e084a refactor: centralize paste handling logic with PasteService (#6199)
- Integrated PasteService for handling paste events in Inputbar and MessageEditor components.
- Removed redundant paste handling code and improved maintainability.
- Registered paste handlers and set last focused component for better user experience.
- Ensured consistent behavior for text and file pasting across components.

Co-authored-by: beyondkmp <beyondkmkp@gmail.com>
2025-05-20 13:09:37 +08:00
kangfenmao
52559534c6 chore(version): 1.3.7 2025-05-20 11:33:52 +08:00
kangfenmao
ab5ffe4e2e fix: set default initial values for rendering speed in aihubmixConfig
- Added initialValue 'DEFAULT' for renderingSpeed in multiple mode configurations to ensure consistent default behavior across the application.
2025-05-20 11:31:35 +08:00
jwcrystal
3d8f514f30 feat: add MCP servers via JSON quickly (#6099)
* feat: add MCP servers via JSON quickly

* refactor(MCPSettings): Extract JSON parsing logic into a helper function

* feat: json linter for EditMcpJsonPopup

* feat(mcp): confirm the MCP server status as connection

* refactor(AddMcpServerModal): 移除冗余注释并修复加载状态

* feat(MCPSettings): Add support for SSE and streamableHttp formats and optimize server configuration parsing

- Add server type validation to ensure the type is stdio, SSE, or streamableHttp
- Optimize JSON parsing logic to ensure server configuration completeness and validity
- Update example text to provide more detailed configuration examples

* fix(MCPSettings): fix AddMcpServerModal default baseUrl login

移除serverToAdd.url作为baseUrl的备选值,确保baseUrl仅使用serverToAdd.baseUrl的值

* feat(MCPSettings): support CodeEditor in AddMcpServerModal

* fix: Remove unnecessary type checks for JSON parsing login

* fix(MCPSettings): fix compatibility issues with the URL field when parsing server data

* refactor: remove unnessary cdoe

* chore: Add a server dropdown button to integrate new features in UI

- Integrate the two buttons for adding a server into a single dropdown menu to enhance user experience and simplify the interface

* chroe: modify the Dropdown items' name of addServer

* refactor(i18n): unify the translation for the MCP server import function

---------

Co-authored-by: one <wangan.cs@gmail.com>
2025-05-20 10:41:25 +08:00
kangfenmao
47faa6edf2 fix: update default values and improve component structure
- Changed default value for `getTrayOnClose` to true in `ConfigManager`.
- Removed fullscreen toggle logic from `WindowService`.
- Adjusted styles in `OpenAIAlert` for better spacing.
- Reorganized imports in `Navbar` and updated component paths in `AssistantsTab` and `SettingsTab`.
- Added new components `AssistantItem` and `OpenAISettingsGroup` for better modularity.
- Enhanced `SettingGroup` styles for improved UI consistency.
- Updated `QuickPhraseSettings` to utilize theme context.
- Minor fixes and refactoring across various services and components.
2025-05-20 00:20:02 +08:00
suyao
a95ace3dc5 fix: update file path resolution in new Electron 2025-05-19 23:02:14 +08:00
kangfenmao
582427663f docs: update branching strategy documentation and add English and Chinese versions
- Updated the README to link to the new English branching strategy document.
- Added a new English version of the branching strategy document.
- Removed the outdated branching strategy document.
- Added a Chinese version of the branching strategy document.
2025-05-19 22:25:46 +08:00
beyondkmp
3abe0e803c feat: add resolveFilePath functionality to resolve restoring from different computer (#5980)
* feat: add resolveFilePath functionality to file management

* Added new IPC channel for resolving file paths.
* Implemented resolveFilePath method in FileStorage service.
* Updated FileManager to utilize the new resolveFilePath method.
* Enhanced preload API to expose resolveFilePath to the renderer.
* Updated KnowledgeService to ensure file paths are correctly resolved in knowledge bases.

* refactor: remove unused path import from preload index

* Removed the unused 'resolve' import to clean up the codebase.
* Improved code readability by eliminating unnecessary dependencies.

---------

Co-authored-by: beyondkmp <beyondkmkp@gmail.com>
2025-05-19 21:58:44 +08:00
kangfenmao
94b602b250 refactor: remove constructor from KnowledgeQueue and invoke checkAllBases in useAppInit hook 2025-05-19 21:28:25 +08:00
kangfenmao
d42bf89045 Merge branch 'develop' 2025-05-19 21:23:17 +08:00
jwcrystal
75f25d8a44 feat: support default Quick Assistant model (#6150)
* feat: support default Quick Assistant model

- support the configuration of the quick assistant model.
- manage the models independently in Quick Assistant and Default Assistant.

* docs(i18n): Add translation for quick assistant model

* fix(llm): fix the default value of quickAssistantModel to silicon[1]

* refactor(i18n): uniformly the terms, changing "快速助手" to "快捷助手"
2025-05-19 20:49:13 +08:00
SuYao
ae163ff0ed hotfix: add OpenAI settings tab and related functionality (#6040)
* feat: add OpenAI settings tab and related functionality

* fix: update related logic to support flexible service layer.

* fix(OpenAIResponseProvider): remove unused isOpenAILLMModel import
2025-05-19 20:18:16 +08:00
SuYao
cc4008bf2b feat(settings): add OpenAI alert (#6164) 2025-05-19 20:06:54 +08:00
one
e9dc0d12d6 hotfix: respect user-defined model group name (#6174) 2025-05-19 18:40:01 +08:00
SuYao
00f1fb1e11 Hotfix/gemini-para-bug (#6173)
* fix(OpenAIProvider): enhance model support and formatting logic

* fix(OpenAIProvider): Gemini OpenAI Compatible
2025-05-19 17:34:35 +08:00
beyondkmp
db01b4981d feat: add zoom factor handling on window restore in WindowService (#6169)
Co-authored-by: beyondkmp <beyondkmkp@gmail.com>
2025-05-19 17:14:55 +08:00
SuYao
6036a94690 hotfix: enhance reasoning summary handling in OpenAI response processing (#6037) 2025-05-19 17:07:35 +08:00
jwcrystal
0c32ac1262 refactor(Scrollbar): Optimize scroll handling logic to support external scroll events (#6047)
* refactor(Scrollbar): Optimize scroll handling logic to support external scroll events

- Refactor `onScroll` logic to support external scroll events
- Integrate with `useScrollPosition` hook for better scroll state management
- Memorize the scoll state for better user experience
- Fix type definition for `ref` attribute
- Remove unnecessary `ref` type overrides
- Improve component compatibility and maintainability

* perf(useScrollPosition): Optimize scroll position updates using requestAnimationFrame

- Wrap the `window.keyv.set` call in `requestAnimationFrame` to reduce unnecessary performance overhead and improve responsiveness during scrolling.

* fix(Messages): Remove unused FC imports and add onComponentUpdate and onFirstUpdate properties
2025-05-19 16:33:06 +08:00
Morax
e3f5999362 feat: Add-aihubmix-ideogram-v3 (#5958)
* chore: update Yarn version to 4.9.1 and add rendering speed option for V3 model

* Discard changes to .yarnrc.yml

* Discard changes to .yarn/releases/yarn-4.6.0.cjs

* Update package.json

* Delete .yarn/releases/yarn-4.9.1.cjs

* Discard changes to .yarn/releases/yarn-4.6.0.cjs

* docs: Update README.zh.md add GitCode✖️Cherry Studio【新源力】贡献挑战赛

GitCode✖️Cherry Studio【新源力】贡献挑战赛

* fix: Update the file permissions for yarn-4.6.0.cjs, modify image links and formatting in README.zh.md

* clean

* refactor: improve switch case blocks and update config parameters in AihubmixPage

* feat: add error handling for empty image URLs and update localization messages

* refactor: replace modal error handling with warning messages for empty image URLs

* feat: update localization for rendering speed and translating messages in Japanese, Russian, and Traditional Chinese

* feat: add style types and rendering speed options to localization files

---------

Co-authored-by: 亢奋猫 <kangfenmao@qq.com>
Co-authored-by: suyao <sy20010504@gmail.com>
2025-05-19 16:30:07 +08:00
SuYao
f20b8c58ee refactor: implement message editing and resend functionality (#5901)
* feat: implement message editing and resend functionality with block management

* fix: move start_time_millsec initialization to onChunk for accurate timing

* feat: refactor message update thunks to separate block addition and updates

* feat: enhance MessageBlockEditor with toolbar buttons and attachment functionality

* refactor: implement message editing context and integrate with message operations

* style: adjust padding in MessageBlockEditor and related components

* refactor: remove MessageEditingContext and integrate editing logic directly in Message component

* refactor: streamline message rendering logic by using conditional rendering for MessageEditor and MessageContent

* refactor: remove redundant ipcRenderer variable in useMCPServers hook

* fix: Add mock for electron's ipcRenderer to support testing

* test: Update mocks for ipcRenderer and remove redundant window.electron mock

* fix: enhance file handling in MessageEditor with new Electron API

- Added support for file dragging with visual feedback.
- Improved file type validation during drag-and-drop and clipboard pasting.
- Displayed user notifications for unsupported file types.
- Refactored file handling logic for better clarity and maintainability.
2025-05-19 15:38:00 +08:00
MyPrototypeWhat
553e3d7989 refactor(messageThunk): optimize message update logic with atomic modifications
* Wrapped message and block updates in a database transaction for improved consistency.
* Replaced direct updates with atomic modifications using where().modify() for better performance and clarity.
* Enhanced error handling for message updates to ensure robustness.
2025-05-19 13:48:47 +08:00
suyao
b1e0b56783 fix: update token limits for Claude models 2025-05-19 13:47:51 +08:00
LiuVaayne
6356e1c0c2 refactor: improve sanitization and formatting in buildFunctionCallToo… (#6152)
refactor: improve sanitization and formatting in buildFunctionCallToolName function
2025-05-19 11:20:38 +08:00
George Zhao
362fdc069e feat: add FloatingSidebar component and integrate assistant switching… (#5852)
* feat: add FloatingSidebar component and integrate assistant switching functionality

* refactor: simplify FloatingSidebar by removing unused hooks and components

* refactor: remove unused AddAssistantPopup and related code from FloatingSidebar

* feat: implement sidebar hide cooldown and adjust tooltip delays in Navbar.

* feat: integrate HomeTabs into FloatingSidebar and update Navbar props

* refactor: remove commented-out code and unused components from FloatingSidebar

* fix: update Popover placement from rightTop to bottomRight in FloatingSidebar.

* feat: add forceToSeeAllTab prop to HomeTabs for improved tab visibility control

* fix: update HomeTabs logic to respect forceToSeeAllTab prop for tab selection

* feat: pass position prop to FloatingSidebar and HomeTabs for consistent layout control

* feat: integrate FloatingSidebar into Navbar for improved topic visibility and update HomeTabs logic for consistent tab rendering

* fix: remove unused showTopics from Navbar component

* feat: enhance topic visibility control in Navbar with cooldown logic for sidebar toggle

* fix: add onMouseOut handler to NavbarIcon for sidebar cooldown reset

---------

Co-authored-by: George Zhao <georgezhao@SKJLAB>
2025-05-19 11:05:15 +08:00
kangfenmao
ee67f7bf9b fix: update min token limit for qwen3 models in THINKING_TOKEN_MAP 2025-05-19 10:49:33 +08:00
kangfenmao
5cee35b167 Merge branch 'main' into develop 2025-05-19 10:12:15 +08:00
one
cd02ee3125 refactor: transparent border for code blocks 2025-05-19 08:36:09 +08:00
fullex
7dac8cee64 refactor: simplify font family handling and improve layout styles in MessageTools and ThinkingBlock components 2025-05-19 08:32:37 +08:00
SuYao
5d3b751aa0 fix: 多发消息时除fold布局以外的其他布局不能被渲染 (#6143) 2025-05-19 01:43:21 +08:00
SuYao
0707be1e1f Revert "feat: add MCP Run Python server and integrate Pyodide for exe… (#6141)
Revert "feat: add MCP Run Python server and integrate Pyodide for executing P… (#5793)"

This reverts commit 086b09d99a.
2025-05-19 00:54:32 +08:00
kangfenmao
5e1c45370b chore(version): 1.3.6 2025-05-18 23:57:53 +08:00
kangfenmao
d4abea4101 refactor(SyntaxHighlighter): modularize highlighter logic and improve theme/language loading
* Moved highlighter initialization and loading functions to a new utility file for better organization.
* Simplified theme and language loading in the SyntaxHighlighterProvider using the new utility functions.
* Removed redundant code and improved readability in MessageTools by introducing a new CollapsedContent component for rendering tool responses.
* Updated MCPDescription to use an async function for Shiki instance initialization.
2025-05-18 23:33:43 +08:00
kangfenmao
08e7b6a7ba feat(electron.vite.config): add manual chunking for vendor modules in output configuration 2025-05-18 23:33:43 +08:00
LiuVaayne
086b09d99a feat: add MCP Run Python server and integrate Pyodide for executing P… (#5793)
feat: add MCP Run Python server and integrate Pyodide for executing Python code
2025-05-18 23:31:05 +08:00
LiuVaayne
1475f75a35 feat: add buildFunctionCallToolName utility for generating tool names (#6136) 2025-05-18 23:05:44 +08:00
jwcrystal
b1babc8cb3 hotfix(i18n): Add multi-language support for network search settings (#6138) 2025-05-18 22:44:57 +08:00
Rudbeckia.hirta.L
537ada3256 fix: Search results are on the right side (#6133) 2025-05-18 22:21:13 +08:00
kangfenmao
bdbb937403 refactor: update spinner handling and improve initialization timing
* Modified the Content Security Policy to include 'unsafe-inline' for script-src.
* Changed the spinner display style from 'none' to 'flex' for better visibility.
* Removed the initSpinner function and directly initialized the spinner in useAppInit.
* Added console timing for initialization to track performance.
2025-05-18 22:05:35 +08:00
kangfenmao
8bfbbd497c refactor: streamline MCP service handling and improve IPC registration
* Refactored MCPService to implement a singleton pattern for better instance management.
* Updated IPC registration to utilize the new getMcpInstance method for handling MCP-related requests.
* Removed redundant IPC handlers from the main index file and centralized them in the ipc module.
* Added background throttling option in WindowService configuration to enhance performance.
* Introduced delays in MCPToolsButton to optimize resource and prompt fetching after initial load.
2025-05-18 22:04:56 +08:00
自由的世界人
4b2417ce37 hotfix: github models check error (#6128) 2025-05-18 20:23:29 +08:00
自由的世界人
68689692b0 hotfix: github models check error (#6128) 2025-05-18 20:16:33 +08:00
kangfenmao
03fa6b5a74 fix(WindowService): handle fullscreen toggle before hiding window 2025-05-18 19:43:47 +08:00
George Zhao
2202b82f33 fix: remove infinite token display for max count and simplify context count rendering. (#6103)
Co-authored-by: George Zhao <georgezhao@SKJLAB>
2025-05-18 18:37:08 +08:00
MyPrototypeWhat
fed855a4ae refactor(MessageGroup): optimize selected message handling with useMe… (#6124)
* refactor(MessageGroup): optimize selected message handling with useMemo and clean up unused code

* refactor(MainTextBlock): optimize citation handling with useMemo and improve code clarity

* fix:del console
2025-05-18 18:08:13 +08:00
SuYao
22ee6f042f Hotfix/rich text paste (#6122)
fix(Inputbar): 优化粘贴处理逻辑,优先处理文本粘贴
2025-05-18 17:16:05 +08:00
Konjac-XZ
114d4850b1 fix: Summary for single message export doesn't work. 2025-05-18 15:11:30 +08:00
purefkh
711888a897 fix: Prevent sending message during input method composition in mini window (#6104) 2025-05-18 15:09:56 +08:00
kangfenmao
f43c16b85f fix(WindowService): add backgroundThrottling option to Electron configuration 2025-05-18 14:41:10 +08:00
purefkh
2f47250748 fix(i18n): websearch has no translation (#6118) 2025-05-18 12:44:06 +08:00
kangfenmao
653f1d54d2 chore: update dependencies in yarn.lock
- Removed outdated dependencies including "@babel/plugin-syntax-jsx" and "@bcoe/v8-coverage".
- Added new dependencies such as "@sec-ant/readable-stream", "browserslist", and "cacheable-request".
- Updated versions for existing packages including "got", "ow", and "type-fest".
- Ensured compatibility with the latest versions of peer dependencies.
2025-05-18 11:58:02 +08:00
kangfenmao
f0e43e9bcd refactor: streamline SettingsTab layout and remove unnecessary Divider components 2025-05-18 11:50:26 +08:00
Konjac-XZ
7706a4cc36 fix: Summary for single message export doesn't work. 2025-05-18 10:21:45 +08:00
自由的世界人
bd65e7bac4 feat: make sidebar setting group collapsible (#6019)
* feat: code tools, editor, executor

CodeEditor & Preview
- CodeEditor: CodeMirror 6
  - Switch to CodeEditor in the settings
  - Support edit&save with a accurate diff&lookup strategy
  - Use CodeEditor for editing MCP json configuration
- CodePreview: Original Shiki syntax highlighting
  - Implemented using a custom Shiki stream tokenizer
  - Remov code caching as it is incompatible with the current streaming code highlighting
  - Add a webworker for shiki
- Other preview components
  - Merge MermaidPopup and Mermaid to MermaidPreview, use local mermaidjs
  - Show mermaid syntax error message on demand
  - Rename PlantUML to PlantUmlPreview
- Rename SyntaxHighlighterProvider to CodeStyleProvider for clarity
- Both light and dark themes are preserved for convenience

CodeToolbar
- Top sticky toolbar provides quick tools (left) and core tools (right)
- Quick tools are hidden under the `More` button to avoid clutter, while core tools are always visible
- View&edit mode
  - Allow switching between preview and edit modes
  - Add a split view

Code execution
- Pyodide for executing Python scripts
- Add a webworker for Pyodide

* fix: migrate version and lint error

* refactor: use constants for defining tool specs

* feat: make setting group collapsible

* fix: yarn.lock

* fix: conflict error

---------

Co-authored-by: one <wangan.cs@gmail.com>
2025-05-18 10:11:14 +08:00
one
f6a496d1b9 refactor: clean up code for MessageGroupModelList (#6084)
* refactor: clean up comments and useless z-index

* refactor: remove useless styles, restore segment animation, extract renderLabel

* refactor: add left margin to the toggle button

* revert: font size
2025-05-18 10:05:19 +08:00
eeee0717
ba88a24455 fix(knowledge): remove topN 2025-05-18 10:02:05 +08:00
MyPrototypeWhat
42f5485899 fix: group message resend (#6106) 2025-05-18 09:52:27 +08:00
MyPrototypeWhat
e51a37cc74 fix: group message resend (#6106) 2025-05-17 23:53:57 +08:00
kangfenmao
5a7423463f Merge branch 'main' into develop 2025-05-17 21:41:38 +08:00
fullex
928a597d38 chore: remove unused packages (#5948)
* chore: update @cherrystudio/embedjs packages to version 0.1.29 and update sass to version 1.88.0; remove deprecated dependencies from package.json and yarn.lock

* chore: add node-stream-zip and update devDependencies in package.json and yarn.lock

* chore: reorganize and update @cherrystudio/embedjs dependencies in package.json

* chore: add zipread dependency to package.json and update yarn.lock

* chore: add @strongtz/win32-arm64-msvc dependency to package.json and update yarn.lock
2025-05-17 21:36:06 +08:00
purefkh
86a58ea050 fix: Prevent sending message during input method composition in mini window (#6104) 2025-05-17 21:34:17 +08:00
fullex
71ae6f2713 fix: font-family changed in Windows with new Electron (#6079) 2025-05-17 21:27:29 +08:00
icinggslits
dbc6014506 feat: Highlighted search in chat page (#3302)
* feat: Highlighted search in chat page

* Bug fixes and added a temporary F3 shortcut

* Bug fixes

* Bug fixes

* feat: Implement content search functionality with keyboard shortcuts

- Added a new `ContentSearch` component for searching text within a specified target.
- Integrated search functionality with keyboard shortcuts, allowing users to enable search via a new shortcut key.
- Updated internationalization files to include new search-related messages in multiple languages.
- Enhanced shortcut management to accommodate the new search feature, including a migration for shortcut updates.
- Refactored the `Chat` component to utilize the new content search capabilities.

* fix(ContentSearch): Update search index check and enhance container styling

* feat(ContentSearch): Enhance search functionality with case sensitivity and whole word options

- Added options for case sensitivity and whole word matching in the search feature.
- Updated the highlightText function to accommodate new search parameters.
- Improved styling and layout of the search input and buttons for better user experience.
- Refactored related components to support the new search options.

* refactor(useShortcuts): Remove console log for shortcut invocation

* feat: Add user filter and initial text to search

- Improve ContentSearch UI:
  - Add tooltips for search options
  - Enhance result display (e.g., "No results", "0/0")
  - Disable navigation buttons when no matches
  - Relocate search bar to the top of the chat view
- Apply case-sensitivity logic only to Latin characters
- Add translations for new options

* i18n: Translate "No results" message

* Add in-chat search shortcut, update global search to Cmd/Ctrl+Shift+F.

* feat: Allow users to scroll during input and optimize the search result index update logic

* feat: Update search message shortcut to include Shift for improved accessibility

* feat: Refactor search component layout and update highlight color variables

* fix: Adjust margin-bottom for SearchBarContainer

---------

Co-authored-by: suyao <sy20010504@gmail.com>
2025-05-17 21:14:03 +08:00
George Zhao
d7139cca02 feat: implement useFullscreen hook and integrate with NavbarRight for dynamic padding (#6000)
* feat: implement useFullscreen hook and integrate with NavbarRight for dynamic padding

* feat: integrate useFullscreen hook to adjust sidebar layout based on fullscreen state

* fix: adjust sidebar height based on fullscreen state for better layout

---------

Co-authored-by: George Zhao <georgezhao@SKJLAB>
2025-05-17 19:48:40 +08:00
Rudbeckia.hirta.L
e9ab193a24 feat: support skipping files during backup(slim backup) (#6075)
* feat: support skipping files during backup

* 修复 lint

* 修复 lint

---------

Co-authored-by: daisai.1 <daisai.1@bytedance.com>
2025-05-17 19:20:40 +08:00
George Zhao
548db37066 fix: Ensure last app is displayed when no filtered apps are found (#6090)
* fix: Ensure last app is displayed when no filtered apps are found

* fix: Remove Empty component from AppsPage when no apps are found

---------

Co-authored-by: George Zhao <georgezhao@SKJLAB>
2025-05-17 17:27:45 +08:00
one
ece9e2ef13 chore: reduce package size (#6092) 2025-05-17 17:07:30 +08:00
Steven Ding
2894eec438 fix: change minimax's URL (#6091)
Signed-off-by: stevending1st <stevending1st@163.com>
2025-05-17 15:55:36 +08:00
beyondkmp
c2465c33b7 feat: Drop file improvements (#6089)
* feat: enhance file drag-and-drop functionality and global paste handling in Inputbar

- Added support for file dragging with visual feedback.
- Implemented global paste event handling to allow pasting outside the text area.
- Improved file drop handling to notify users of unsupported file types.

* refactor(Inputbar): simplify global paste handling logic

- Removed unnecessary checks for active element in global paste event handling.
- Streamlined the paste event processing for improved clarity and maintainability.

* style(Inputbar): clean up comments and maintain visual feedback for file dragging

- Removed unnecessary comments related to the styling of the file dragging state.
- Ensured the visual feedback for file dragging remains intact with updated background color.
2025-05-17 09:35:25 +08:00
kangfenmao
3c2e8e2f1d fix: electron-store config.json missing
* Added 'electron-store' to the Vite configuration for Electron.
* Imported configuration from '@main/config' in the main index file.
* Changed default return value for getTrayOnClose method in ConfigManager to false
2025-05-17 08:46:26 +08:00
1600822305
f84358adfd fix: Update file API usage for Electron 35.2.2 and add translations f… (#6087)
* Fix: Update file API usage for Electron 35.2.2 and add translations for file error messages

* 修复Electron 35.2.2中的文件API问题
2025-05-17 08:09:30 +08:00
one
51071d65fb refactor(CodeEditor): add more options to props for better customization, fix auto theme (#6071)
* refactor(CodeEditor): add more options to props for better customization

- A complete BasicSetupOptions could be passed in to override system-wise options
- EditMcpJsonPopup now use customized options

* fix: accommodate ThemeMode.auto

* fix: typos
2025-05-17 01:32:54 +08:00
方程
ba30bffa49 Gitee AI:update name, update models (#6006)
* Gitee AI:update name, update models

* Gitee AI:update name, update models

---------

Co-authored-by: 方程 <fangcheng@oschina.cn>
2025-05-17 00:47:42 +08:00
自由的世界人
c66563d335 feat: add citation list right-click copy (#6066)
* feat: add citation list right-click copy

* fix: extract the right-click menu as a component
2025-05-17 00:22:09 +08:00
SuiYunsy
c7b0b5ce53 拓宽”请选择要检测的模型“模态框 (#6062)
许多模型名称很长,目前模态框太短,无法完全显示
2025-05-16 23:40:22 +08:00
亢奋猫
273fabafcb docs: Update README.zh.md add GitCode✖️Cherry Studio【新源力】贡献挑战赛
GitCode✖️Cherry Studio【新源力】贡献挑战赛
2025-05-16 17:25:21 +08:00
kangfenmao
6f5f917c98 fix: adjust visibility timer and trigger area dimensions in ChatNavigation
* Reduced the button visibility timer from 1500ms to 500ms for quicker feedback.
* Decreased the trigger width from 80 to 60 for improved interaction.
* Updated the height calculation from 40% to 30% of the window height for better layout consistency.
2025-05-16 14:46:22 +08:00
kangfenmao
4ad3e44769 refactor: remove deprecated max token settings from OpenAIProvider and OpenAIResponseProvider 2025-05-16 14:36:40 +08:00
one
2dedd95fcc feat: code tools, editor, executor (#4632)
* feat: code tools, editor, executor

CodeEditor & Preview
- CodeEditor: CodeMirror 6
  - Switch to CodeEditor in the settings
  - Support edit&save with a accurate diff&lookup strategy
  - Use CodeEditor for editing MCP json configuration
- CodePreview: Original Shiki syntax highlighting
  - Implemented using a custom Shiki stream tokenizer
  - Remov code caching as it is incompatible with the current streaming code highlighting
  - Add a webworker for shiki
- Other preview components
  - Merge MermaidPopup and Mermaid to MermaidPreview, use local mermaidjs
  - Show mermaid syntax error message on demand
  - Rename PlantUML to PlantUmlPreview
- Rename SyntaxHighlighterProvider to CodeStyleProvider for clarity
- Both light and dark themes are preserved for convenience

CodeToolbar
- Top sticky toolbar provides quick tools (left) and core tools (right)
- Quick tools are hidden under the `More` button to avoid clutter, while core tools are always visible
- View&edit mode
  - Allow switching between preview and edit modes
  - Add a split view

Code execution
- Pyodide for executing Python scripts
- Add a webworker for Pyodide

* fix: migrate version and lint error

* refactor: use constants for defining tool specs

* refactor: add user-select, fix tool specs

* refactor: simplify some state changing

* fix: make sure editor tools registered after the editor is ready

---------

Co-authored-by: 自由的世界人 <3196812536@qq.com>
2025-05-16 13:53:44 +08:00
beyondkmp
c6b87b307b feat: upgrade electron to 35.2.2 (#5151)
* upgrade electron to 35.2.0

* update electron to 32.3.3

* udpate electron-vite to 3.0.0

* upgrade to 35.2.2

* patch https://github.com/electron-userland/electron-builder/pull/9046

* add minimumSystemVersion config

* update electron vite

---------

Co-authored-by: beyondkmp <beyondkmkp@gmail.com>
2025-05-16 12:56:52 +08:00
tiankuo.zhou
e6e0000328 fix: update mcp sdk version to solve the bug-preserve custom paths in SSE endpoint URLs (#6021)
* fix: update mcp sdk to slove the bug - preserve custom paths in SSE endpoint URLs

* add signoff

Signed-off-by: tiankuo.zhou <tiankuo.zhou@lofty.com>

* add signoff

Signed-off-by: tiankuo.zhou <tiankuo.zhou@lofty.com>

---------

Signed-off-by: tiankuo.zhou <tiankuo.zhou@lofty.com>
Co-authored-by: tiankuo.zhou <tiankuo.zhou@lofty.com>
2025-05-16 10:13:53 +08:00
243 changed files with 17459 additions and 6031 deletions

View File

@@ -54,5 +54,5 @@ jobs:
days-before-pr-close: -1 # Completely disable closing for PRs
# Temporary to reduce the huge issues number
operations-per-run: 100
operations-per-run: 1000
debug-only: false

1
.gitignore vendored
View File

@@ -51,3 +51,4 @@ local
coverage
.vitest-cache
vitest.config.*.timestamp-*
YOUR_MEMORY_FILE_PATH

View File

@@ -0,0 +1,159 @@
diff --git a/out/macPackager.js b/out/macPackager.js
index 852f6c4d16f86a7bb8a78bf1ed5a14647a279aa1..60e7f5f16a844541eb1909b215fcda1811e924b8 100644
--- a/out/macPackager.js
+++ b/out/macPackager.js
@@ -423,7 +423,7 @@ class MacPackager extends platformPackager_1.PlatformPackager {
}
appPlist.CFBundleName = appInfo.productName;
appPlist.CFBundleDisplayName = appInfo.productName;
- const minimumSystemVersion = this.platformSpecificBuildOptions.minimumSystemVersion;
+ const minimumSystemVersion = this.platformSpecificBuildOptions.LSMinimumSystemVersion;
if (minimumSystemVersion != null) {
appPlist.LSMinimumSystemVersion = minimumSystemVersion;
}
diff --git a/out/publish/updateInfoBuilder.js b/out/publish/updateInfoBuilder.js
index 7924c5b47d01f8dfccccb8f46658015fa66da1f7..1a1588923c3939ae1297b87931ba83f0ebc052d8 100644
--- a/out/publish/updateInfoBuilder.js
+++ b/out/publish/updateInfoBuilder.js
@@ -133,6 +133,7 @@ async function createUpdateInfo(version, event, releaseInfo) {
const customUpdateInfo = event.updateInfo;
const url = path.basename(event.file);
const sha512 = (customUpdateInfo == null ? null : customUpdateInfo.sha512) || (await (0, hash_1.hashFile)(event.file));
+ const minimumSystemVersion = customUpdateInfo == null ? null : customUpdateInfo.minimumSystemVersion;
const files = [{ url, sha512 }];
const result = {
// @ts-ignore
@@ -143,9 +144,13 @@ async function createUpdateInfo(version, event, releaseInfo) {
path: url /* backward compatibility, electron-updater 1.x - electron-updater 2.15.0 */,
// @ts-ignore
sha512 /* backward compatibility, electron-updater 1.x - electron-updater 2.15.0 */,
+ minimumSystemVersion,
...releaseInfo,
};
if (customUpdateInfo != null) {
+ if (customUpdateInfo.minimumSystemVersion) {
+ delete customUpdateInfo.minimumSystemVersion;
+ }
// file info or nsis web installer packages info
Object.assign("sha512" in customUpdateInfo ? files[0] : result, customUpdateInfo);
}
diff --git a/out/targets/ArchiveTarget.js b/out/targets/ArchiveTarget.js
index e1f52a5fa86fff6643b2e57eaf2af318d541f865..47cc347f154a24b365e70ae5e1f6d309f3582ed0 100644
--- a/out/targets/ArchiveTarget.js
+++ b/out/targets/ArchiveTarget.js
@@ -69,6 +69,9 @@ class ArchiveTarget extends core_1.Target {
}
}
}
+ if (updateInfo != null && this.packager.platformSpecificBuildOptions.minimumSystemVersion) {
+ updateInfo.minimumSystemVersion = this.packager.platformSpecificBuildOptions.minimumSystemVersion;
+ }
await packager.info.emitArtifactBuildCompleted({
updateInfo,
file: artifactPath,
diff --git a/out/targets/nsis/NsisTarget.js b/out/targets/nsis/NsisTarget.js
index e8bd7bb46c8a54b3f55cf3a853ef924195271e01..f956e9f3fe9eb903c78aef3502553b01de4b89b1 100644
--- a/out/targets/nsis/NsisTarget.js
+++ b/out/targets/nsis/NsisTarget.js
@@ -305,6 +305,9 @@ class NsisTarget extends core_1.Target {
if (updateInfo != null && isPerMachine && (oneClick || options.packElevateHelper)) {
updateInfo.isAdminRightsRequired = true;
}
+ if (updateInfo != null && this.packager.platformSpecificBuildOptions.minimumSystemVersion) {
+ updateInfo.minimumSystemVersion = this.packager.platformSpecificBuildOptions.minimumSystemVersion;
+ }
await packager.info.emitArtifactBuildCompleted({
file: installerPath,
updateInfo,
diff --git a/scheme.json b/scheme.json
index 433e2efc9cef156ff5444f0c4520362ed2ef9ea7..a89c7a9b0b608fef67902c49106a43ebd0fa8b61 100644
--- a/scheme.json
+++ b/scheme.json
@@ -1975,6 +1975,13 @@
],
"description": "The mime types in addition to specified in the file associations. Use it if you don't want to register a new mime type, but reuse existing."
},
+ "minimumSystemVersion": {
+ "description": "The minimum os kernel version required to install the application.",
+ "type": [
+ "null",
+ "string"
+ ]
+ },
"packageCategory": {
"description": "backward compatibility + to allow specify fpm-only category for all possible fpm targets in one place",
"type": [
@@ -2327,6 +2334,13 @@
"MacConfiguration": {
"additionalProperties": false,
"properties": {
+ "LSMinimumSystemVersion": {
+ "description": "The minimum version of macOS required for the app to run. Corresponds to `LSMinimumSystemVersion`.",
+ "type": [
+ "null",
+ "string"
+ ]
+ },
"additionalArguments": {
"anyOf": [
{
@@ -2737,7 +2751,7 @@
"type": "boolean"
},
"minimumSystemVersion": {
- "description": "The minimum version of macOS required for the app to run. Corresponds to `LSMinimumSystemVersion`.",
+ "description": "The minimum os kernel version required to install the application.",
"type": [
"null",
"string"
@@ -2959,6 +2973,13 @@
"MasConfiguration": {
"additionalProperties": false,
"properties": {
+ "LSMinimumSystemVersion": {
+ "description": "The minimum version of macOS required for the app to run. Corresponds to `LSMinimumSystemVersion`.",
+ "type": [
+ "null",
+ "string"
+ ]
+ },
"additionalArguments": {
"anyOf": [
{
@@ -3369,7 +3390,7 @@
"type": "boolean"
},
"minimumSystemVersion": {
- "description": "The minimum version of macOS required for the app to run. Corresponds to `LSMinimumSystemVersion`.",
+ "description": "The minimum os kernel version required to install the application.",
"type": [
"null",
"string"
@@ -6507,6 +6528,13 @@
"string"
]
},
+ "minimumSystemVersion": {
+ "description": "The minimum os kernel version required to install the application.",
+ "type": [
+ "null",
+ "string"
+ ]
+ },
"protocols": {
"anyOf": [
{
@@ -7376,6 +7404,13 @@
],
"description": "MAS (Mac Application Store) development options (`mas-dev` target)."
},
+ "minimumSystemVersion": {
+ "description": "The minimum os kernel version required to install the application.",
+ "type": [
+ "null",
+ "string"
+ ]
+ },
"msi": {
"anyOf": [
{

File diff suppressed because one or more lines are too long

948
.yarn/releases/yarn-4.9.1.cjs vendored Executable file

File diff suppressed because one or more lines are too long

View File

@@ -4,4 +4,4 @@ httpTimeout: 300000
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.6.0.cjs
yarnPath: .yarn/releases/yarn-4.9.1.cjs

View File

@@ -23,9 +23,11 @@ Cherry Studio is a desktop client that supports for multiple LLM providers, avai
# 🌠 Screenshot
![](https://github.com/user-attachments/assets/082efa42-c4df-4863-a9cb-80435cecce0f)
![](https://github.com/user-attachments/assets/f8411a65-c51f-47d3-9273-62ae384cc6f1)
![](https://github.com/user-attachments/assets/0d235b3e-65ae-45ab-987f-8dbe003c52be)
![](https://github.com/user-attachments/assets/36dddb2c-e0fb-4a5f-9411-91447bab6e18)
![](https://github.com/user-attachments/assets/f549e8a0-2385-40b4-b52b-2039e39f2930)
![](https://github.com/user-attachments/assets/58e0237c-4d36-40de-b428-53051d982026)
# 🌟 Key Features
@@ -96,7 +98,7 @@ Refer to the [development documentation](docs/dev.md)
Refer to the [Architecture overview documentation](https://deepwiki.com/CherryHQ/cherry-studio)
Refer to the [Branching Strategy](docs/branching-strategy.md) for contribution guidelines
Refer to the [Branching Strategy](docs/branching-strategy-en.md) for contribution guidelines
# 🤝 Contributing
@@ -121,7 +123,7 @@ For more detailed guidelines, please refer to our [Contributing Guide](./CONTRIB
Thank you for your support and contributions!
## Related Projects
# 🔗 Related Projects
- [one-api](https://github.com/songquanpeng/one-api):LLM API management and distribution system, supporting mainstream models like OpenAI, Azure, and Anthropic. Features unified API interface, suitable for key management and secondary distribution.

View File

@@ -6,17 +6,19 @@
<p align="center">
<a href="https://github.com/CherryHQ/cherry-studio">English</a> | <a href="./README.zh.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 で利用可能です。
👏 [Telegram](https://t.me/CherryStudioAI)[Discord](https://discord.gg/wez8HtpxqQ) | [QQグループ(575014769)](https://qm.qq.com/q/lo0D4qVZKi)
❤️ Cherry Studio をお気に入りにしましたか?小さな星をつけてください 🌟 または [スポンサー](sponsor.md) をして開発をサポートしてください!❤️
❤️ Cherry Studio をお気に入りにしましたか?小さな星をつけてください 🌟 または [スポンサー](sponsor.md) をして開発をサポートしてください!
# 📖 ガイド
@@ -24,9 +26,11 @@ https://docs.cherry-ai.com
# 🌠 スクリーンショット
![](https://github.com/user-attachments/assets/082efa42-c4df-4863-a9cb-80435cecce0f)
![](https://github.com/user-attachments/assets/f8411a65-c51f-47d3-9273-62ae384cc6f1)
![](https://github.com/user-attachments/assets/0d235b3e-65ae-45ab-987f-8dbe003c52be)
![](https://github.com/user-attachments/assets/36dddb2c-e0fb-4a5f-9411-91447bab6e18)
![](https://github.com/user-attachments/assets/f549e8a0-2385-40b4-b52b-2039e39f2930)
![](https://github.com/user-attachments/assets/58e0237c-4d36-40de-b428-53051d982026)
# 🌟 主な機能
@@ -56,7 +60,7 @@ https://docs.cherry-ai.com
- 🔤 AI による翻訳機能
- 🎯 ドラッグ&ドロップによる整理
- 🔌 ミニプログラム対応
- ⚙️ MCPモデルコンテキストプロトコル サービス
- ⚙️ MCPモデルコンテキストプロトコルサービス
5. **優れたユーザー体験**
@@ -70,71 +74,78 @@ https://docs.cherry-ai.com
- [x] クイックポップアップ(クリップボードの読み取り、簡単な質問、説明、翻訳、要約)
- [x] 複数モデルの回答の比較
- [x] サービスプロバイダーが提供する SSO を使用したログインをサポート
- [x] すべてのモデルネットワークをサポート
- [x] サービスプロバイダーが提供する SSO を使用したログイン対応
- [x] すべてのモデルネットワーク対応
- [x] 最初の公式バージョンのリリース
- [ ] 錯誤修復と改善 (開発中...)
- [x] バグ修正と改善(進行中...
- [ ] プラグイン機能JavaScript
- [ ] ブラウザ拡張機能(テキストをハイライトして翻訳、要約、ナレッジベースに追加)
- [ ] iOS & Android クライアント
- [ ] AIート
- [ ] AI ノート
- [ ] 音声入出力AI コール)
- [ ] データバックアップカスタムバックアップコンテンツをサポート
- [ ] データバックアップカスタマイズ対応
# 🌈 テーマ
- テーマギャラリー: https://cherrycss.com
- Aero テーマ: https://github.com/hakadao/CherryStudio-Aero
- PaperMaterial テーマ: https://github.com/rainoffallingstar/CherryStudio-PaperMaterial
- Claude テーマ: https://github.com/bjl101501/CherryStudio-Claudestyle-dynamic
- メープルネオンテーマ: https://github.com/BoningtonChen/CherryStudio_themes
- テーマギャラリーhttps://cherrycss.com
- Aero テーマhttps://github.com/hakadao/CherryStudio-Aero
- PaperMaterial テーマhttps://github.com/rainoffallingstar/CherryStudio-PaperMaterial
- Claude テーマhttps://github.com/bjl101501/CherryStudio-Claudestyle-dynamic
- メープルネオンテーマhttps://github.com/BoningtonChen/CherryStudio_themes
より多くのテーマのPRを歓迎します
より多くのテーマの PR を歓迎します
# 🖥️ 開発
参考[開発ドキュメント](dev.md)
[開発ドキュメント](dev.md)を参照してください
[アーキテクチャ概要ドキュメント](https://deepwiki.com/CherryHQ/cherry-studio)を参照してください
[ブランチ戦略](branching-strategy-en.md)を参照して貢献ガイドラインを確認してください
# 🤝 貢献
Cherry Studio への貢献を歓迎します!以下の方法で貢献できます:
1. **コードの貢献**:新機能を開発するか、既存のコードを最適化します
2. **バグの修正**:見つけたバグを修正します
3. **問題の管理**GitHub の問題を管理するのを手伝います
4. **製品デザイン**:デザインの議論に参加します
5. **ドキュメントの作成**:ユーザーマニュアルやガイドを改善します
6. **コミュニティの参加**:ディスカッションに参加し、ユーザーを支援します
7. **使用の促進**Cherry Studio を広めます
1. **コードの貢献**:新機能を開発するか、既存のコードを最適化します
2. **バグの修正**:見つけたバグを修正します
3. **問題の管理**GitHub の問題を管理するのを手伝います
4. **製品デザイン**:デザインの議論に参加します
5. **ドキュメントの作成**:ユーザーマニュアルやガイドを改善します
6. **コミュニティの参加**:ディスカッションに参加し、ユーザーを支援します
7. **使用の促進**Cherry Studio を広めます
## 始め方
1. **リポジトリをフォーク**:フォークしてローカルマシンにクローンします
2. **ブランチを作成**:変更のためのブランチを作成します
3. **変更を提出**:変更をコミットしてプッシュします
4. **プルリクエストを開く**:変更内容と理由を説明します
1. **リポジトリをフォーク**:フォークしてローカルマシンにクローンします
2. **ブランチを作成**:変更のためのブランチを作成します
3. **変更を提出**:変更をコミットしてプッシュします
4. **プルリクエストを開く**:変更内容と理由を説明します
詳細なガイドラインについては、[貢献ガイド](../CONTRIBUTING.md)をご覧ください。
ご支援と貢献に感謝します!
## 関連頁版
# 🔗 関連プロジェクト
- [one-api](https://github.com/songquanpeng/one-api)LLM API の管理・配信システム。OpenAI、Azure、Anthropic などの主要モデルに対応し、統一 API インターフェースを提供。API キー管理と再配布に利用可能。
- [ublacklist](https://github.com/iorate/ublacklist)Google 検索結果から特定のサイトを非表示にします
# 🚀 コントリビューター
<a href="https://github.com/CherryHQ/cherry-studio/graphs/contributors">
<img src="https://contrib.rocks/image?repo=kangfenmao/cherry-studio" />
<img src="https://contrib.rocks/image?repo=CherryHQ/cherry-studio" />
</a>
<br /><br />
# コミュニティ
# 🌐 コミュニティ
[Telegram](https://t.me/CherryStudioAI) | [Email](mailto:support@cherry-ai.com) | [Twitter](https://x.com/kangfenmao)
# スポンサー
# スポンサー
[Buy Me a Coffee](sponsor.md)
[開発者を支援する](sponsor.md)
# 📃 ライセンス

View File

@@ -4,7 +4,8 @@
</a>
</h1>
<p align="center">
<a href="https://github.com/CherryHQ/cherry-studio">English</a> | 中文 | <a href="./README.ja.md">日本語</a><br></p>
<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>
@@ -18,15 +19,25 @@ Cherry Studio 是一款支持多个大语言模型LLM服务商的桌面客
❤️ 喜欢 Cherry Studio? 点亮小星星 🌟 或 [赞助开发者](sponsor.md)! ❤️
# GitCode✖Cherry Studio【新源力】贡献挑战赛
<p align="center">
<a href="https://gitcode.com/CherryHQ/cherry-studio/discussion/2">
<img src="https://raw.gitcode.com/user-images/assets/5007375/8d8d7559-1141-4691-b90f-d154558c6896/cherry-studio-gitcode.jpg" width="100%" alt="banner" />
</a>
</p>
# 📖 使用教程
https://docs.cherry-ai.com
# 🌠 界面
![](https://github.com/user-attachments/assets/082efa42-c4df-4863-a9cb-80435cecce0f)
![](https://github.com/user-attachments/assets/f8411a65-c51f-47d3-9273-62ae384cc6f1)
![](https://github.com/user-attachments/assets/0d235b3e-65ae-45ab-987f-8dbe003c52be)
![](https://github.com/user-attachments/assets/36dddb2c-e0fb-4a5f-9411-91447bab6e18)
![](https://github.com/user-attachments/assets/f549e8a0-2385-40b4-b52b-2039e39f2930)
![](https://github.com/user-attachments/assets/58e0237c-4d36-40de-b428-53051d982026)
# 🌟 主要特性
@@ -66,12 +77,12 @@ https://docs.cherry-ai.com
- 📝 完整的 Markdown 渲染
- 🤲 便捷的内容分享功能
# 📝 待辦事項
# 📝 待办事项
- [x] 快捷弹窗(读取剪贴板、快速提问、解释、翻译、总结)
- [x] 多模型回答对比
- [x] 支持使用服务供应商提供的 SSO 进行登
- [x] 全部模型支持连网(开发中...
- [x] 支持使用服务供应商提供的 SSO 进行登
- [x] 所有模型支持联网
- [x] 推出第一个正式版
- [x] 错误修复和改进(开发中...
- [ ] 插件功能JavaScript
@@ -85,9 +96,9 @@ https://docs.cherry-ai.com
- 主题库https://cherrycss.com
- Aero 主题https://github.com/hakadao/CherryStudio-Aero
- PaperMaterial 主题: https://github.com/rainoffallingstar/CherryStudio-PaperMaterial
- 仿Claude 主题: https://github.com/bjl101501/CherryStudio-Claudestyle-dynamic
- 霓虹枫叶字体主题: https://github.com/BoningtonChen/CherryStudio_themes
- PaperMaterial 主题https://github.com/rainoffallingstar/CherryStudio-PaperMaterial
- 仿 Claude 主题https://github.com/bjl101501/CherryStudio-Claudestyle-dynamic
- 霓虹枫叶主题:https://github.com/BoningtonChen/CherryStudio_themes
欢迎 PR 更多主题
@@ -95,37 +106,43 @@ https://docs.cherry-ai.com
参考[开发文档](dev.md)
参考[架构概览文档](https://deepwiki.com/CherryHQ/cherry-studio)
参考[分支策略](branching-strategy-zh.md)了解贡献指南
# 🤝 贡献
我们欢迎对 Cherry Studio 的贡献!您可以通过以下方式贡献:
1. **贡献代码**:开发新功能或优化现有代码
2. **修复错误**:提交您发现的错误修复
3. **维护问题**:帮助管理 GitHub 问题
4. **产品设计**:参与设计讨论
5. **撰写文档**:改进用户手册和指南
6. **社区参与**:加入讨论并帮助用户
7. **推广使用**:宣传 Cherry Studio
1. **贡献代码**:开发新功能或优化现有代码
2. **修复错误**:提交您发现的错误修复
3. **维护问题**:帮助管理 GitHub 问题
4. **产品设计**:参与设计讨论
5. **撰写文档**:改进用户手册和指南
6. **社区参与**:加入讨论并帮助用户
7. **推广使用**:宣传 Cherry Studio
## 入门
1. **Fork 仓库**Fork 并克隆到您的本地机器
2. **创建分支**:为您的更改创建分支
3. **提交更改**:提交并推送您的更改
4. **打开 Pull Request**:描述您的更改和原因
1. **Fork 仓库**Fork 并克隆到您的本地机器
2. **创建分支**:为您的更改创建分支
3. **提交更改**:提交并推送您的更改
4. **打开 Pull Request**:描述您的更改和原因
有关更详细的指南,请参阅我们的 [贡献指南](./CONTRIBUTING.zh.md)
有关更详细的指南,请参阅我们的 [贡献指南](./CONTRIBUTING.zh.md)
感谢您的支持和贡献!
## 相关项目
# 🔗 相关项目
- [one-api](https://github.com/songquanpeng/one-api)LLM API 管理及分发系统,支持 OpenAI、Azure、Anthropic 等主流模型,统一 API 接口,可用于密钥管理与二次分发。
- [ublacklist](https://github.com/iorate/ublacklist):屏蔽特定网站在 Google 搜索结果中显示
# 🚀 贡献者
<a href="https://github.com/CherryHQ/cherry-studio/graphs/contributors">
<img src="https://contrib.rocks/image?repo=kangfenmao/cherry-studio" />
<img src="https://contrib.rocks/image?repo=CherryHQ/cherry-studio" />
</a>
<br /><br />
@@ -135,7 +152,7 @@ https://docs.cherry-ai.com
# ☕ 赞助
[微信赞赏码](sponsor.md)
[赞助开发者](sponsor.md)
# 📃 许可证

View File

@@ -0,0 +1,71 @@
# 🌿 Branching Strategy
Cherry Studio implements a structured branching strategy to maintain code quality and streamline the development process.
## Main Branches
- `main`: Main development branch
- Contains the latest development code
- Direct commits are not allowed - changes must come through pull requests
- Code may contain features in development and might not be fully stable
- `release/*`: Release branches
- Created from `main` branch
- Contains stable code ready for release
- Only accepts documentation updates and bug fixes
- Thoroughly tested before production deployment
## Contributing Branches
When contributing to Cherry Studio, please follow these guidelines:
1. **Feature Branches:**
- Create from `main` branch
- Naming format: `feature/issue-number-brief-description`
- Submit PR back to `main`
2. **Bug Fix Branches:**
- Create from `main` branch
- Naming format: `fix/issue-number-brief-description`
- Submit PR back to `main`
3. **Documentation Branches:**
- Create from `main` branch
- Naming format: `docs/brief-description`
- Submit PR back to `main`
4. **Hotfix Branches:**
- Create from `main` branch
- Naming format: `hotfix/issue-number-brief-description`
- Submit PR to both `main` and relevant `release` branches
5. **Release Branches:**
- Create from `main` branch
- Naming format: `release/version-number`
- Used for final preparation work before version release
- Only accepts bug fixes and documentation updates
- After testing and preparation, merge back to `main` and tag with version
## Workflow Diagram
![](https://github.com/user-attachments/assets/61db64a2-fab1-4a16-8253-0c64c9df1a63)
## Pull Request Guidelines
- All PRs should be submitted to the `main` branch unless fixing a critical production issue
- Ensure your branch is up to date with the latest `main` changes before submitting
- Include relevant issue numbers in your PR description
- Make sure all tests pass and code meets our quality standards
- Add before/after screenshots if you add a new feature or modify a UI component
## Version Tag Management
- Major releases: v1.0.0, v2.0.0, etc.
- Feature releases: v1.1.0, v1.2.0, etc.
- Patch releases: v1.0.1, v1.0.2, etc.
- Hotfix releases: v1.0.1-hotfix, etc.

View File

@@ -0,0 +1,71 @@
# 🌿 分支策略
Cherry Studio 采用结构化的分支策略来维护代码质量并简化开发流程。
## 主要分支
- `main`:主开发分支
- 包含最新的开发代码
- 禁止直接提交 - 所有更改必须通过拉取请求Pull Request
- 此分支上的代码可能包含正在开发的功能,不一定完全稳定
- `release/*`:发布分支
-`main` 分支创建
- 包含准备发布的稳定代码
- 只接受文档更新和 bug 修复
- 经过完整测试后可以发布到生产环境
## 贡献分支
在为 Cherry Studio 贡献代码时,请遵循以下准则:
1. **功能开发分支:**
-`main` 分支创建
- 命名格式:`feature/issue-number-brief-description`
- 完成后提交 PR 到 `main` 分支
2. **Bug 修复分支:**
-`main` 分支创建
- 命名格式:`fix/issue-number-brief-description`
- 完成后提交 PR 到 `main` 分支
3. **文档更新分支:**
-`main` 分支创建
- 命名格式:`docs/brief-description`
- 完成后提交 PR 到 `main` 分支
4. **紧急修复分支:**
-`main` 分支创建
- 命名格式:`hotfix/issue-number-brief-description`
- 完成后需要同时合并到 `main` 和相关的 `release` 分支
5. **发布分支:**
-`main` 分支创建
- 命名格式:`release/version-number`
- 用于版本发布前的最终准备工作
- 只允许合并 bug 修复和文档更新
- 完成测试和准备工作后,将代码合并回 `main` 分支并打上版本标签
## 工作流程
![](https://github.com/user-attachments/assets/61db64a2-fab1-4a16-8253-0c64c9df1a63)
## 拉取请求PR指南
- 除非是修复生产环境的关键问题,否则所有 PR 都应该提交到 `main` 分支
- 提交 PR 前确保你的分支已经同步了最新的 `main` 分支内容
- 在 PR 描述中包含相关的 issue 编号
- 确保所有测试通过,且代码符合我们的质量标准
- 如果你添加了新功能或修改了 UI 组件,请附上更改前后的截图
## 版本标签管理
- 主要版本发布v1.0.0、v2.0.0 等
- 功能更新发布v1.1.0、v1.2.0 等
- 补丁修复发布v1.0.1、v1.0.2 等
- 紧急修复发布v1.0.1-hotfix 等

View File

@@ -1,52 +0,0 @@
# 🌿 Branching Strategy
Cherry Studio follows a structured branching strategy to maintain code quality and streamline the development process:
## Main Branches
- `main`: Production-ready branch containing stable releases
- All code here is thoroughly tested and ready for production
- Direct commits are not allowed - changes must come through pull requests
- Each merge to main represents a new release
- `develop` (default): Primary development branch
- Contains the latest delivered development changes for the next release
- Relatively stable but may contain features in progress
- This is the default branch for development
## Contributing Branches
When contributing to Cherry Studio, please follow these guidelines:
1. **For bug fixes:**
- Create a branch from `develop`
- Name format: `fix/issue-number-brief-description`
- Submit pull request back to `develop`
2. **For new features:**
- Create a branch from `develop`
- Name format: `feature/issue-number-brief-description`
- Submit pull request back to `develop`
3. **For documentation:**
- Create a branch from `develop`
- Name format: `docs/brief-description`
- Submit pull request back to `develop`
4. **For critical hotfixes:**
- Create a branch from `main`
- Name format: `hotfix/issue-number-brief-description`
- Submit pull request to both `main` and `develop`
## Pull Request Guidelines
- Always create pull requests against the `develop` branch unless fixing a critical production issue
- Ensure your branch is up to date with the latest `develop` changes before submitting
- Include relevant issue numbers in your PR description
- Make sure all tests pass and code meets our quality standards
- Critical hotfixes may be submitted against `main` but must also be merged into `develop`
- Add a photo to show what is different if you add a new feature or modify a component in the UI.

View File

@@ -45,6 +45,8 @@ win:
target:
- target: nsis
- target: portable
signtoolOptions:
sign: scripts/win-sign.js
nsis:
artifactName: ${productName}-${version}-${arch}-setup.${ext}
shortcutName: ${productName}
@@ -61,6 +63,7 @@ mac:
entitlementsInherit: build/entitlements.mac.plist
notarize: false
artifactName: ${productName}-${version}-${arch}.${ext}
minimumSystemVersion: '20.1.0' # 最低支持 macOS 11.0
extendInfo:
- NSCameraUsageDescription: Application requests access to the device's camera.
- NSMicrophoneUsageDescription: Application requests access to the device's microphone.
@@ -91,9 +94,13 @@ artifactBuildCompleted: scripts/artifact-build-completed.js
releaseInfo:
releaseNotes: |
⚠️ 注意:升级前请备份数据,否则将无法降级
重构消息结构,支持不同类型消息按时间顺序显示
智能体支持导入和导出
快捷面板增加网络搜索引擎选择
显示设置增加缩放控制按钮
支持添加自定义小程序
性能优化和错误修复
增加 TokenFlux 服务商
增加 Claude 4 模型支持
Grok 模型增加联网能力
小程序支持前进和后退
修复 Windows 用户 MCP 无法启动问题
修复无法搜索历史消息问题
修复 MCP 代理问题
修复精简备份恢复覆盖文件问题
修复@模型回复插入位置错误问题
修复搜索小程序崩溃问题

View File

@@ -73,7 +73,10 @@ export default defineConfig({
}
},
optimizeDeps: {
exclude: []
exclude: ['pyodide']
},
worker: {
format: 'es'
},
build: {
rollupOptions: {

View File

@@ -1,6 +1,6 @@
{
"name": "CherryStudio",
"version": "1.3.5",
"version": "1.3.11",
"private": true,
"description": "A powerful AI assistant for producer.",
"main": "./out/main/index.js",
@@ -84,6 +84,7 @@
"electron-updater": "6.6.4",
"electron-window-state": "^5.0.3",
"epub": "patch:epub@npm%3A1.3.0#~/.yarn/patches/epub-npm-1.3.0-8325494ffe.patch",
"fast-diff": "^1.3.0",
"fast-xml-parser": "^5.2.0",
"fetch-socks": "^1.3.2",
"fs-extra": "^11.2.0",
@@ -94,8 +95,6 @@
"officeparser": "^4.1.1",
"os-proxy-config": "^1.1.2",
"proxy-agent": "^6.5.0",
"rc-virtual-list": "^3.18.6",
"react-window": "^1.8.11",
"tar": "^7.4.3",
"turndown": "^7.2.0",
"turndown-plugin-gfm": "^1.0.2",
@@ -118,17 +117,14 @@
"@eslint/js": "^9.22.0",
"@google/genai": "^0.13.0",
"@hello-pangea/dnd": "^16.6.0",
"@iconify-json/svg-spinners": "^1.2.2",
"@kangfenmao/keyv-storage": "^0.1.0",
"@modelcontextprotocol/sdk": "^1.10.2",
"@modelcontextprotocol/sdk": "^1.11.4",
"@mozilla/readability": "^0.6.0",
"@notionhq/client": "^2.2.15",
"@reduxjs/toolkit": "^2.2.5",
"@shikijs/markdown-it": "^3.2.2",
"@swc/plugin-styled-components": "^7.1.3",
"@tavily/core": "patch:@tavily/core@npm%3A0.3.1#~/.yarn/patches/@tavily-core-npm-0.3.1-fe69bf2bea.patch",
"@shikijs/markdown-it": "^3.4.2",
"@swc/plugin-styled-components": "^7.1.5",
"@tryfabric/martian": "^1.2.4",
"@types/adm-zip": "^0",
"@types/diff": "^7",
"@types/fs-extra": "^11",
"@types/lodash": "^4.17.5",
@@ -142,24 +138,25 @@
"@types/react-window": "^1",
"@types/tinycolor2": "^1",
"@types/ws": "^8",
"@uiw/codemirror-extensions-langs": "^4.23.12",
"@uiw/codemirror-themes-all": "^4.23.12",
"@uiw/react-codemirror": "^4.23.12",
"@vitejs/plugin-react-swc": "^3.9.0",
"@vitest/coverage-v8": "^3.1.1",
"@vitest/ui": "^3.1.1",
"@vitest/web-worker": "^3.1.3",
"@xyflow/react": "^12.4.4",
"antd": "^5.22.5",
"applescript": "^1.0.0",
"axios": "^1.7.3",
"babel-plugin-styled-components": "^2.1.4",
"browser-image-compression": "^2.0.2",
"dayjs": "^1.11.11",
"dexie": "^4.0.8",
"dexie-react-hooks": "^1.1.7",
"dotenv-cli": "^7.4.2",
"electron": "31.7.6",
"electron": "35.4.0",
"electron-builder": "26.0.15",
"electron-devtools-installer": "^3.2.0",
"electron-icon-builder": "^2.0.1",
"electron-vite": "^2.3.0",
"electron-vite": "^3.1.0",
"emittery": "^1.0.3",
"emoji-picker-element": "^1.22.1",
"eslint": "^9.22.0",
@@ -173,12 +170,14 @@
"lodash": "^4.17.21",
"lru-cache": "^11.1.0",
"lucide-react": "^0.487.0",
"mermaid": "^11.6.0",
"mime": "^4.0.4",
"motion": "^12.10.5",
"npx-scope-finder": "^1.2.0",
"openai": "patch:openai@npm%3A4.96.0#~/.yarn/patches/openai-npm-4.96.0-0665b05cb9.patch",
"p-queue": "^8.1.0",
"prettier": "^3.5.3",
"rc-virtual-list": "^3.18.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hotkeys-hook": "^4.6.1",
@@ -189,6 +188,7 @@
"react-router": "6",
"react-router-dom": "6",
"react-spinners": "^0.14.1",
"react-window": "^1.8.11",
"redux": "^5.0.1",
"redux-persist": "^6.0.0",
"rehype-katex": "^7.0.1",
@@ -198,12 +198,11 @@
"remark-gfm": "^4.0.0",
"remark-math": "^6.0.0",
"rollup-plugin-visualizer": "^5.12.0",
"sass": "^1.77.2",
"shiki": "^3.2.2",
"sass": "^1.88.0",
"shiki": "^3.4.2",
"string-width": "^7.2.0",
"styled-components": "^6.1.11",
"tiny-pinyin": "^1.3.2",
"tinycolor2": "^1.6.0",
"tokenx": "^0.4.1",
"typescript": "^5.6.2",
"uuid": "^10.0.0",
@@ -219,10 +218,10 @@
"openai@npm:^4.77.0": "patch:openai@npm%3A4.96.0#~/.yarn/patches/openai-npm-4.96.0-0665b05cb9.patch",
"pkce-challenge@npm:^4.1.0": "patch:pkce-challenge@npm%3A4.1.0#~/.yarn/patches/pkce-challenge-npm-4.1.0-fbc51695a3.patch",
"app-builder-lib@npm:26.0.13": "patch:app-builder-lib@npm%3A26.0.13#~/.yarn/patches/app-builder-lib-npm-26.0.13-a064c9e1d0.patch",
"shiki": "3.2.2",
"openai@npm:^4.87.3": "patch:openai@npm%3A4.96.0#~/.yarn/patches/openai-npm-4.96.0-0665b05cb9.patch"
"openai@npm:^4.87.3": "patch:openai@npm%3A4.96.0#~/.yarn/patches/openai-npm-4.96.0-0665b05cb9.patch",
"app-builder-lib@npm:26.0.15": "patch:app-builder-lib@npm%3A26.0.15#~/.yarn/patches/app-builder-lib-npm-26.0.15-360e5b0476.patch"
},
"packageManager": "yarn@4.6.0",
"packageManager": "yarn@4.9.1",
"lint-staged": {
"*.{js,jsx,ts,tsx,cjs,mjs,cts,mts}": [
"prettier --write",

View File

@@ -21,6 +21,9 @@ export enum IpcChannel {
App_InstallUvBinary = 'app:install-uv-binary',
App_InstallBunBinary = 'app:install-bun-binary',
Notification_Send = 'notification:send',
Notification_OnClick = 'notification:on-click',
Webview_SetOpenLinkExternal = 'webview:set-open-link-external',
// Open
@@ -52,6 +55,7 @@ export enum IpcChannel {
Mcp_GetInstallInfo = 'mcp:get-install-info',
Mcp_ServersChanged = 'mcp:servers-changed',
Mcp_ServersUpdated = 'mcp:servers-updated',
Mcp_CheckConnectivity = 'mcp:check-connectivity',
//copilot
Copilot_GetAuthMessage = 'copilot:get-auth-message',
@@ -169,5 +173,8 @@ export enum IpcChannel {
StoreSync_Subscribe = 'store-sync:subscribe',
StoreSync_Unsubscribe = 'store-sync:unsubscribe',
StoreSync_OnUpdate = 'store-sync:on-update',
StoreSync_BroadcastSync = 'store-sync:broadcast-sync'
StoreSync_BroadcastSync = 'store-sync:broadcast-sync',
// Provider
Provider_AddKey = 'provider:add-key'
}

19
scripts/win-sign.js Normal file
View File

@@ -0,0 +1,19 @@
const { execSync } = require('child_process')
exports.default = async function (configuration) {
if (process.env.WIN_SIGN) {
const { path } = configuration
if (configuration.path) {
try {
console.log('Start code signing...')
console.log('Signing file:', path)
const signCommand = `signtool sign /tr http://timestamp.comodoca.com /td sha256 /fd sha256 /a /v "${path}"`
execSync(signCommand, { stdio: 'inherit' })
console.log('Code signing completed')
} catch (error) {
console.error('Code signing failed:', error)
throw error
}
}
}
}

View File

@@ -2,12 +2,11 @@ import '@main/config'
import { electronApp, optimizer } from '@electron-toolkit/utils'
import { replaceDevtoolsFont } from '@main/utils/windowUtil'
import { IpcChannel } from '@shared/IpcChannel'
import { app, BrowserWindow, ipcMain } from 'electron'
import { app } from 'electron'
import installExtension, { REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS } from 'electron-devtools-installer'
import Logger from 'electron-log'
import { isDev, isMac, isWin } from './constant'
import { isDev } from './constant'
import { registerIpc } from './ipc'
import { configManager } from './services/ConfigManager'
import mcpService from './services/MCPService'
@@ -85,18 +84,6 @@ if (!app.requestSingleInstanceLock()) {
.then((name) => console.log(`Added Extension: ${name}`))
.catch((err) => console.log('An error occurred: ', err))
}
ipcMain.handle(IpcChannel.System_GetDeviceType, () => {
return isMac ? 'mac' : isWin ? 'windows' : 'linux'
})
ipcMain.handle(IpcChannel.System_GetHostname, () => {
return require('os').hostname()
})
ipcMain.handle(IpcChannel.System_ToggleDevTools, (e) => {
const win = BrowserWindow.fromWebContents(e.sender)
win && win.webContents.toggleDevTools()
})
})
registerProtocolClient(app)

View File

@@ -8,6 +8,7 @@ import { IpcChannel } from '@shared/IpcChannel'
import { Shortcut, ThemeMode } from '@types'
import { BrowserWindow, ipcMain, nativeTheme, session, shell } from 'electron'
import log from 'electron-log'
import { Notification } from 'src/renderer/src/types/notification'
import { titleBarOverlayDark, titleBarOverlayLight } from './config'
import AppUpdater from './services/AppUpdater'
@@ -20,6 +21,7 @@ import FileStorage from './services/FileStorage'
import { GeminiService } from './services/GeminiService'
import KnowledgeService from './services/KnowledgeService'
import mcpService from './services/MCPService'
import NotificationService from './services/NotificationService'
import * as NutstoreService from './services/NutstoreService'
import ObsidianVaultService from './services/ObsidianVaultService'
import { ProxyConfig, proxyManager } from './services/ProxyManager'
@@ -41,6 +43,7 @@ const obsidianVaultService = new ObsidianVaultService()
export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
const appUpdater = new AppUpdater(mainWindow)
const notificationService = new NotificationService(mainWindow)
ipcMain.handle(IpcChannel.App_Info, () => ({
version: app.getVersion(),
@@ -200,10 +203,26 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
await appUpdater.checkForUpdates()
})
// notification
ipcMain.handle(IpcChannel.Notification_Send, async (_, notification: Notification) => {
await notificationService.sendNotification(notification)
})
ipcMain.handle(IpcChannel.Notification_OnClick, (_, notification: Notification) => {
mainWindow.webContents.send('notification-click', notification)
})
// zip
ipcMain.handle(IpcChannel.Zip_Compress, (_, text: string) => compress(text))
ipcMain.handle(IpcChannel.Zip_Decompress, (_, text: Buffer) => decompress(text))
// system
ipcMain.handle(IpcChannel.System_GetDeviceType, () => (isMac ? 'mac' : isWin ? 'windows' : 'linux'))
ipcMain.handle(IpcChannel.System_GetHostname, () => require('os').hostname())
ipcMain.handle(IpcChannel.System_ToggleDevTools, (e) => {
const win = BrowserWindow.fromWebContents(e.sender)
win && win.webContents.toggleDevTools()
})
// backup
ipcMain.handle(IpcChannel.Backup_Backup, backupManager.backup)
ipcMain.handle(IpcChannel.Backup_Restore, backupManager.restore)
@@ -311,6 +330,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.Mcp_ListResources, mcpService.listResources)
ipcMain.handle(IpcChannel.Mcp_GetResource, mcpService.getResource)
ipcMain.handle(IpcChannel.Mcp_GetInstallInfo, mcpService.getInstallInfo)
ipcMain.handle(IpcChannel.Mcp_CheckConnectivity, mcpService.checkMcpConnectivity)
ipcMain.handle(IpcChannel.App_IsBinaryExist, (_, name: string) => isBinaryExists(name))
ipcMain.handle(IpcChannel.App_GetBinaryPath, (_, name: string) => getBinaryPath(name))

View File

@@ -38,7 +38,7 @@ export default abstract class BaseReranker {
protected getRerankRequestBody(query: string, searchResults: ExtractChunkData[]) {
const provider = this.base.rerankModelProvider
const documents = searchResults.map((doc) => doc.pageContent)
const topN = this.base.topN || 10
const topN = this.base.documentCount
if (provider === 'voyageai') {
return {

View File

@@ -77,7 +77,8 @@ class BackupManager {
_: Electron.IpcMainInvokeEvent,
fileName: string,
data: string,
destinationPath: string = this.backupDir
destinationPath: string = this.backupDir,
skipBackupFile: boolean = false
): Promise<string> {
const mainWindow = windowService.getMainWindow()
@@ -104,23 +105,30 @@ class BackupManager {
onProgress({ stage: 'writing_data', progress: 20, total: 100 })
// 复制 Data 目录到临时目录
const sourcePath = path.join(app.getPath('userData'), 'Data')
const tempDataDir = path.join(this.tempDir, 'Data')
Logger.log('[BackupManager IPC] ', skipBackupFile)
// 获取源目录总大小
const totalSize = await this.getDirSize(sourcePath)
let copiedSize = 0
if (!skipBackupFile) {
// 复制 Data 目录到临时目录
const sourcePath = path.join(app.getPath('userData'), 'Data')
const tempDataDir = path.join(this.tempDir, 'Data')
// 使用流式复制
await this.copyDirWithProgress(sourcePath, tempDataDir, (size) => {
copiedSize += size
const progress = Math.min(50, Math.floor((copiedSize / totalSize) * 50))
onProgress({ stage: 'copying_files', progress, total: 100 })
})
// 获取源目录总大小
const totalSize = await this.getDirSize(sourcePath)
let copiedSize = 0
await this.setWritableRecursive(tempDataDir)
onProgress({ stage: 'preparing_compression', progress: 50, total: 100 })
// 使用流式复制
await this.copyDirWithProgress(sourcePath, tempDataDir, (size) => {
copiedSize += size
const progress = Math.min(50, Math.floor((copiedSize / totalSize) * 50))
onProgress({ stage: 'copying_files', progress, total: 100 })
})
await this.setWritableRecursive(tempDataDir)
onProgress({ stage: 'preparing_compression', progress: 50, total: 100 })
} else {
Logger.log('[BackupManager] Skip the backup of the file')
await fs.promises.mkdir(path.join(this.tempDir, 'Data')) // 不创建空 Data 目录会导致 restore 失败
}
// 创建输出文件流
const backupedFilePath = path.join(destinationPath, fileName)
@@ -247,19 +255,26 @@ class BackupManager {
const sourcePath = path.join(this.tempDir, 'Data')
const destPath = path.join(app.getPath('userData'), 'Data')
// 获取源目录总大小
const totalSize = await this.getDirSize(sourcePath)
let copiedSize = 0
const dataExists = await fs.pathExists(sourcePath)
const dataFiles = dataExists ? await fs.readdir(sourcePath) : []
await this.setWritableRecursive(destPath)
await fs.remove(destPath)
if (dataExists && dataFiles.length > 0) {
// 获取源目录总大小
const totalSize = await this.getDirSize(sourcePath)
let copiedSize = 0
// 使用流式复制
await this.copyDirWithProgress(sourcePath, destPath, (size) => {
copiedSize += size
const progress = Math.min(85, 35 + Math.floor((copiedSize / totalSize) * 50))
onProgress({ stage: 'copying_files', progress, total: 100 })
})
await this.setWritableRecursive(destPath)
await fs.remove(destPath)
// 使用流式复制
await this.copyDirWithProgress(sourcePath, destPath, (size) => {
copiedSize += size
const progress = Math.min(85, 35 + Math.floor((copiedSize / totalSize) * 50))
onProgress({ stage: 'copying_files', progress, total: 100 })
})
} else {
Logger.log('[backup] skipBackupFile is true, skip restoring Data directory')
}
Logger.log('[backup] step 4: clean up temp directory')
// 清理临时目录
@@ -279,7 +294,7 @@ class BackupManager {
async backupToWebdav(_: Electron.IpcMainInvokeEvent, data: string, webdavConfig: WebDavConfig) {
const filename = webdavConfig.fileName || 'cherry-studio.backup.zip'
const backupedFilePath = await this.backup(_, filename, data)
const backupedFilePath = await this.backup(_, filename, data, undefined, webdavConfig.skipBackupFile)
const webdavClient = new WebDav(webdavConfig)
try {
const result = await webdavClient.putFileContents(filename, fs.createReadStream(backupedFilePath), {

View File

@@ -62,7 +62,7 @@ export class ConfigManager {
}
getTrayOnClose(): boolean {
return !!this.get(ConfigKeys.TrayOnClose, false)
return !!this.get(ConfigKeys.TrayOnClose, true)
}
setTrayOnClose(value: boolean) {

View File

@@ -328,7 +328,7 @@ class FileStorage {
fileName: string,
content: string,
options?: SaveDialogOptions
): Promise<string | null> => {
): Promise<string> => {
try {
const result: SaveDialogReturnValue = await dialog.showSaveDialog({
title: '保存文件',
@@ -336,14 +336,18 @@ class FileStorage {
...options
})
if (result.canceled) {
return Promise.reject(new Error('User canceled the save dialog'))
}
if (!result.canceled && result.filePath) {
await writeFileSync(result.filePath, content, { encoding: 'utf-8' })
}
return result.filePath
} catch (err) {
} catch (err: any) {
logger.error('[IPC - Error]', 'An error occurred saving the file:', err)
return null
return Promise.reject('An error occurred saving the file: ' + err?.message)
}
}

View File

@@ -8,8 +8,19 @@ export class GeminiService {
private static readonly FILE_LIST_CACHE_KEY = 'gemini_file_list'
private static readonly CACHE_DURATION = 3000
static async uploadFile(_: Electron.IpcMainInvokeEvent, file: FileType, apiKey: string): Promise<File> {
const sdk = new GoogleGenAI({ vertexai: false, apiKey })
static async uploadFile(
_: Electron.IpcMainInvokeEvent,
file: FileType,
{ apiKey, baseURL }: { apiKey: string; baseURL: string }
): Promise<File> {
const sdk = new GoogleGenAI({
vertexai: false,
apiKey,
httpOptions: {
baseUrl: baseURL
}
})
return await sdk.files.upload({
file: file.path,
config: {

View File

@@ -4,6 +4,7 @@ import path from 'node:path'
import { createInMemoryMCPServer } from '@main/mcpServers/factory'
import { makeSureDirExists } from '@main/utils'
import { buildFunctionCallToolName } from '@main/utils/mcp'
import { getBinaryName, getBinaryPath } from '@main/utils/process'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { SSEClientTransport, SSEClientTransportOptions } from '@modelcontextprotocol/sdk/client/sse.js'
@@ -69,17 +70,7 @@ function withCache<T extends unknown[], R>(
class McpService {
private clients: Map<string, Client> = new Map()
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
})
}
private pendingClients: Map<string, Promise<Client>> = new Map()
constructor() {
this.initClient = this.initClient.bind(this)
@@ -96,9 +87,26 @@ class McpService {
this.cleanup = this.cleanup.bind(this)
}
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
})
}
async initClient(server: MCPServer): Promise<Client> {
const serverKey = this.getServerKey(server)
// If there's a pending initialization, wait for it
const pendingClient = this.pendingClients.get(serverKey)
if (pendingClient) {
return pendingClient
}
// Check if we already have a client for this server configuration
const existingClient = this.clients.get(serverKey)
if (existingClient) {
@@ -113,209 +121,232 @@ class McpService {
} else {
return existingClient
}
} catch (error) {
Logger.error(`[MCP] Error pinging server ${server.name}:`, error)
} catch (error: any) {
Logger.error(`[MCP] Error pinging server ${server.name}:`, error?.message)
this.clients.delete(serverKey)
}
}
// Create new client instance for each connection
const client = new Client({ name: 'Cherry Studio', version: app.getVersion() }, { capabilities: {} })
const args = [...(server.args || [])]
// Create a promise for the initialization process
const initPromise = (async () => {
try {
// Create new client instance for each connection
const client = new Client({ name: 'Cherry Studio', version: app.getVersion() }, { capabilities: {} })
// let transport: StdioClientTransport | SSEClientTransport | InMemoryTransport | StreamableHTTPClientTransport
const authProvider = new McpOAuthClientProvider({
serverUrlHash: crypto
.createHash('md5')
.update(server.baseUrl || '')
.digest('hex')
})
const args = [...(server.args || [])]
const initTransport = async (): Promise<
StdioClientTransport | SSEClientTransport | InMemoryTransport | StreamableHTTPClientTransport
> => {
// Create appropriate transport based on configuration
if (server.type === 'inMemory') {
Logger.info(`[MCP] Using in-memory transport for server: ${server.name}`)
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
// start the in-memory server with the given name and environment variables
const inMemoryServer = createInMemoryMCPServer(server.name, args, server.env || {})
try {
await inMemoryServer.connect(serverTransport)
Logger.info(`[MCP] In-memory server started: ${server.name}`)
} catch (error: Error | any) {
Logger.error(`[MCP] Error starting in-memory server: ${error}`)
throw new Error(`Failed to start in-memory server: ${error.message}`)
}
// set the client transport to the client
return clientTransport
} else if (server.baseUrl) {
if (server.type === 'streamableHttp') {
const options: StreamableHTTPClientTransportOptions = {
requestInit: {
headers: server.headers || {}
},
authProvider
}
return new StreamableHTTPClientTransport(new URL(server.baseUrl!), options)
} else if (server.type === 'sse') {
const options: SSEClientTransportOptions = {
eventSourceInit: {
fetch: async (url, init) => {
const headers = { ...(server.headers || {}), ...(init?.headers || {}) }
// let transport: StdioClientTransport | SSEClientTransport | InMemoryTransport | StreamableHTTPClientTransport
const authProvider = new McpOAuthClientProvider({
serverUrlHash: crypto
.createHash('md5')
.update(server.baseUrl || '')
.digest('hex')
})
// Get tokens from authProvider to make sure using the latest tokens
if (authProvider && typeof authProvider.tokens === 'function') {
try {
const tokens = await authProvider.tokens()
if (tokens && tokens.access_token) {
headers['Authorization'] = `Bearer ${tokens.access_token}`
const initTransport = async (): Promise<
StdioClientTransport | SSEClientTransport | InMemoryTransport | StreamableHTTPClientTransport
> => {
// Create appropriate transport based on configuration
if (server.type === 'inMemory') {
Logger.info(`[MCP] Using in-memory transport for server: ${server.name}`)
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
// start the in-memory server with the given name and environment variables
const inMemoryServer = createInMemoryMCPServer(server.name, args, server.env || {})
try {
await inMemoryServer.connect(serverTransport)
Logger.info(`[MCP] In-memory server started: ${server.name}`)
} catch (error: Error | any) {
Logger.error(`[MCP] Error starting in-memory server: ${error}`)
throw new Error(`Failed to start in-memory server: ${error.message}`)
}
// set the client transport to the client
return clientTransport
} else if (server.baseUrl) {
if (server.type === 'streamableHttp') {
const options: StreamableHTTPClientTransportOptions = {
requestInit: {
headers: server.headers || {}
},
authProvider
}
return new StreamableHTTPClientTransport(new URL(server.baseUrl!), options)
} else if (server.type === 'sse') {
const options: SSEClientTransportOptions = {
eventSourceInit: {
fetch: async (url, init) => {
const headers = { ...(server.headers || {}), ...(init?.headers || {}) }
// Get tokens from authProvider to make sure using the latest tokens
if (authProvider && typeof authProvider.tokens === 'function') {
try {
const tokens = await authProvider.tokens()
if (tokens && tokens.access_token) {
headers['Authorization'] = `Bearer ${tokens.access_token}`
}
} catch (error) {
Logger.error('Failed to fetch tokens:', error)
}
}
} catch (error) {
Logger.error('Failed to fetch tokens:', error)
return fetch(url, { ...init, headers })
}
},
requestInit: {
headers: server.headers || {}
},
authProvider
}
return new SSEClientTransport(new URL(server.baseUrl!), options)
} else {
throw new Error('Invalid server type')
}
} else if (server.command) {
let cmd = server.command
if (server.command === 'npx') {
cmd = await getBinaryPath('bun')
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')
}
if (!args.includes('x')) {
args.unshift('x')
}
}
if (server.registryUrl) {
server.env = {
...server.env,
NPM_CONFIG_REGISTRY: server.registryUrl
}
return fetch(url, { ...init, headers })
// if the server name is mcp-auto-install, use the mcp-registry.json file in the bin directory
if (server.name.includes('mcp-auto-install')) {
const binPath = await getBinaryPath()
makeSureDirExists(binPath)
server.env.MCP_REGISTRY_PATH = path.join(binPath, '..', 'config', '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
}
}
},
requestInit: {
headers: server.headers || {}
},
authProvider
}
return new SSEClientTransport(new URL(server.baseUrl!), options)
} else {
throw new Error('Invalid server type')
}
} else if (server.command) {
let cmd = server.command
if (server.command === 'npx') {
cmd = await getBinaryPath('bun')
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')
}
if (!args.includes('x')) {
args.unshift('x')
}
}
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.includes('mcp-auto-install')) {
const binPath = await getBinaryPath()
makeSureDirExists(binPath)
server.env.MCP_REGISTRY_PATH = path.join(binPath, '..', 'config', '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
Logger.info(`[MCP] Starting server with command: ${cmd} ${args ? args.join(' ') : ''}`)
// Logger.info(`[MCP] Environment variables for server:`, server.env)
const loginShellEnv = await this.getLoginShellEnv()
// Bun not support proxy https://github.com/oven-sh/bun/issues/16812
if (cmd.endsWith('bun')) {
this.removeProxyEnv(loginShellEnv)
}
const stdioTransport = new StdioClientTransport({
command: cmd,
args,
env: {
...loginShellEnv,
...server.env
},
stderr: 'pipe'
})
stdioTransport.stderr?.on('data', (data) =>
Logger.info(`[MCP] Stdio stderr for server: ${server.name} `, data.toString())
)
return stdioTransport
} else {
throw new Error('Either baseUrl or command must be provided')
}
}
Logger.info(`[MCP] Starting server with command: ${cmd} ${args ? args.join(' ') : ''}`)
// Logger.info(`[MCP] Environment variables for server:`, server.env)
const loginShellEnv = await this.getLoginShellEnv()
const stdioTransport = new StdioClientTransport({
command: cmd,
args,
env: {
...loginShellEnv,
...server.env
},
stderr: 'pipe'
})
stdioTransport.stderr?.on('data', (data) =>
Logger.info(`[MCP] Stdio stderr for server: ${server.name} `, data.toString())
)
return stdioTransport
} else {
throw new Error('Either baseUrl or command must be provided')
}
}
const handleAuth = async (client: Client, transport: SSEClientTransport | StreamableHTTPClientTransport) => {
Logger.info(`[MCP] Starting OAuth flow for server: ${server.name}`)
// Create an event emitter for the OAuth callback
const events = new EventEmitter()
const handleAuth = async (client: Client, transport: SSEClientTransport | StreamableHTTPClientTransport) => {
Logger.info(`[MCP] Starting OAuth flow for server: ${server.name}`)
// Create an event emitter for the OAuth callback
const events = new EventEmitter()
// Create a callback server
const callbackServer = new CallBackServer({
port: authProvider.config.callbackPort,
path: authProvider.config.callbackPath || '/oauth/callback',
events
})
// Create a callback server
const callbackServer = new CallBackServer({
port: authProvider.config.callbackPort,
path: authProvider.config.callbackPath || '/oauth/callback',
events
})
// Set a timeout to close the callback server
const timeoutId = setTimeout(() => {
Logger.warn(`[MCP] OAuth flow timed out for server: ${server.name}`)
callbackServer.close()
}, 300000) // 5 minutes timeout
// Set a timeout to close the callback server
const timeoutId = setTimeout(() => {
Logger.warn(`[MCP] OAuth flow timed out for server: ${server.name}`)
callbackServer.close()
}, 300000) // 5 minutes timeout
try {
// Wait for the authorization code
const authCode = await callbackServer.waitForAuthCode()
Logger.info(`[MCP] Received auth code: ${authCode}`)
try {
// Wait for the authorization code
const authCode = await callbackServer.waitForAuthCode()
Logger.info(`[MCP] Received auth code: ${authCode}`)
// Complete the OAuth flow
await transport.finishAuth(authCode)
// Complete the OAuth flow
await transport.finishAuth(authCode)
Logger.info(`[MCP] OAuth flow completed for server: ${server.name}`)
Logger.info(`[MCP] OAuth flow completed for server: ${server.name}`)
const newTransport = await initTransport()
// Try to connect again
await client.connect(newTransport)
const newTransport = await initTransport()
// Try to connect again
await client.connect(newTransport)
Logger.info(`[MCP] Successfully authenticated with server: ${server.name}`)
} catch (oauthError) {
Logger.error(`[MCP] OAuth authentication failed for server ${server.name}:`, oauthError)
throw new Error(
`OAuth authentication failed: ${oauthError instanceof Error ? oauthError.message : String(oauthError)}`
)
} finally {
// Clear the timeout and close the callback server
clearTimeout(timeoutId)
callbackServer.close()
}
}
Logger.info(`[MCP] Successfully authenticated with server: ${server.name}`)
} catch (oauthError) {
Logger.error(`[MCP] OAuth authentication failed for server ${server.name}:`, oauthError)
throw new Error(
`OAuth authentication failed: ${oauthError instanceof Error ? oauthError.message : String(oauthError)}`
)
try {
const transport = await initTransport()
try {
await client.connect(transport)
} catch (error: Error | any) {
if (
error instanceof Error &&
(error.name === 'UnauthorizedError' || error.message.includes('Unauthorized'))
) {
Logger.info(`[MCP] Authentication required for server: ${server.name}`)
await handleAuth(client, transport as SSEClientTransport | StreamableHTTPClientTransport)
} else {
throw error
}
}
// Store the new client in the cache
this.clients.set(serverKey, client)
Logger.info(`[MCP] Activated server: ${server.name}`)
return client
} catch (error: any) {
Logger.error(`[MCP] Error activating server ${server.name}:`, error?.message)
throw new Error(`[MCP] Error activating server ${server.name}: ${error.message}`)
}
} finally {
// Clear the timeout and close the callback server
clearTimeout(timeoutId)
callbackServer.close()
// Clean up the pending promise when done
this.pendingClients.delete(serverKey)
}
}
})()
try {
const transport = await initTransport()
try {
await client.connect(transport)
} catch (error: Error | any) {
if (error instanceof Error && (error.name === 'UnauthorizedError' || error.message.includes('Unauthorized'))) {
Logger.info(`[MCP] Authentication required for server: ${server.name}`)
await handleAuth(client, transport as SSEClientTransport | StreamableHTTPClientTransport)
} else {
throw error
}
}
// Store the pending promise
this.pendingClients.set(serverKey, initPromise)
// Store the new client in the cache
this.clients.set(serverKey, client)
Logger.info(`[MCP] Activated server: ${server.name}`)
return client
} catch (error: any) {
Logger.error(`[MCP] Error activating server ${server.name}:`, error)
throw new Error(`[MCP] Error activating server ${server.name}: ${error.message}`)
}
return initPromise
}
async closeClient(serverKey: string) {
@@ -357,12 +388,32 @@ class McpService {
for (const [key] of this.clients) {
try {
await this.closeClient(key)
} catch (error) {
Logger.error(`[MCP] Failed to close client: ${error}`)
} catch (error: any) {
Logger.error(`[MCP] Failed to close client: ${error?.message}`)
}
}
}
/**
* Check connectivity for an MCP server
*/
public async checkMcpConnectivity(_: Electron.IpcMainInvokeEvent, server: MCPServer): Promise<boolean> {
Logger.info(`[MCP] Checking connectivity for server: ${server.name}`)
try {
const client = await this.initClient(server)
// Attempt to list tools as a way to check connectivity
await client.listTools()
Logger.info(`[MCP] Connectivity check successful for server: ${server.name}`)
return true
} catch (error) {
Logger.error(`[MCP] Connectivity check failed for server: ${server.name}`, error)
// Close the client if connectivity check fails to ensure a clean state for the next attempt
const serverKey = this.getServerKey(server)
await this.closeClient(serverKey)
return false
}
}
private async listToolsImpl(server: MCPServer): Promise<MCPTool[]> {
Logger.info(`[MCP] Listing tools for server: ${server.name}`)
const client = await this.initClient(server)
@@ -372,15 +423,15 @@ class McpService {
tools.map((tool: any) => {
const serverTool: MCPTool = {
...tool,
id: `f${nanoid()}`,
id: buildFunctionCallToolName(server.name, tool.name),
serverId: server.id,
serverName: server.name
}
serverTools.push(serverTool)
})
return serverTools
} catch (error) {
Logger.error(`[MCP] Failed to list tools for server: ${server.name}`, error)
} catch (error: any) {
Logger.error(`[MCP] Failed to list tools for server: ${server.name}`, error?.message)
return []
}
}
@@ -439,8 +490,8 @@ class McpService {
* List prompts available on an MCP server
*/
private async listPromptsImpl(server: MCPServer): Promise<MCPPrompt[]> {
Logger.info(`[MCP] Listing prompts for server: ${server.name}`)
const client = await this.initClient(server)
Logger.info(`[MCP] Listing prompts for server: ${server.name}`)
try {
const { prompts } = await client.listPrompts()
return prompts.map((prompt: any) => ({
@@ -449,8 +500,11 @@ class McpService {
serverId: server.id,
serverName: server.name
}))
} catch (error) {
Logger.error(`[MCP] Failed to list prompts for server: ${server.name}`, error)
} catch (error: any) {
// -32601 is the code for the method not found
if (error?.code !== -32601) {
Logger.error(`[MCP] Failed to list prompts for server: ${server.name}`, error?.message)
}
return []
}
}
@@ -508,8 +562,8 @@ class McpService {
* List resources available on an MCP server (implementation)
*/
private async listResourcesImpl(server: MCPServer): Promise<MCPResource[]> {
Logger.info(`[MCP] Listing resources for server: ${server.name}`)
const client = await this.initClient(server)
Logger.info(`[MCP] Listing resources for server: ${server.name}`)
try {
const result = await client.listResources()
const resources = result.resources || []
@@ -519,8 +573,11 @@ class McpService {
serverName: server.name
}))
return serverResources
} catch (error) {
Logger.error(`[MCP] Failed to list resources for server: ${server.name}`, error)
} catch (error: any) {
// -32601 is the code for the method not found
if (error?.code !== -32601) {
Logger.error(`[MCP] Failed to list resources for server: ${server.name}`, error?.message)
}
return []
}
}
@@ -563,7 +620,7 @@ class McpService {
contents: contents
}
} catch (error: Error | any) {
Logger.error(`[MCP] Failed to get resource ${uri} from server: ${server.name}`, error)
Logger.error(`[MCP] Failed to get resource ${uri} from server: ${server.name}`, error.message)
throw new Error(`Failed to get resource ${uri} from server: ${server.name}: ${error.message}`)
}
}
@@ -600,7 +657,14 @@ class McpService {
return {}
}
})
private removeProxyEnv(env: Record<string, string>) {
delete env.HTTPS_PROXY
delete env.HTTP_PROXY
delete env.grpc_proxy
delete env.http_proxy
delete env.https_proxy
}
}
const mcpService = new McpService()
export default mcpService
export default new McpService()

View File

@@ -0,0 +1,31 @@
import { BrowserWindow, Notification as ElectronNotification } from 'electron'
import { Notification } from 'src/renderer/src/types/notification'
import icon from '../../../build/icon.png?asset'
class NotificationService {
private window: BrowserWindow
constructor(window: BrowserWindow) {
// Initialize the service
this.window = window
}
public async sendNotification(notification: Notification) {
// 使用 Electron Notification API
const electronNotification = new ElectronNotification({
title: notification.title,
body: notification.message,
icon: icon
})
electronNotification.on('click', () => {
this.window.show()
this.window.webContents.send('notification-click', notification)
})
electronNotification.show()
}
}
export default NotificationService

View File

@@ -6,6 +6,7 @@ import { promisify } from 'node:util'
import { app } from 'electron'
import Logger from 'electron-log'
import { handleProvidersProtocolUrl } from './urlschema/handle-providers'
import { handleMcpProtocolUrl } from './urlschema/mcp-install'
import { windowService } from './WindowService'
@@ -34,6 +35,9 @@ export function handleProtocolUrl(url: string) {
case 'mcp':
handleMcpProtocolUrl(urlObj)
return
case 'providers':
handleProvidersProtocolUrl(urlObj)
return
}
// You can send the data to your renderer process

View File

@@ -75,7 +75,9 @@ export class WindowService {
sandbox: false,
webSecurity: false,
webviewTag: true,
allowRunningInsecureContent: true
allowRunningInsecureContent: true,
zoomFactor: configManager.getZoomFactor(),
backgroundThrottling: false
}
})
@@ -184,6 +186,12 @@ export class WindowService {
mainWindow.webContents.setZoomFactor(configManager.getZoomFactor())
})
// set the zoom factor again when the window is going to restore
// minimize and restore will cause zoom reset
mainWindow.on('restore', () => {
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
@@ -440,7 +448,8 @@ export class WindowService {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
webSecurity: false,
webviewTag: true
webviewTag: true,
backgroundThrottling: false
}
})

View File

@@ -47,7 +47,7 @@ function getLoginShellEnvironment(): Promise<Record<string, string>> {
commandArgs = ['-ilc', shellCommandToGetEnv] // -i for interactive, -l for login, -c to execute command
}
Logger.log(`Spawning shell: ${shellPath} with args: ${commandArgs.join(' ')} in ${homeDirectory}`)
Logger.log(`[ShellEnv] Spawning shell: ${shellPath} with args: ${commandArgs.join(' ')} in ${homeDirectory}`)
const child = spawn(shellPath, commandArgs, {
cwd: homeDirectory, // Run the command in the user's home directory
@@ -85,7 +85,7 @@ function getLoginShellEnvironment(): Promise<Record<string, string>> {
Logger.warn(`Shell process stderr output (even with exit code 0):\n${errorOutput.trim()}`)
}
const env = {}
const env: Record<string, string> = {}
const lines = output.split('\n')
lines.forEach((line) => {
@@ -110,6 +110,8 @@ function getLoginShellEnvironment(): Promise<Record<string, string>> {
Logger.warn('Raw output from shell:\n', output)
}
env.PATH = env.Path || env.PATH || ''
resolve(env)
})
})

View File

@@ -0,0 +1,37 @@
import { IpcChannel } from '@shared/IpcChannel'
import Logger from 'electron-log'
import { windowService } from '../WindowService'
export function handleProvidersProtocolUrl(url: URL) {
const params = new URLSearchParams(url.search)
switch (url.pathname) {
case '/api-keys': {
// jsonConfig example:
// {
// "id": "tokenflux",
// "baseUrl": "https://tokenflux.ai/v1",
// "apiKey": "sk-xxxx"
// }
// cherrystudio://providers/api-keys?data={base64Encode(JSON.stringify(jsonConfig))}
const data = params.get('data')
if (data) {
const stringify = Buffer.from(data, 'base64').toString('utf8')
Logger.info('get api keys from urlschema: ', stringify)
const jsonConfig = JSON.parse(stringify)
Logger.info('get api keys from urlschema: ', jsonConfig)
const mainWindow = windowService.getMainWindow()
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send(IpcChannel.Provider_AddKey, jsonConfig)
mainWindow.webContents.executeJavaScript(`window.navigate('/settings/provider?id=${jsonConfig.id}')`)
}
} else {
Logger.error('No data found in URL')
}
break
}
default:
console.error(`Unknown MCP protocol URL: ${url}`)
break
}
}

34
src/main/utils/mcp.ts Normal file
View File

@@ -0,0 +1,34 @@
export function buildFunctionCallToolName(serverName: string, toolName: string) {
const sanitizedServer = serverName.trim().replace(/-/g, '_')
const sanitizedTool = toolName.trim().replace(/-/g, '_')
// Combine server name and tool name
let name = sanitizedTool
if (!sanitizedTool.includes(sanitizedServer.slice(0, 7))) {
name = `${sanitizedServer.slice(0, 7) || ''}-${sanitizedTool || ''}`
}
// Replace invalid characters with underscores or dashes
// Keep a-z, A-Z, 0-9, underscores and dashes
name = name.replace(/[^a-zA-Z0-9_-]/g, '_')
// Ensure name starts with a letter or underscore (for valid JavaScript identifier)
if (!/^[a-zA-Z]/.test(name)) {
name = `tool-${name}`
}
// Remove consecutive underscores/dashes (optional improvement)
name = name.replace(/[_-]{2,}/g, '_')
// Truncate to 63 characters maximum
if (name.length > 63) {
name = name.slice(0, 63)
}
// Handle edge case: ensure we still have a valid name if truncation left invalid chars at edges
if (name.endsWith('_') || name.endsWith('-')) {
name = name.slice(0, -1)
}
return name
}

View File

@@ -1,5 +1,7 @@
import { BrowserWindow } from 'electron'
import { isDev, isWin } from '../constant'
function isTilingWindowManager() {
if (process.platform === 'darwin') {
return false
@@ -15,31 +17,59 @@ function isTilingWindowManager() {
return tilingSystems.some((system) => desktopEnv?.includes(system))
}
//see: https://github.com/electron/electron/issues/42055#issuecomment-2449365647
export const replaceDevtoolsFont = (browserWindow: BrowserWindow) => {
if (process.platform === 'win32') {
//only for windows and dev, don't do this in production to avoid performance issues
if (isWin && isDev) {
browserWindow.webContents.on('devtools-opened', () => {
const css = `
:root {
--sys-color-base: var(--ref-palette-neutral100);
--source-code-font-family: consolas;
--source-code-font-family: consolas !important;
--source-code-font-size: 12px;
--monospace-font-family: consolas;
--monospace-font-family: consolas !important;
--monospace-font-size: 12px;
--default-font-family: system-ui, sans-serif;
--default-font-size: 12px;
--ref-palette-neutral99: #ffffffff;
}
.-theme-with-dark-background {
.theme-with-dark-background {
--sys-color-base: var(--ref-palette-secondary25);
}
body {
--default-font-family: system-ui,sans-serif;
}`
--default-font-family: system-ui, sans-serif;
}
`
browserWindow.webContents.devToolsWebContents?.executeJavaScript(`
const overriddenStyle = document.createElement('style');
overriddenStyle.innerHTML = '${css.replaceAll('\n', ' ')}';
document.body.append(overriddenStyle);
document.body.classList.remove('platform-windows');`)
document.querySelectorAll('.platform-windows').forEach(el => el.classList.remove('platform-windows'));
addStyleToAutoComplete();
const observer = new MutationObserver((mutationList, observer) => {
for (const mutation of mutationList) {
if (mutation.type === 'childList') {
for (let i = 0; i < mutation.addedNodes.length; i++) {
const item = mutation.addedNodes[i];
if (item.classList.contains('editor-tooltip-host')) {
addStyleToAutoComplete();
}
}
}
}
});
observer.observe(document.body, {childList: true});
function addStyleToAutoComplete() {
document.querySelectorAll('.editor-tooltip-host').forEach(element => {
if (element.shadowRoot.querySelectorAll('[data-key="overridden-dev-tools-font"]').length === 0) {
const overriddenStyle = document.createElement('style');
overriddenStyle.setAttribute('data-key', 'overridden-dev-tools-font');
overriddenStyle.innerHTML = '.cm-tooltip-autocomplete ul[role=listbox] {font-family: consolas !important;}';
element.shadowRoot.append(overriddenStyle);
}
});
}
`)
})
}
}

View File

@@ -2,7 +2,8 @@ import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
import { electronAPI } from '@electron-toolkit/preload'
import { IpcChannel } from '@shared/IpcChannel'
import { FileType, KnowledgeBaseParams, KnowledgeItem, MCPServer, Shortcut, WebDavConfig } from '@types'
import { contextBridge, ipcRenderer, OpenDialogOptions, shell } from 'electron'
import { contextBridge, ipcRenderer, OpenDialogOptions, shell, webUtils } from 'electron'
import { Notification } from 'src/renderer/src/types/notification'
import { CreateDirectoryOptions } from 'webdav'
// Custom APIs for renderer
@@ -25,6 +26,9 @@ const api = {
openWebsite: (url: string) => ipcRenderer.invoke(IpcChannel.Open_Website, url),
getCacheSize: () => ipcRenderer.invoke(IpcChannel.App_GetCacheSize),
clearCache: () => ipcRenderer.invoke(IpcChannel.App_ClearCache),
notification: {
send: (notification: Notification) => ipcRenderer.invoke(IpcChannel.Notification_Send, notification)
},
system: {
getDeviceType: () => ipcRenderer.invoke(IpcChannel.System_GetDeviceType),
getHostname: () => ipcRenderer.invoke(IpcChannel.System_GetHostname)
@@ -37,8 +41,8 @@ const api = {
decompress: (text: Buffer) => ipcRenderer.invoke(IpcChannel.Zip_Decompress, text)
},
backup: {
backup: (fileName: string, data: string, destinationPath?: string) =>
ipcRenderer.invoke(IpcChannel.Backup_Backup, fileName, data, destinationPath),
backup: (fileName: string, data: string, destinationPath?: string, skipBackupFile?: boolean) =>
ipcRenderer.invoke(IpcChannel.Backup_Backup, fileName, data, destinationPath, skipBackupFile),
restore: (backupPath: string) => ipcRenderer.invoke(IpcChannel.Backup_Restore, backupPath),
backupToWebdav: (data: string, webdavConfig: WebDavConfig) =>
ipcRenderer.invoke(IpcChannel.Backup_BackupToWebdav, data, webdavConfig),
@@ -73,7 +77,8 @@ const api = {
download: (url: string) => ipcRenderer.invoke(IpcChannel.File_Download, url),
copy: (fileId: string, destPath: string) => ipcRenderer.invoke(IpcChannel.File_Copy, fileId, destPath),
binaryImage: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_BinaryImage, fileId),
base64File: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Base64File, fileId)
base64File: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Base64File, fileId),
getPathForFile: (file: File) => webUtils.getPathForFile(file)
},
fs: {
read: (path: string) => ipcRenderer.invoke(IpcChannel.Fs_Read, path)
@@ -111,7 +116,8 @@ const api = {
resetMinimumSize: () => ipcRenderer.invoke(IpcChannel.Windows_ResetMinimumSize)
},
gemini: {
uploadFile: (file: FileType, apiKey: string) => ipcRenderer.invoke(IpcChannel.Gemini_UploadFile, file, apiKey),
uploadFile: (file: FileType, { apiKey, baseURL }: { apiKey: string; baseURL: string }) =>
ipcRenderer.invoke(IpcChannel.Gemini_UploadFile, file, { apiKey, baseURL }),
base64File: (file: FileType) => ipcRenderer.invoke(IpcChannel.Gemini_Base64File, file),
retrieveFile: (file: FileType, apiKey: string) => ipcRenderer.invoke(IpcChannel.Gemini_RetrieveFile, file, apiKey),
listFiles: (apiKey: string) => ipcRenderer.invoke(IpcChannel.Gemini_ListFiles, apiKey),
@@ -147,7 +153,8 @@ const api = {
listResources: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_ListResources, server),
getResource: ({ server, uri }: { server: MCPServer; uri: string }) =>
ipcRenderer.invoke(IpcChannel.Mcp_GetResource, { server, uri }),
getInstallInfo: () => ipcRenderer.invoke(IpcChannel.Mcp_GetInstallInfo)
getInstallInfo: () => ipcRenderer.invoke(IpcChannel.Mcp_GetInstallInfo),
checkMcpConnectivity: (server: any) => ipcRenderer.invoke(IpcChannel.Mcp_CheckConnectivity, server)
},
shell: {
openExternal: (url: string, options?: Electron.OpenExternalOptions) => shell.openExternal(url, options)

View File

@@ -18,3 +18,32 @@ vi.mock('electron-log/renderer', () => {
}
}
})
vi.stubGlobal('window', {
electron: {
ipcRenderer: {
on: vi.fn(), // Mocking ipcRenderer.on
send: vi.fn() // Mocking ipcRenderer.send
}
},
api: {
file: {
read: vi.fn().mockResolvedValue('[]'), // Mock file.read to return an empty array (you can customize this)
writeWithId: vi.fn().mockResolvedValue(undefined) // Mock file.writeWithId to do nothing
}
}
})
vi.mock('axios', () => ({
default: {
get: vi.fn().mockResolvedValue({ data: {} }), // Mocking axios GET request
post: vi.fn().mockResolvedValue({ data: {} }) // Mocking axios POST request
// You can add other axios methods like put, delete etc. as needed
}
}))
vi.stubGlobal('window', {
...global.window, // Copy other global properties
addEventListener: vi.fn(), // Mock addEventListener
removeEventListener: vi.fn() // You can also mock removeEventListener if needed
})

View File

@@ -5,7 +5,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="initial-scale=1, width=device-width" />
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; connect-src blob: *; script-src 'self' 'unsafe-eval' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: * blob:; frame-src * file:" />
content="default-src 'self'; connect-src blob: *; script-src 'self' 'unsafe-eval' 'unsafe-inline' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: * blob:; frame-src * file:" />
<title>Cherry Studio</title>
<style>
@@ -21,7 +21,7 @@
flex-direction: row;
justify-content: center;
align-items: center;
display: none;
display: flex;
}
#spinner img {
@@ -36,6 +36,9 @@
<div id="spinner">
<img src="/src/assets/images/logo.png" />
</div>
<script>
console.time('init')
</script>
<script type="module" src="/src/init.ts"></script>
<script type="module" src="/src/entryPoint.tsx"></script>
</body>

View File

@@ -8,8 +8,9 @@ import { PersistGate } from 'redux-persist/integration/react'
import Sidebar from './components/app/Sidebar'
import TopViewContainer from './components/TopView'
import AntdProvider from './context/AntdProvider'
import { CodeStyleProvider } from './context/CodeStyleProvider'
import { NotificationProvider } from './context/NotificationProvider'
import StyleSheetManager from './context/StyleSheetManager'
import { SyntaxHighlighterProvider } from './context/SyntaxHighlighterProvider'
import { ThemeProvider } from './context/ThemeProvider'
import NavigationHandler from './handler/NavigationHandler'
import AgentsPage from './pages/agents/AgentsPage'
@@ -27,26 +28,28 @@ function App(): React.ReactElement {
<StyleSheetManager>
<ThemeProvider>
<AntdProvider>
<SyntaxHighlighterProvider>
<PersistGate loading={null} persistor={persistor}>
<TopViewContainer>
<HashRouter>
<NavigationHandler />
<Sidebar />
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/agents" element={<AgentsPage />} />
<Route path="/paintings/*" element={<PaintingsRoutePage />} />
<Route path="/translate" element={<TranslatePage />} />
<Route path="/files" element={<FilesPage />} />
<Route path="/knowledge" element={<KnowledgePage />} />
<Route path="/apps" element={<AppsPage />} />
<Route path="/settings/*" element={<SettingsPage />} />
</Routes>
</HashRouter>
</TopViewContainer>
</PersistGate>
</SyntaxHighlighterProvider>
<NotificationProvider>
<CodeStyleProvider>
<PersistGate loading={null} persistor={persistor}>
<TopViewContainer>
<HashRouter>
<NavigationHandler />
<Sidebar />
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/agents" element={<AgentsPage />} />
<Route path="/paintings/*" element={<PaintingsRoutePage />} />
<Route path="/translate" element={<TranslatePage />} />
<Route path="/files" element={<FilesPage />} />
<Route path="/knowledge" element={<KnowledgePage />} />
<Route path="/apps" element={<AppsPage />} />
<Route path="/settings/*" element={<SettingsPage />} />
</Routes>
</HashRouter>
</TopViewContainer>
</PersistGate>
</CodeStyleProvider>
</NotificationProvider>
</AntdProvider>
</ThemeProvider>
</StyleSheetManager>

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 48 48"><defs><path id="a" d="M44.5 20H24v8.5h11.8C34.7 33.9 30.1 37 24 37c-7.2 0-13-5.8-13-13s5.8-13 13-13c3.1 0 5.9 1.1 8.1 2.9l6.4-6.4C34.6 4.1 29.6 2 24 2 11.8 2 2 11.8 2 24s9.8 22 22 22c11 0 21-8 21-22 0-1.3-.2-2.7-.5-4z"/></defs><clipPath id="b"><use xlink:href="#a" overflow="visible"/></clipPath><path clip-path="url(#b)" fill="#FBBC05" d="M0 37V11l17 13z"/><path clip-path="url(#b)" fill="#EA4335" d="M0 11l17 13 7-6.1L48 14V0H0z"/><path clip-path="url(#b)" fill="#34A853" d="M0 37l30-23 7.9 1L48 0v48H0z"/><path clip-path="url(#b)" fill="#4285F4" d="M48 48L17 24l-4-3 35-10z"/></svg>

After

Width:  |  Height:  |  Size: 688 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -17,7 +17,7 @@
}
.ant-tabs-tab-btn {
outline: none;
outline: none !important;
}
.ant-segmented-group {

View File

@@ -0,0 +1,130 @@
:root {
--color-white: #ffffff;
--color-white-soft: rgba(255, 255, 255, 0.8);
--color-white-mute: rgba(255, 255, 255, 0.94);
--color-black: #181818;
--color-black-soft: #222222;
--color-black-mute: #333333;
--color-gray-1: #515c67;
--color-gray-2: #414853;
--color-gray-3: #32363f;
--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);
--color-background: var(--color-black);
--color-background-soft: var(--color-black-soft);
--color-background-mute: var(--color-black-mute);
--color-background-opacity: rgba(34, 34, 34, 0.7);
--inner-glow-opacity: 0.3; // For the glassmorphism effect in the dropdown menu
--color-primary: #00b96b;
--color-primary-soft: #00b96b99;
--color-primary-mute: #00b96b33;
--color-text: var(--color-text-1);
--color-icon: #ffffff99;
--color-icon-white: #ffffff;
--color-border: #ffffff19;
--color-border-soft: #ffffff10;
--color-border-mute: #ffffff05;
--color-error: #f44336;
--color-link: #338cff;
--color-code-background: #323232;
--color-hover: rgba(40, 40, 40, 1);
--color-active: rgba(55, 55, 55, 1);
--color-frame-border: #333;
--color-group-background: var(--color-background-soft);
--color-reference: #404040;
--color-reference-text: #ffffff;
--color-reference-background: #0b0e12;
--modal-background: #1f1f1f;
--color-highlight: rgba(0, 0, 0, 1);
--color-background-highlight: rgba(255, 255, 0, 0.9);
--color-background-highlight-accent: rgba(255, 150, 50, 0.9);
--navbar-background-mac: rgba(20, 20, 20, 0.55);
--navbar-background: #1f1f1f;
--navbar-height: 40px;
--sidebar-width: 50px;
--status-bar-height: 40px;
--input-bar-height: 100px;
--assistants-width: 275px;
--topic-list-width: 275px;
--settings-width: 250px;
--chat-background: #111111;
--chat-background-user: #28b561;
--chat-background-assistant: #2c2c2c;
--chat-text-user: var(--color-black);
--list-item-border-radius: 16px;
}
[theme-mode='light'] {
--color-white: #ffffff;
--color-white-soft: rgba(0, 0, 0, 0.04);
--color-white-mute: #eee;
--color-black: #1b1b1f;
--color-black-soft: #262626;
--color-black-mute: #363636;
--color-gray-1: #8e8e93;
--color-gray-2: #aeaeb2;
--color-gray-3: #c7c7cc;
--color-text-1: rgba(0, 0, 0, 1);
--color-text-2: rgba(0, 0, 0, 0.6);
--color-text-3: rgba(0, 0, 0, 0.38);
--color-background: var(--color-white);
--color-background-soft: var(--color-white-soft);
--color-background-mute: var(--color-white-mute);
--color-background-opacity: rgba(235, 235, 235, 0.7);
--inner-glow-opacity: 0.1;
--color-primary: #00b96b;
--color-primary-soft: #00b96b99;
--color-primary-mute: #00b96b33;
--color-text: var(--color-text-1);
--color-icon: #00000099;
--color-icon-white: #000000;
--color-border: #00000019;
--color-border-soft: #00000010;
--color-border-mute: #00000005;
--color-error: #f44336;
--color-link: #1677ff;
--color-code-background: #e3e3e3;
--color-hover: var(--color-white-mute);
--color-active: var(--color-white-soft);
--color-frame-border: #ddd;
--color-group-background: var(--color-white);
--color-reference: #cfe1ff;
--color-reference-text: #000000;
--color-reference-background: #f1f7ff;
--modal-background: var(--color-white);
--color-highlight: initial;
--color-background-highlight: rgba(255, 255, 0, 0.5);
--color-background-highlight-accent: rgba(255, 150, 50, 0.5);
--navbar-background-mac: rgba(255, 255, 255, 0.55);
--navbar-background: rgba(244, 244, 244);
--chat-background: #f3f3f3;
--chat-background-user: #95ec69;
--chat-background-assistant: #ffffff;
--chat-text-user: var(--color-text);
}

View File

@@ -0,0 +1,13 @@
:root {
--font-family:
Ubuntu, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Roboto, Oxygen, Cantarell, 'Open Sans',
'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
--font-family-serif:
-apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Ubuntu, Roboto, Oxygen, Cantarell, 'Open Sans',
'Helvetica Neue', serif, Arial, 'Noto Sans', 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
--code-font-family: 'Cascadia Code', 'Fira Code', 'Consolas', Menlo, Courier, monospace;
}

View File

@@ -1,3 +1,5 @@
@use './color.scss';
@use './font.scss';
@use './markdown.scss';
@use './ant.scss';
@use './scrollbar.scss';
@@ -6,136 +8,6 @@
@import '../fonts/icon-fonts/iconfont.css';
@import '../fonts/ubuntu/ubuntu.css';
:root {
--color-white: #ffffff;
--color-white-soft: rgba(255, 255, 255, 0.8);
--color-white-mute: rgba(255, 255, 255, 0.94);
--color-black: #181818;
--color-black-soft: #222222;
--color-black-mute: #333333;
--color-gray-1: #515c67;
--color-gray-2: #414853;
--color-gray-3: #32363f;
--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);
--color-background: var(--color-black);
--color-background-soft: var(--color-black-soft);
--color-background-mute: var(--color-black-mute);
--color-background-opacity: rgba(34, 34, 34, 0.7);
--inner-glow-opacity: 0.3; // For the glassmorphism effect in the dropdown menu
--color-primary: #00b96b;
--color-primary-soft: #00b96b99;
--color-primary-mute: #00b96b33;
--color-text: var(--color-text-1);
--color-icon: #ffffff99;
--color-icon-white: #ffffff;
--color-border: #ffffff19;
--color-border-soft: #ffffff10;
--color-border-mute: #ffffff05;
--color-error: #f44336;
--color-link: #338cff;
--color-code-background: #323232;
--color-hover: rgba(40, 40, 40, 1);
--color-active: rgba(55, 55, 55, 1);
--color-frame-border: #333;
--color-group-background: var(--color-background-soft);
--color-reference: #404040;
--color-reference-text: #ffffff;
--color-reference-background: #0b0e12;
--modal-background: #1f1f1f;
--navbar-background-mac: rgba(20, 20, 20, 0.55);
--navbar-background: #1f1f1f;
--navbar-height: 40px;
--sidebar-width: 50px;
--status-bar-height: 40px;
--input-bar-height: 100px;
--assistants-width: 275px;
--topic-list-width: 275px;
--settings-width: 250px;
--chat-background: #111111;
--chat-background-user: #28b561;
--chat-background-assistant: #2c2c2c;
--chat-text-user: var(--color-black);
--list-item-border-radius: 16px;
}
body {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
body[theme-mode='light'] {
--color-white: #ffffff;
--color-white-soft: rgba(0, 0, 0, 0.04);
--color-white-mute: #eee;
--color-black: #1b1b1f;
--color-black-soft: #262626;
--color-black-mute: #363636;
--color-gray-1: #8e8e93;
--color-gray-2: #aeaeb2;
--color-gray-3: #c7c7cc;
--color-text-1: rgba(0, 0, 0, 1);
--color-text-2: rgba(0, 0, 0, 0.6);
--color-text-3: rgba(0, 0, 0, 0.38);
--color-background: var(--color-white);
--color-background-soft: var(--color-white-soft);
--color-background-mute: var(--color-white-mute);
--color-background-opacity: rgba(235, 235, 235, 0.7);
--inner-glow-opacity: 0.1;
--color-primary: #00b96b;
--color-primary-soft: #00b96b99;
--color-primary-mute: #00b96b33;
--color-text: var(--color-text-1);
--color-icon: #00000099;
--color-icon-white: #000000;
--color-border: #00000019;
--color-border-soft: #00000010;
--color-border-mute: #00000005;
--color-error: #f44336;
--color-link: #1677ff;
--color-code-background: #e3e3e3;
--color-hover: var(--color-white-mute);
--color-active: var(--color-white-soft);
--color-frame-border: #ddd;
--color-group-background: var(--color-white);
--color-reference: #cfe1ff;
--color-reference-text: #000000;
--color-reference-background: #f1f7ff;
--modal-background: var(--color-white);
--navbar-background-mac: rgba(255, 255, 255, 0.55);
--navbar-background: rgba(244, 244, 244);
--chat-background: #f3f3f3;
--chat-background-user: #95ec69;
--chat-background-assistant: #ffffff;
--chat-text-user: var(--color-text);
}
*,
*::before,
*::after {
@@ -152,8 +24,18 @@ body[theme-mode='light'] {
-webkit-tap-highlight-color: transparent;
}
ul {
list-style: none;
html,
body,
#root {
height: 100%;
width: 100%;
margin: 0;
}
#root {
display: flex;
flex-direction: row;
flex: 1;
}
body {
@@ -163,13 +45,17 @@ body {
font-size: 14px;
line-height: 1.6;
overflow: hidden;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue',
sans-serif;
font-family: var(--font-family);
text-rendering: optimizeLegibility;
transition: background-color 0.3s linear;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
transition: background-color 0.3s linear;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
input,
@@ -190,20 +76,8 @@ a {
-webkit-user-drag: none;
}
html,
body,
#root {
height: 100%;
width: 100%;
margin: 0;
}
#root {
width: 100%;
height: 100%;
display: flex;
flex-direction: row;
flex: 1;
ul {
list-style: none;
}
.loader {
@@ -291,3 +165,11 @@ body,
.lucide {
color: var(--color-icon);
}
span.highlight {
background-color: var(--color-background-highlight);
color: var(--color-highlight);
}
span.highlight.selected {
background-color: var(--color-background-highlight-accent);
}

View File

@@ -20,10 +20,8 @@
h5,
h6 {
margin: 1em 0 1em 0;
font-weight: 800;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue',
sans-serif;
font-weight: bold;
font-family: var(--font-family);
}
h1 {
@@ -117,7 +115,7 @@
}
code {
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
font-family: var(--code-font-family);
}
pre {
@@ -125,7 +123,9 @@
overflow-x: auto;
font-family: 'Fira Code', 'Courier New', Courier, monospace;
background-color: var(--color-background-mute);
&:has(> .mermaid) {
&:has(.mermaid),
&:has(.plantuml-preview),
&:has(.svg-preview) {
background-color: transparent;
}
&:not(pre pre) {
@@ -153,7 +153,7 @@
padding-left: 1em;
color: var(--color-text-light);
border-left: 4px solid var(--color-border);
font-family: Georgia, 'Times New Roman', Times, serif;
font-family: var(--font-family);
}
table {
@@ -171,9 +171,7 @@
th {
background-color: var(--color-background-mute);
font-weight: bold;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue',
sans-serif;
font-family: var(--font-family);
}
img {
@@ -304,3 +302,25 @@ emoji-picker {
mjx-container {
overflow-x: auto;
}
/* CodeMirror 相关样式 */
.cm-editor {
.cm-scroller {
font-family: var(--code-font-family);
padding: 1px;
border-radius: 5px;
.cm-gutters {
line-height: 1.6;
}
.cm-content {
line-height: 1.6;
padding-left: 0.25em;
}
.cm-lineWrapping * {
word-wrap: break-word;
}
}
}

View File

@@ -0,0 +1,32 @@
import { Alert } from 'antd'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
const LOCALSTORAGE_KEY = 'openai_alert_closed'
const OpenAIAlert = () => {
const { t } = useTranslation()
const [visible, setVisible] = useState(false)
useEffect(() => {
const closed = localStorage.getItem(LOCALSTORAGE_KEY)
setVisible(!closed)
}, [])
if (!visible) return null
return (
<Alert
style={{ width: '100%', marginTop: 5, marginBottom: 5 }}
message={t('settings.provider.openai.alert')}
closable
afterClose={() => {
localStorage.setItem(LOCALSTORAGE_KEY, '1')
setVisible(false)
}}
type="warning"
/>
)
}
export default OpenAIAlert

View File

@@ -0,0 +1,312 @@
import { TOOL_SPECS, useCodeToolbar } from '@renderer/components/CodeToolbar'
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
import { useSettings } from '@renderer/hooks/useSettings'
import { uuid } from '@renderer/utils'
import { getReactStyleFromToken } from '@renderer/utils/shiki'
import { ChevronsDownUp, ChevronsUpDown, Text as UnWrapIcon, WrapText as WrapIcon } from 'lucide-react'
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ThemedToken } from 'shiki/core'
import styled from 'styled-components'
interface CodePreviewProps {
children: string
language: string
}
/**
* Shiki 流式代码高亮组件
*
* - 通过 shiki tokenizer 处理流式响应
* - 为了正确执行语法高亮,必须保证流式响应都依次到达 tokenizer不能跳过
*/
const CodePreview = ({ children, language }: CodePreviewProps) => {
const { codeShowLineNumbers, fontSize, codeCollapsible, codeWrappable } = useSettings()
const { activeShikiTheme, highlightCodeChunk, cleanupTokenizers } = useCodeStyle()
const [isExpanded, setIsExpanded] = useState(!codeCollapsible)
const [isUnwrapped, setIsUnwrapped] = useState(!codeWrappable)
const [tokenLines, setTokenLines] = useState<ThemedToken[][]>([])
const codeContentRef = useRef<HTMLDivElement>(null)
const prevCodeLengthRef = useRef(0)
const safeCodeStringRef = useRef(children)
const highlightQueueRef = useRef<Promise<void>>(Promise.resolve())
const callerId = useRef(`${Date.now()}-${uuid()}`).current
const shikiThemeRef = useRef(activeShikiTheme)
const { t } = useTranslation()
const { registerTool, removeTool } = useCodeToolbar()
// 展开/折叠工具
useEffect(() => {
registerTool({
...TOOL_SPECS.expand,
icon: isExpanded ? <ChevronsDownUp className="icon" /> : <ChevronsUpDown className="icon" />,
tooltip: isExpanded ? t('code_block.collapse') : t('code_block.expand'),
visible: () => {
const scrollHeight = codeContentRef.current?.scrollHeight
return codeCollapsible && (scrollHeight ?? 0) > 350
},
onClick: () => setIsExpanded((prev) => !prev)
})
return () => removeTool(TOOL_SPECS.expand.id)
}, [codeCollapsible, isExpanded, registerTool, removeTool, t])
// 自动换行工具
useEffect(() => {
registerTool({
...TOOL_SPECS.wrap,
icon: isUnwrapped ? <WrapIcon className="icon" /> : <UnWrapIcon className="icon" />,
tooltip: isUnwrapped ? t('code_block.wrap.on') : t('code_block.wrap.off'),
visible: () => codeWrappable,
onClick: () => setIsUnwrapped((prev) => !prev)
})
return () => removeTool(TOOL_SPECS.wrap.id)
}, [codeWrappable, isUnwrapped, registerTool, removeTool, t])
// 更新展开状态
useEffect(() => {
setIsExpanded(!codeCollapsible)
}, [codeCollapsible])
// 更新换行状态
useEffect(() => {
setIsUnwrapped(!codeWrappable)
}, [codeWrappable])
// 处理尾部空白字符
const safeCodeString = useMemo(() => {
return typeof children === 'string' ? children.trimEnd() : ''
}, [children])
const highlightCode = useCallback(async () => {
if (!safeCodeString) return
if (prevCodeLengthRef.current === safeCodeString.length) return
// 捕获当前状态
const startPos = prevCodeLengthRef.current
const endPos = safeCodeString.length
// 添加到处理队列,确保按顺序处理
highlightQueueRef.current = highlightQueueRef.current.then(async () => {
// FIXME: 长度有问题,或者破坏了流式内容,需要清理 tokenizer 并使用完整代码重新高亮
if (prevCodeLengthRef.current > safeCodeString.length || !safeCodeString.startsWith(safeCodeStringRef.current)) {
cleanupTokenizers(callerId)
prevCodeLengthRef.current = 0
safeCodeStringRef.current = ''
const result = await highlightCodeChunk(safeCodeString, language, callerId)
setTokenLines(result.lines)
prevCodeLengthRef.current = safeCodeString.length
safeCodeStringRef.current = safeCodeString
return
}
// 跳过 race condition延迟到后续任务
if (prevCodeLengthRef.current !== startPos) {
return
}
const incrementalCode = safeCodeString.slice(startPos, endPos)
const result = await highlightCodeChunk(incrementalCode, language, callerId)
setTokenLines((lines) => [...lines.slice(0, Math.max(0, lines.length - result.recall)), ...result.lines])
prevCodeLengthRef.current = endPos
safeCodeStringRef.current = safeCodeString
})
}, [callerId, cleanupTokenizers, highlightCodeChunk, language, safeCodeString])
// 主题变化时强制重新高亮
useEffect(() => {
if (shikiThemeRef.current !== activeShikiTheme) {
prevCodeLengthRef.current++
shikiThemeRef.current = activeShikiTheme
}
}, [activeShikiTheme])
// 组件卸载时清理资源
useEffect(() => {
return () => cleanupTokenizers(callerId)
}, [callerId, cleanupTokenizers])
// 处理第二次开始的代码高亮
useEffect(() => {
if (prevCodeLengthRef.current > 0) {
setTimeout(highlightCode, 0)
}
}, [highlightCode])
// 视口检测逻辑,只处理第一次代码高亮
useEffect(() => {
const codeElement = codeContentRef.current
if (!codeElement || prevCodeLengthRef.current > 0) return
let isMounted = true
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && isMounted) {
setTimeout(highlightCode, 0)
observer.disconnect()
}
})
observer.observe(codeElement)
return () => {
isMounted = false
observer.disconnect()
}
}, [highlightCode])
const hasHighlightedCode = useMemo(() => {
return tokenLines.length > 0
}, [tokenLines.length])
return (
<ContentContainer
ref={codeContentRef}
$lineNumbers={codeShowLineNumbers}
$wrap={codeWrappable && !isUnwrapped}
style={{
fontSize: fontSize - 1,
maxHeight: codeCollapsible && !isExpanded ? '350px' : 'none'
}}>
{hasHighlightedCode ? (
<div className="fade-in-effect">
<ShikiTokensRenderer language={language} tokenLines={tokenLines} />
</div>
) : (
<CodePlaceholder>{children}</CodePlaceholder>
)}
</ContentContainer>
)
}
/**
* 渲染 Shiki 高亮后的 tokens
*
* 独立出来,方便将来做 virtual list
*/
const ShikiTokensRenderer: React.FC<{ language: string; tokenLines: ThemedToken[][] }> = memo(
({ language, tokenLines }) => {
const { getShikiPreProperties } = useCodeStyle()
const rendererRef = useRef<HTMLPreElement>(null)
// 设置 pre 标签属性
useEffect(() => {
getShikiPreProperties(language).then((properties) => {
const pre = rendererRef.current
if (pre) {
pre.className = properties.class
pre.style.cssText = properties.style
pre.tabIndex = properties.tabindex
}
})
}, [language, getShikiPreProperties])
return (
<pre className="shiki" ref={rendererRef}>
<code>
{tokenLines.map((lineTokens, lineIndex) => (
<span key={`line-${lineIndex}`} className="line">
{lineTokens.map((token, tokenIndex) => (
<span key={`token-${tokenIndex}`} style={getReactStyleFromToken(token)}>
{token.content}
</span>
))}
</span>
))}
</code>
</pre>
)
}
)
const ContentContainer = styled.div<{
$lineNumbers: boolean
$wrap: boolean
}>`
position: relative;
overflow: auto;
display: flex;
flex-direction: column;
border: 0.5px solid transparent;
border-radius: 5px;
margin-top: 0;
::-webkit-scrollbar-thumb {
border-radius: 10px;
}
.shiki {
padding: 1em;
code {
display: flex;
flex-direction: column;
width: 100%;
.line {
display: block;
min-height: 1.3rem;
padding-left: ${(props) => (props.$lineNumbers ? '2rem' : '0')};
* {
word-wrap: ${(props) => (props.$wrap ? 'break-word' : undefined)};
white-space: ${(props) => (props.$wrap ? 'pre-wrap' : 'pre')};
}
}
}
}
${(props) =>
props.$lineNumbers &&
`
code {
counter-reset: step;
counter-increment: step 0;
position: relative;
}
code .line::before {
content: counter(step);
counter-increment: step;
width: 1rem;
position: absolute;
left: 0;
text-align: right;
opacity: 0.35;
}
`}
@keyframes contentFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.fade-in-effect {
animation: contentFadeIn 0.3s ease-in-out forwards;
}
`
const CodePlaceholder = styled.div`
opacity: 0.1;
flex-direction: column;
white-space: pre-wrap;
word-break: break-all;
overflow-x: hidden;
display: block;
min-height: 1.3rem;
`
CodePreview.displayName = 'CodePreview'
export default memo(CodePreview)

View File

@@ -1,4 +1,4 @@
import { DownloadOutlined, ExpandOutlined, LinkOutlined } from '@ant-design/icons'
import { ExpandOutlined, LinkOutlined } from '@ant-design/icons'
import { AppLogo } from '@renderer/config/env'
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
import { extractTitle } from '@renderer/utils/formats'
@@ -13,7 +13,6 @@ interface Props {
const Artifacts: FC<Props> = ({ html }) => {
const { t } = useTranslation()
const title = extractTitle(html) || 'Artifacts ' + t('chat.artifacts.button.preview')
const { openMinapp } = useMinappPopup()
/**
@@ -23,6 +22,7 @@ const Artifacts: FC<Props> = ({ html }) => {
const path = await window.api.file.create('artifacts-preview.html')
await window.api.file.write(path, html)
const filePath = `file://${path}`
const title = extractTitle(html) || 'Artifacts ' + t('chat.artifacts.button.preview')
openMinapp({
id: 'artifacts-preview',
name: title,
@@ -46,13 +46,6 @@ const Artifacts: FC<Props> = ({ html }) => {
}
}
/**
*
*/
const onDownload = () => {
window.api.file.save(`${title}.html`, html)
}
return (
<Container>
<Button icon={<ExpandOutlined />} onClick={handleOpenInApp}>
@@ -62,10 +55,6 @@ const Artifacts: FC<Props> = ({ html }) => {
<Button icon={<LinkOutlined />} onClick={handleOpenExternal}>
{t('chat.artifacts.button.openExternal')}
</Button>
<Button icon={<DownloadOutlined />} onClick={onDownload}>
{t('chat.artifacts.button.download')}
</Button>
</Container>
)
}

View File

@@ -0,0 +1,99 @@
import { nanoid } from '@reduxjs/toolkit'
import { usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
import { useMermaid } from '@renderer/hooks/useMermaid'
import { Flex } from 'antd'
import React, { memo, startTransition, useCallback, useEffect, useRef, useState } from 'react'
import styled from 'styled-components'
interface Props {
children: string
}
const MermaidPreview: React.FC<Props> = ({ children }) => {
const { mermaid, isLoading, error: mermaidError } = useMermaid()
const mermaidRef = useRef<HTMLDivElement>(null)
const [error, setError] = useState<string | null>(null)
const diagramId = useRef<string>(`mermaid-${nanoid(6)}`).current
const errorTimeoutRef = useRef<NodeJS.Timeout | null>(null)
// 使用通用图像工具
const { handleZoom, handleCopyImage, handleDownload } = usePreviewToolHandlers(mermaidRef, {
imgSelector: 'svg',
prefix: 'mermaid',
enableWheelZoom: true
})
// 使用工具栏
usePreviewTools({
handleZoom,
handleCopyImage,
handleDownload
})
const render = useCallback(async () => {
try {
if (!children) return
// 验证语法,提前抛出异常
await mermaid.parse(children)
if (!mermaidRef.current) return
const { svg } = await mermaid.render(diagramId, children, mermaidRef.current)
// 避免不可见时产生 undefined 和 NaN
const fixedSvg = svg.replace(/translate\(undefined,\s*NaN\)/g, 'translate(0, 0)')
mermaidRef.current.innerHTML = fixedSvg
// 没有语法错误时清除错误记录和定时器
setError(null)
if (errorTimeoutRef.current) {
clearTimeout(errorTimeoutRef.current)
errorTimeoutRef.current = null
}
} catch (error) {
// 延迟显示错误
if (errorTimeoutRef.current) clearTimeout(errorTimeoutRef.current)
errorTimeoutRef.current = setTimeout(() => {
setError((error as Error).message)
}, 500)
}
}, [children, diagramId, mermaid])
// 渲染Mermaid图表
useEffect(() => {
if (isLoading) return
startTransition(render)
// 清理定时器
return () => {
if (errorTimeoutRef.current) {
clearTimeout(errorTimeoutRef.current)
errorTimeoutRef.current = null
}
}
}, [isLoading, render])
return (
<Flex vertical>
{(mermaidError || error) && <StyledError>{mermaidError || error}</StyledError>}
<StyledMermaid ref={mermaidRef} className="mermaid" />
</Flex>
)
}
const StyledMermaid = styled.div`
overflow: auto;
`
const StyledError = styled.div`
overflow: auto;
padding: 16px;
color: #ff4d4f;
border: 1px solid #ff4d4f;
border-radius: 4px;
word-wrap: break-word;
white-space: pre-wrap;
`
export default memo(MermaidPreview)

View File

@@ -0,0 +1,193 @@
import { LoadingOutlined } from '@ant-design/icons'
import { usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
import { Spin } from 'antd'
import pako from 'pako'
import React, { memo, useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
const PlantUMLServer = 'https://www.plantuml.com/plantuml'
function encode64(data: Uint8Array) {
let r = ''
for (let i = 0; i < data.length; i += 3) {
if (i + 2 === data.length) {
r += append3bytes(data[i], data[i + 1], 0)
} else if (i + 1 === data.length) {
r += append3bytes(data[i], 0, 0)
} else {
r += append3bytes(data[i], data[i + 1], data[i + 2])
}
}
return r
}
function encode6bit(b: number) {
if (b < 10) {
return String.fromCharCode(48 + b)
}
b -= 10
if (b < 26) {
return String.fromCharCode(65 + b)
}
b -= 26
if (b < 26) {
return String.fromCharCode(97 + b)
}
b -= 26
if (b === 0) {
return '-'
}
if (b === 1) {
return '_'
}
return '?'
}
function append3bytes(b1: number, b2: number, b3: number) {
const c1 = b1 >> 2
const c2 = ((b1 & 0x3) << 4) | (b2 >> 4)
const c3 = ((b2 & 0xf) << 2) | (b3 >> 6)
const c4 = b3 & 0x3f
let r = ''
r += encode6bit(c1 & 0x3f)
r += encode6bit(c2 & 0x3f)
r += encode6bit(c3 & 0x3f)
r += encode6bit(c4 & 0x3f)
return r
}
/**
* https://plantuml.com/zh/code-javascript-synchronous
* To use PlantUML image generation, a text diagram description have to be :
1. Encoded in UTF-8
2. Compressed using Deflate algorithm
3. Reencoded in ASCII using a transformation _close_ to base64
*/
function encodeDiagram(diagram: string): string {
const utf8text = new TextEncoder().encode(diagram)
const compressed = pako.deflateRaw(utf8text)
return encode64(compressed)
}
async function downloadUrl(url: string, filename: string) {
const response = await fetch(url)
if (!response.ok) {
window.message.warning({ content: response.statusText, duration: 1.5 })
return
}
const blob = await response.blob()
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(link.href)
}
type PlantUMLServerImageProps = {
format: 'png' | 'svg'
diagram: string
onClick?: React.MouseEventHandler<HTMLDivElement>
className?: string
}
function getPlantUMLImageUrl(format: 'png' | 'svg', diagram: string, isDark?: boolean) {
const encodedDiagram = encodeDiagram(diagram)
if (isDark) {
return `${PlantUMLServer}/d${format}/${encodedDiagram}`
}
return `${PlantUMLServer}/${format}/${encodedDiagram}`
}
const PlantUMLServerImage: React.FC<PlantUMLServerImageProps> = ({ format, diagram, onClick, className }) => {
const [loading, setLoading] = useState(true)
// FIXME: 黑暗模式背景太黑了,目前让 PlantUML 和 SVG 一样保持白色背景
const url = getPlantUMLImageUrl(format, diagram, false)
return (
<StyledPlantUML onClick={onClick} className={className}>
<Spin
spinning={loading}
indicator={
<LoadingOutlined
spin
style={{
fontSize: 32
}}
/>
}>
<img
src={url}
onLoad={() => {
setLoading(false)
}}
onError={(e) => {
setLoading(false)
const target = e.target as HTMLImageElement
target.style.opacity = '0.5'
target.style.filter = 'blur(2px)'
}}
/>
</Spin>
</StyledPlantUML>
)
}
interface PlantUMLProps {
children: string
}
const PlantUmlPreview: React.FC<PlantUMLProps> = ({ children }) => {
const { t } = useTranslation()
const containerRef = useRef<HTMLDivElement>(null)
const encodedDiagram = encodeDiagram(children)
// 自定义 PlantUML 下载方法
const customDownload = useCallback(
(format: 'svg' | 'png') => {
const timestamp = Date.now()
const url = `${PlantUMLServer}/${format}/${encodedDiagram}`
const filename = `plantuml-diagram-${timestamp}.${format}`
downloadUrl(url, filename).catch(() => {
window.message.error(t('code_block.download.failed.network'))
})
},
[encodedDiagram, t]
)
// 使用通用图像工具,提供自定义下载方法
const { handleZoom, handleCopyImage } = usePreviewToolHandlers(containerRef, {
imgSelector: '.plantuml-preview img',
prefix: 'plantuml-diagram',
enableWheelZoom: true,
customDownloader: customDownload
})
// 使用工具栏
usePreviewTools({
handleZoom,
handleCopyImage,
handleDownload: customDownload
})
return (
<div ref={containerRef}>
<PlantUMLServerImage format="svg" diagram={children} className="plantuml-preview" />
</div>
)
}
const StyledPlantUML = styled.div`
max-height: calc(80vh - 100px);
text-align: left;
overflow-y: auto;
background-color: white;
img {
max-width: 100%;
height: auto;
min-height: 100px;
transition: transform 0.2s ease;
}
`
export default memo(PlantUmlPreview)

View File

@@ -0,0 +1,22 @@
import { FC, memo } from 'react'
import styled from 'styled-components'
interface Props {
children: string
}
const StatusBar: FC<Props> = ({ children }) => {
return <Container>{children}</Container>
}
const Container = styled.div`
margin: 10px;
display: flex;
flex-direction: row;
gap: 8px;
padding-bottom: 10px;
overflow-y: auto;
text-wrap: wrap;
`
export default memo(StatusBar)

View File

@@ -0,0 +1,38 @@
import { usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
import { memo, useRef } from 'react'
import styled from 'styled-components'
interface Props {
children: string
}
const SvgPreview: React.FC<Props> = ({ children }) => {
const svgContainerRef = useRef<HTMLDivElement>(null)
// 使用通用图像工具
const { handleCopyImage, handleDownload } = usePreviewToolHandlers(svgContainerRef, {
imgSelector: '.svg-preview svg',
prefix: 'svg-image'
})
// 使用工具栏
usePreviewTools({
handleCopyImage,
handleDownload
})
return (
<SvgPreviewContainer ref={svgContainerRef} className="svg-preview" dangerouslySetInnerHTML={{ __html: children }} />
)
}
const SvgPreviewContainer = styled.div`
padding: 1em;
background-color: white;
overflow: auto;
border: 0.5px solid var(--color-code-background);
border-top-left-radius: 0;
border-top-right-radius: 0;
`
export default memo(SvgPreview)

View File

@@ -0,0 +1,304 @@
import { LoadingOutlined } from '@ant-design/icons'
import CodeEditor from '@renderer/components/CodeEditor'
import { CodeToolbar, CodeToolContext, TOOL_SPECS, useCodeToolbar } from '@renderer/components/CodeToolbar'
import { useSettings } from '@renderer/hooks/useSettings'
import { pyodideService } from '@renderer/services/PyodideService'
import { extractTitle } from '@renderer/utils/formats'
import { isValidPlantUML } from '@renderer/utils/markdown'
import dayjs from 'dayjs'
import { CirclePlay, CodeXml, Copy, Download, Eye, Square, SquarePen, SquareSplitHorizontal } from 'lucide-react'
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import CodePreview from './CodePreview'
import HtmlArtifacts from './HtmlArtifacts'
import MermaidPreview from './MermaidPreview'
import PlantUmlPreview from './PlantUmlPreview'
import StatusBar from './StatusBar'
import SvgPreview from './SvgPreview'
type ViewMode = 'source' | 'special' | 'split'
interface Props {
children: string
language: string
onSave?: (newContent: string) => void
}
/**
* 代码块视图
*
* 视图类型:
* - preview: 预览视图,其中非源代码的是特殊视图
* - edit: 编辑视图
*
* 视图模式:
* - source: 源代码视图模式
* - special: 特殊视图模式Mermaid、PlantUML、SVG
* - split: 分屏模式(源代码和特殊视图并排显示)
*
* 顶部 sticky 工具栏:
* - quick 工具
* - core 工具
*/
const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
const { t } = useTranslation()
const { codeEditor, codeExecution } = useSettings()
const [viewMode, setViewMode] = useState<ViewMode>('special')
const [isRunning, setIsRunning] = useState(false)
const [output, setOutput] = useState('')
const isExecutable = useMemo(() => {
return codeExecution.enabled && language === 'python'
}, [codeExecution.enabled, language])
const hasSpecialView = useMemo(() => ['mermaid', 'plantuml', 'svg'].includes(language), [language])
const isInSpecialView = useMemo(() => {
return hasSpecialView && viewMode === 'special'
}, [hasSpecialView, viewMode])
const { updateContext, registerTool, removeTool } = useCodeToolbar()
useEffect(() => {
updateContext({
code: children,
language
})
}, [children, language, updateContext])
const handleCopySource = useCallback(
(ctx?: CodeToolContext) => {
if (!ctx) return
navigator.clipboard.writeText(ctx.code)
window.message.success({ content: t('code_block.copy.success'), key: 'copy-code' })
},
[t]
)
const handleDownloadSource = useCallback((ctx?: CodeToolContext) => {
if (!ctx) return
const { code, language } = ctx
let fileName = ''
// 尝试提取标题
if (language === 'html' && code.includes('</html>')) {
const title = extractTitle(code)
if (title) {
fileName = `${title}.html`
}
}
// 默认使用日期格式命名
if (!fileName) {
fileName = `${dayjs().format('YYYYMMDDHHmm')}.${language}`
}
window.api.file.save(fileName, code)
}, [])
const handleRunScript = useCallback(
(ctx?: CodeToolContext) => {
if (!ctx) return
setIsRunning(true)
setOutput('')
pyodideService
.runScript(ctx.code, {}, codeExecution.timeoutMinutes * 60000)
.then((formattedOutput) => {
setOutput(formattedOutput)
})
.catch((error) => {
console.error('Unexpected error:', error)
setOutput(`Unexpected error: ${error.message || 'Unknown error'}`)
})
.finally(() => {
setIsRunning(false)
})
},
[codeExecution.timeoutMinutes]
)
useEffect(() => {
// 复制按钮
registerTool({
...TOOL_SPECS.copy,
icon: <Copy className="icon" />,
tooltip: t('code_block.copy.source'),
onClick: handleCopySource
})
// 下载按钮
registerTool({
...TOOL_SPECS.download,
icon: <Download className="icon" />,
tooltip: t('code_block.download.source'),
onClick: handleDownloadSource
})
return () => {
removeTool(TOOL_SPECS.copy.id)
removeTool(TOOL_SPECS.download.id)
}
}, [handleCopySource, handleDownloadSource, registerTool, removeTool, t])
// 特殊视图的编辑按钮,在分屏模式下不可用
useEffect(() => {
if (!hasSpecialView || viewMode === 'split') return
const viewSourceToolSpec = codeEditor.enabled ? TOOL_SPECS.edit : TOOL_SPECS['view-source']
if (codeEditor.enabled) {
registerTool({
...viewSourceToolSpec,
icon: viewMode === 'source' ? <Eye className="icon" /> : <SquarePen className="icon" />,
tooltip: viewMode === 'source' ? t('code_block.preview') : t('code_block.edit'),
onClick: () => setViewMode(viewMode === 'source' ? 'special' : 'source')
})
} else {
registerTool({
...viewSourceToolSpec,
icon: viewMode === 'source' ? <Eye className="icon" /> : <CodeXml className="icon" />,
tooltip: viewMode === 'source' ? t('code_block.preview') : t('code_block.preview.source'),
onClick: () => setViewMode(viewMode === 'source' ? 'special' : 'source')
})
}
return () => removeTool(viewSourceToolSpec.id)
}, [codeEditor.enabled, hasSpecialView, viewMode, registerTool, removeTool, t])
// 特殊视图的分屏按钮
useEffect(() => {
if (!hasSpecialView) return
registerTool({
...TOOL_SPECS['split-view'],
icon: viewMode === 'split' ? <Square className="icon" /> : <SquareSplitHorizontal className="icon" />,
tooltip: viewMode === 'split' ? t('code_block.split.restore') : t('code_block.split'),
onClick: () => setViewMode(viewMode === 'split' ? 'special' : 'split')
})
return () => removeTool(TOOL_SPECS['split-view'].id)
}, [hasSpecialView, viewMode, registerTool, removeTool, t])
// 运行按钮
useEffect(() => {
if (!isExecutable) return
registerTool({
...TOOL_SPECS.run,
icon: isRunning ? <LoadingOutlined /> : <CirclePlay className="icon" />,
tooltip: t('code_block.run'),
onClick: (ctx) => !isRunning && handleRunScript(ctx)
})
return () => isExecutable && removeTool(TOOL_SPECS.run.id)
}, [isExecutable, isRunning, handleRunScript, registerTool, removeTool, t])
// 源代码视图组件
const sourceView = useMemo(() => {
if (codeEditor.enabled) {
return <CodeEditor value={children} language={language} onSave={onSave} options={{ stream: true }} />
} else {
return <CodePreview language={language}>{children}</CodePreview>
}
}, [children, codeEditor.enabled, language, onSave])
// 特殊视图组件映射
const specialView = useMemo(() => {
if (language === 'mermaid') {
return <MermaidPreview>{children}</MermaidPreview>
} else if (language === 'plantuml' && isValidPlantUML(children)) {
return <PlantUmlPreview>{children}</PlantUmlPreview>
} else if (language === 'svg') {
return <SvgPreview>{children}</SvgPreview>
}
return null
}, [children, language])
const renderHeader = useMemo(() => {
const langTag = '<' + language.toUpperCase() + '>'
return <CodeHeader $isInSpecialView={isInSpecialView}>{isInSpecialView ? '' : langTag}</CodeHeader>
}, [isInSpecialView, language])
// 根据视图模式和语言选择组件优先展示特殊视图fallback是源代码视图
const renderContent = useMemo(() => {
const showSpecialView = specialView && ['special', 'split'].includes(viewMode)
const showSourceView = !specialView || viewMode !== 'special'
return (
<SplitViewWrapper className="split-view-wrapper">
{showSpecialView && specialView}
{showSourceView && sourceView}
</SplitViewWrapper>
)
}, [specialView, sourceView, viewMode])
const renderArtifacts = useMemo(() => {
if (language === 'html') {
return <HtmlArtifacts html={children} />
}
return null
}, [children, language])
return (
<CodeBlockWrapper className="code-block" $isInSpecialView={isInSpecialView}>
{renderHeader}
<CodeToolbar />
{renderContent}
{renderArtifacts}
{isExecutable && output && <StatusBar>{output}</StatusBar>}
</CodeBlockWrapper>
)
}
const CodeBlockWrapper = styled.div<{ $isInSpecialView: boolean }>`
position: relative;
.code-toolbar {
margin-top: ${(props) => (props.$isInSpecialView ? '20px' : '0')};
background-color: ${(props) => (props.$isInSpecialView ? 'transparent' : 'var(--color-background-mute)')};
border-radius: ${(props) => (props.$isInSpecialView ? '0' : '4px')};
opacity: 0;
transition: opacity 0.2s ease;
transform: translateZ(0);
will-change: opacity;
&.show {
opacity: 1;
}
}
&:hover {
.code-toolbar {
opacity: 1;
}
}
`
const CodeHeader = styled.div<{ $isInSpecialView: boolean }>`
display: flex;
align-items: center;
justify-content: space-between;
color: var(--color-text);
font-size: 14px;
font-weight: bold;
padding: 0 10px;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
height: ${(props) => (props.$isInSpecialView ? '16px' : '34px')};
`
const SplitViewWrapper = styled.div`
display: flex;
width: 100%;
> * {
flex: 1 1 0;
width: 0;
min-width: 0;
max-width: 100%;
}
`
export default memo(CodeBlockView)

View File

@@ -0,0 +1,65 @@
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
import { Extension } from '@uiw/react-codemirror'
import { useEffect, useState } from 'react'
let linterPromise: Promise<any> | null = null
function importLintPackage() {
if (!linterPromise) {
linterPromise = import('@codemirror/lint').then((mod) => mod.linter)
}
return linterPromise
}
// 语言对应的 linter 加载器
const linterLoaders: Record<string, () => Promise<any>> = {
json: async () => {
const [linter, jsonParseLinter] = await Promise.all([
importLintPackage(),
import('@codemirror/lang-json').then((mod) => mod.jsonParseLinter)
])
return linter(jsonParseLinter())
}
}
export const useLanguageExtensions = (language: string, lint?: boolean) => {
const { languageMap } = useCodeStyle()
const [extensions, setExtensions] = useState<Extension[]>([])
// 加载语言
useEffect(() => {
let normalizedLang = languageMap[language as keyof typeof languageMap] || language.toLowerCase()
// 如果语言名包含 `-`,转换为驼峰命名法
if (normalizedLang.includes('-')) {
normalizedLang = normalizedLang.replace(/-([a-z])/g, (_, char) => char.toUpperCase())
}
import('@uiw/codemirror-extensions-langs')
.then(({ loadLanguage }) => {
const extension = loadLanguage(normalizedLang as any)
if (extension) {
setExtensions((prev) => [...prev, extension])
}
})
.catch((error) => {
console.debug(`Failed to load language: ${normalizedLang}`, error)
})
}, [language, languageMap])
useEffect(() => {
if (!lint) return
const loader = linterLoaders[language]
if (loader) {
loader()
.then((extension) => {
setExtensions((prev) => [...prev, extension])
})
.catch((error) => {
console.error(`Failed to load linter for ${language}`, error)
})
}
}, [language, lint])
return extensions
}

View File

@@ -0,0 +1,273 @@
import { TOOL_SPECS, useCodeToolbar } from '@renderer/components/CodeToolbar'
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
import { useSettings } from '@renderer/hooks/useSettings'
import CodeMirror, { Annotation, BasicSetupOptions, EditorView, Extension, keymap } from '@uiw/react-codemirror'
import diff from 'fast-diff'
import {
ChevronsDownUp,
ChevronsUpDown,
Save as SaveIcon,
Text as UnWrapIcon,
WrapText as WrapIcon
} from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import { useLanguageExtensions } from './hook'
// 标记非用户编辑的变更
const External = Annotation.define<boolean>()
interface Props {
value: string
placeholder?: string | HTMLElement
language: string
onSave?: (newContent: string) => void
onChange?: (newContent: string) => void
minHeight?: string
maxHeight?: string
/** 用于覆写编辑器的某些设置 */
options?: {
stream?: boolean // 用于流式响应场景,默认 false
lint?: boolean
collapsible?: boolean
wrappable?: boolean
keymap?: boolean
} & BasicSetupOptions
/** 用于追加 extensions */
extensions?: Extension[]
/** 用于覆写编辑器的样式,会直接传给 CodeMirror 的 style 属性 */
style?: React.CSSProperties
}
/**
* 源代码编辑器,基于 CodeMirror封装了 ReactCodeMirror。
*
* 目前必须和 CodeToolbar 配合使用。
*/
const CodeEditor = ({
value,
placeholder,
language,
onSave,
onChange,
minHeight,
maxHeight,
options,
extensions,
style
}: Props) => {
const {
fontSize,
codeShowLineNumbers: _lineNumbers,
codeCollapsible: _collapsible,
codeWrappable: _wrappable,
codeEditor
} = useSettings()
const collapsible = useMemo(() => options?.collapsible ?? _collapsible, [options?.collapsible, _collapsible])
const wrappable = useMemo(() => options?.wrappable ?? _wrappable, [options?.wrappable, _wrappable])
const enableKeymap = useMemo(() => options?.keymap ?? codeEditor.keymap, [options?.keymap, codeEditor.keymap])
// 合并 codeEditor 和 options 的 basicSetupoptions 优先
const customBasicSetup = useMemo(() => {
return {
lineNumbers: _lineNumbers,
...(codeEditor as BasicSetupOptions),
...(options as BasicSetupOptions)
}
}, [codeEditor, _lineNumbers, options])
const { activeCmTheme } = useCodeStyle()
const [isExpanded, setIsExpanded] = useState(!collapsible)
const [isUnwrapped, setIsUnwrapped] = useState(!wrappable)
const initialContent = useRef(options?.stream ? (value ?? '').trimEnd() : (value ?? ''))
const [editorReady, setEditorReady] = useState(false)
const editorViewRef = useRef<EditorView | null>(null)
const { t } = useTranslation()
const langExtensions = useLanguageExtensions(language, options?.lint)
const { registerTool, removeTool } = useCodeToolbar()
// 展开/折叠工具
useEffect(() => {
registerTool({
...TOOL_SPECS.expand,
icon: isExpanded ? <ChevronsDownUp className="icon" /> : <ChevronsUpDown className="icon" />,
tooltip: isExpanded ? t('code_block.collapse') : t('code_block.expand'),
visible: () => {
const scrollHeight = editorViewRef?.current?.scrollDOM?.scrollHeight
return collapsible && (scrollHeight ?? 0) > 350
},
onClick: () => setIsExpanded((prev) => !prev)
})
return () => removeTool(TOOL_SPECS.expand.id)
}, [collapsible, isExpanded, registerTool, removeTool, t, editorReady])
// 自动换行工具
useEffect(() => {
registerTool({
...TOOL_SPECS.wrap,
icon: isUnwrapped ? <WrapIcon className="icon" /> : <UnWrapIcon className="icon" />,
tooltip: isUnwrapped ? t('code_block.wrap.on') : t('code_block.wrap.off'),
visible: () => wrappable,
onClick: () => setIsUnwrapped((prev) => !prev)
})
return () => removeTool(TOOL_SPECS.wrap.id)
}, [wrappable, isUnwrapped, registerTool, removeTool, t])
const handleSave = useCallback(() => {
const currentDoc = editorViewRef.current?.state.doc.toString() ?? ''
onSave?.(currentDoc)
}, [onSave])
// 保存按钮
useEffect(() => {
registerTool({
...TOOL_SPECS.save,
icon: <SaveIcon className="icon" />,
tooltip: t('code_block.edit.save'),
onClick: handleSave
})
return () => removeTool(TOOL_SPECS.save.id)
}, [handleSave, registerTool, removeTool, t])
// 流式响应过程中计算 changes 来更新 EditorView
// 无法处理用户在流式响应过程中编辑代码的情况(应该也不必处理)
useEffect(() => {
if (!editorViewRef.current) return
const newContent = options?.stream ? (value ?? '').trimEnd() : (value ?? '')
const currentDoc = editorViewRef.current.state.doc.toString()
const changes = prepareCodeChanges(currentDoc, newContent)
if (changes && changes.length > 0) {
editorViewRef.current.dispatch({
changes,
annotations: [External.of(true)]
})
}
}, [options?.stream, value])
useEffect(() => {
setIsExpanded(!collapsible)
}, [collapsible])
useEffect(() => {
setIsUnwrapped(!wrappable)
}, [wrappable])
// 保存功能的快捷键
const saveKeymap = useMemo(() => {
return keymap.of([
{
key: 'Mod-s',
run: () => {
handleSave()
return true
},
preventDefault: true
}
])
}, [handleSave])
const customExtensions = useMemo(() => {
return [
...(extensions ?? []),
...langExtensions,
...(isUnwrapped ? [] : [EditorView.lineWrapping]),
...(enableKeymap ? [saveKeymap] : [])
]
}, [extensions, langExtensions, isUnwrapped, enableKeymap, saveKeymap])
return (
<CodeMirror
// 维持一个稳定值,避免触发 CodeMirror 重置
value={initialContent.current}
placeholder={placeholder}
width="100%"
minHeight={minHeight}
maxHeight={collapsible && !isExpanded ? (maxHeight ?? '350px') : 'none'}
editable={true}
// @ts-ignore 强制使用,见 react-codemirror 的 Example.tsx
theme={activeCmTheme}
extensions={customExtensions}
onCreateEditor={(view: EditorView) => {
editorViewRef.current = view
setEditorReady(true)
}}
onChange={(value, viewUpdate) => {
if (onChange && viewUpdate.docChanged) onChange(value)
}}
basicSetup={{
dropCursor: true,
allowMultipleSelections: true,
indentOnInput: true,
bracketMatching: true,
closeBrackets: true,
rectangularSelection: true,
crosshairCursor: true,
highlightActiveLineGutter: false,
highlightSelectionMatches: true,
closeBracketsKeymap: enableKeymap,
searchKeymap: enableKeymap,
foldKeymap: enableKeymap,
completionKeymap: enableKeymap,
lintKeymap: enableKeymap,
...customBasicSetup // override basicSetup
}}
style={{
fontSize: `${fontSize - 1}px`,
border: '0.5px solid transparent',
borderRadius: '5px',
marginTop: 0,
...style
}}
/>
)
}
CodeEditor.displayName = 'CodeEditor'
/**
* 使用 fast-diff 计算代码变更,再转换为 CodeMirror 的 changes。
* 可以处理所有类型的变更,不过流式响应过程中多是插入操作。
* @param oldCode 旧的代码内容
* @param newCode 新的代码内容
* @returns 用于 EditorView.dispatch 的 changes 数组
*/
function prepareCodeChanges(oldCode: string, newCode: string) {
const diffResult = diff(oldCode, newCode)
const changes: { from: number; to: number; insert: string }[] = []
let offset = 0
// operation: 1=插入, -1=删除, 0=相等
for (const [operation, text] of diffResult) {
if (operation === 1) {
changes.push({
from: offset,
to: offset,
insert: text
})
} else if (operation === -1) {
changes.push({
from: offset,
to: offset + text.length,
insert: ''
})
offset += text.length
} else {
offset += text.length
}
}
return changes
}
export default memo(CodeEditor)

View File

@@ -0,0 +1,76 @@
import { CodeToolSpec } from './types'
export const TOOL_SPECS: Record<string, CodeToolSpec> = {
// Core tools
copy: {
id: 'copy',
type: 'core',
order: 10
},
download: {
id: 'download',
type: 'core',
order: 11
},
edit: {
id: 'edit',
type: 'core',
order: 12
},
'view-source': {
id: 'view-source',
type: 'core',
order: 12
},
save: {
id: 'save',
type: 'core',
order: 13
},
expand: {
id: 'expand',
type: 'core',
order: 20
},
// Quick tools
'split-view': {
id: 'split-view',
type: 'quick',
order: 10
},
run: {
id: 'run',
type: 'quick',
order: 11
},
wrap: {
id: 'wrap',
type: 'quick',
order: 20
},
'copy-image': {
id: 'copy-image',
type: 'quick',
order: 30
},
'download-svg': {
id: 'download-svg',
type: 'quick',
order: 31
},
'download-png': {
id: 'download-png',
type: 'quick',
order: 32
},
'zoom-in': {
id: 'zoom-in',
type: 'quick',
order: 40
},
'zoom-out': {
id: 'zoom-out',
type: 'quick',
order: 41
}
}

View File

@@ -0,0 +1,71 @@
import React, { createContext, use, useCallback, useMemo, useState } from 'react'
import { CodeTool, CodeToolContext } from './types'
// 定义上下文默认值
const defaultContext: CodeToolContext = {
code: '',
language: ''
}
export interface CodeToolbarContextType {
tools: CodeTool[]
context: CodeToolContext
registerTool: (tool: CodeTool) => void
removeTool: (id: string) => void
updateContext: (newContext: Partial<CodeToolContext>) => void
}
const defaultCodeToolbarContext: CodeToolbarContextType = {
tools: [],
context: defaultContext,
registerTool: () => {},
removeTool: () => {},
updateContext: () => {}
}
const CodeToolbarContext = createContext<CodeToolbarContextType>(defaultCodeToolbarContext)
export const CodeToolbarProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [tools, setTools] = useState<CodeTool[]>([])
const [context, setContext] = useState<CodeToolContext>(defaultContext)
// 注册工具如果已存在同ID工具则替换
const registerTool = useCallback((tool: CodeTool) => {
setTools((prev) => {
const filtered = prev.filter((t) => t.id !== tool.id)
return [...filtered, tool].sort((a, b) => b.order - a.order)
})
}, [])
// 移除工具
const removeTool = useCallback((id: string) => {
setTools((prev) => prev.filter((tool) => tool.id !== id))
}, [])
// 更新上下文
const updateContext = useCallback((newContext: Partial<CodeToolContext>) => {
setContext((prev) => ({ ...prev, ...newContext }))
}, [])
const value: CodeToolbarContextType = useMemo(
() => ({
tools,
context,
registerTool,
removeTool,
updateContext
}),
[tools, context, registerTool, removeTool, updateContext]
)
return <CodeToolbarContext value={value}>{children}</CodeToolbarContext>
}
export const useCodeToolbar = () => {
const context = use(CodeToolbarContext)
if (!context) {
throw new Error('useCodeToolbar must be used within a CodeToolbarProvider')
}
return context
}

View File

@@ -0,0 +1,5 @@
export * from './constants'
export * from './context'
export * from './toolbar'
export * from './types'
export * from './usePreviewTools'

View File

@@ -0,0 +1,119 @@
import { HStack } from '@renderer/components/Layout'
import { Tooltip } from 'antd'
import { EllipsisVertical } from 'lucide-react'
import React, { memo, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import { useCodeToolbar } from './context'
import { CodeTool } from './types'
interface CodeToolButtonProps {
tool: CodeTool
}
const CodeToolButton: React.FC<CodeToolButtonProps> = memo(({ tool }) => {
const { context } = useCodeToolbar()
return (
<Tooltip key={`${tool.id}-${tool.tooltip}`} title={tool.tooltip} mouseEnterDelay={0.5}>
<ToolWrapper onClick={() => tool.onClick(context)}>{tool.icon}</ToolWrapper>
</Tooltip>
)
})
export const CodeToolbar: React.FC = memo(() => {
const { tools, context } = useCodeToolbar()
const [showQuickTools, setShowQuickTools] = useState(false)
const { t } = useTranslation()
// 根据条件显示工具
const visibleTools = tools.filter((tool) => !tool.visible || tool.visible(context))
// 按类型分组
const coreTools = visibleTools.filter((tool) => tool.type === 'core')
const quickTools = visibleTools.filter((tool) => tool.type === 'quick')
// 点击了 more 按钮或者只有一个快捷工具时
const quickToolButtons = useMemo(() => {
if (quickTools.length === 1 || (quickTools.length > 1 && showQuickTools)) {
return quickTools.map((tool) => <CodeToolButton key={tool.id} tool={tool} />)
}
return null
}, [quickTools, showQuickTools])
if (visibleTools.length === 0) {
return null
}
return (
<StickyWrapper>
<ToolbarWrapper className="code-toolbar">
{/* 有多个快捷工具时通过 more 按钮展示 */}
{quickToolButtons}
{quickTools.length > 1 && (
<Tooltip title={t('code_block.more')} mouseEnterDelay={0.5}>
<ToolWrapper onClick={() => setShowQuickTools(!showQuickTools)} className={showQuickTools ? 'active' : ''}>
<EllipsisVertical className="icon" />
</ToolWrapper>
</Tooltip>
)}
{/* 始终显示核心工具 */}
{coreTools.map((tool) => (
<CodeToolButton key={tool.id} tool={tool} />
))}
</ToolbarWrapper>
</StickyWrapper>
)
})
const StickyWrapper = styled.div`
position: sticky;
top: 28px;
z-index: 10;
`
const ToolbarWrapper = styled(HStack)`
position: absolute;
align-items: center;
bottom: 0.3rem;
right: 0.5rem;
height: 24px;
gap: 4px;
`
const ToolWrapper = styled.div`
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 4px;
cursor: pointer;
user-select: none;
transition: all 0.2s ease;
color: var(--color-text-3);
&:hover {
background-color: var(--color-background-soft);
.icon {
color: var(--color-text-1);
}
}
&.active {
color: var(--color-primary);
.icon {
color: var(--color-primary);
}
}
/* For Lucide icons */
.icon {
width: 14px;
height: 14px;
color: var(--color-text-3);
}
`

View File

@@ -0,0 +1,35 @@
/**
* 代码块工具基本信息
*/
export interface CodeToolSpec {
id: string
type: 'core' | 'quick'
order: number
}
/**
* 代码块工具定义接口
* @param id 唯一标识符
* @param type 工具类型
* @param icon 按钮图标
* @param tooltip 提示文本
* @param condition 显示条件
* @param onClick 点击动作
* @param order 显示顺序,越小越靠右
*/
export interface CodeTool extends CodeToolSpec {
icon: React.ReactNode
tooltip: string
visible?: (ctx?: CodeToolContext) => boolean
onClick: (ctx?: CodeToolContext) => void
}
/**
* 工具上下文接口
* @param code 代码内容
* @param language 语言类型
*/
export interface CodeToolContext {
code: string
language: string
}

View File

@@ -0,0 +1,350 @@
import { download } from '@renderer/utils/download'
import { FileImage, ZoomIn, ZoomOut } from 'lucide-react'
import { RefObject, useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { DownloadPngIcon, DownloadSvgIcon } from '../Icons/DownloadIcons'
import { TOOL_SPECS } from './constants'
import { useCodeToolbar } from './context'
// 预编译正则表达式用于查询位置
const TRANSFORM_REGEX = /translate\((-?\d+\.?\d*)px,\s*(-?\d+\.?\d*)px\)/
/**
* 使用图像处理工具的自定义Hook
* 提供图像缩放、复制和下载功能
*/
export const usePreviewToolHandlers = (
containerRef: RefObject<HTMLDivElement | null>,
options: {
prefix: string
imgSelector: string
enableWheelZoom?: boolean
customDownloader?: (format: 'svg' | 'png') => void
}
) => {
const transformRef = useRef({ scale: 1, x: 0, y: 0 }) // 管理变换状态
const [renderTrigger, setRenderTrigger] = useState(0) // 仅用于触发组件重渲染的状态
const { imgSelector, prefix, customDownloader, enableWheelZoom } = options
const { t } = useTranslation()
// 创建选择器函数
const getImgElement = useCallback(() => {
if (!containerRef.current) return null
return containerRef.current.querySelector(imgSelector) as SVGElement | null
}, [containerRef, imgSelector])
// 查询当前位置
const getCurrentPosition = useCallback(() => {
const imgElement = getImgElement()
if (!imgElement) return { x: transformRef.current.x, y: transformRef.current.y }
const transform = imgElement.style.transform
if (!transform || transform === 'none') return { x: transformRef.current.x, y: transformRef.current.y }
const match = transform.match(TRANSFORM_REGEX)
if (match && match.length >= 3) {
return {
x: parseFloat(match[1]),
y: parseFloat(match[2])
}
}
return { x: transformRef.current.x, y: transformRef.current.y }
}, [getImgElement])
// 平移缩放变换
const applyTransform = useCallback((element: SVGElement | null, x: number, y: number, scale: number) => {
if (!element) return
element.style.transformOrigin = 'top left'
element.style.transform = `translate(${x}px, ${y}px) scale(${scale})`
}, [])
// 拖拽平移支持
useEffect(() => {
const container = containerRef.current
if (!container) return
let isDragging = false
const startPos = { x: 0, y: 0 }
const startOffset = { x: 0, y: 0 }
const onMouseDown = (e: MouseEvent) => {
if (e.button !== 0) return // 只响应左键
// 更新当前实际位置
const position = getCurrentPosition()
transformRef.current.x = position.x
transformRef.current.y = position.y
isDragging = true
startPos.x = e.clientX
startPos.y = e.clientY
startOffset.x = position.x
startOffset.y = position.y
container.style.cursor = 'grabbing'
e.preventDefault()
}
const onMouseMove = (e: MouseEvent) => {
if (!isDragging) return
const dx = e.clientX - startPos.x
const dy = e.clientY - startPos.y
const newX = startOffset.x + dx
const newY = startOffset.y + dy
const imgElement = getImgElement()
applyTransform(imgElement, newX, newY, transformRef.current.scale)
e.preventDefault()
}
const stopDrag = () => {
if (!isDragging) return
// 更新位置但不立即触发状态变更
const position = getCurrentPosition()
transformRef.current.x = position.x
transformRef.current.y = position.y
// 只触发一次渲染以保持组件状态同步
setRenderTrigger((prev) => prev + 1)
isDragging = false
container.style.cursor = 'default'
}
// 绑定到document以确保拖拽可以在鼠标离开容器后继续
container.addEventListener('mousedown', onMouseDown)
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', stopDrag)
return () => {
container.removeEventListener('mousedown', onMouseDown)
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', stopDrag)
}
}, [containerRef, getCurrentPosition, getImgElement, applyTransform])
// 缩放处理函数
const handleZoom = useCallback(
(delta: number) => {
const newScale = Math.max(0.1, Math.min(3, transformRef.current.scale + delta))
transformRef.current.scale = newScale
const imgElement = getImgElement()
applyTransform(imgElement, transformRef.current.x, transformRef.current.y, newScale)
// 触发重渲染以保持组件状态同步
setRenderTrigger((prev) => prev + 1)
},
[getImgElement, applyTransform]
)
// 滚轮缩放支持
useEffect(() => {
if (!enableWheelZoom || !containerRef.current) return
const container = containerRef.current
const handleWheel = (e: WheelEvent) => {
if ((e.ctrlKey || e.metaKey) && e.target) {
// 确认事件发生在容器内部
if (container.contains(e.target as Node)) {
const delta = e.deltaY < 0 ? 0.1 : -0.1
handleZoom(delta)
}
}
}
container.addEventListener('wheel', handleWheel, { passive: true })
return () => container.removeEventListener('wheel', handleWheel)
}, [containerRef, handleZoom, enableWheelZoom])
// 复制图像处理函数
const handleCopyImage = useCallback(async () => {
try {
const imgElement = getImgElement()
if (!imgElement) return
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
const img = new Image()
img.crossOrigin = 'anonymous'
const viewBox = imgElement.getAttribute('viewBox')?.split(' ').map(Number) || []
const width = viewBox[2] || imgElement.clientWidth || imgElement.getBoundingClientRect().width
const height = viewBox[3] || imgElement.clientHeight || imgElement.getBoundingClientRect().height
const svgData = new XMLSerializer().serializeToString(imgElement)
const svgBase64 = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svgData)))}`
img.onload = async () => {
const scale = 3
canvas.width = width * scale
canvas.height = height * scale
if (ctx) {
ctx.scale(scale, scale)
ctx.drawImage(img, 0, 0, width, height)
const blob = await new Promise<Blob>((resolve) => canvas.toBlob((b) => resolve(b!), 'image/png'))
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
window.message.success(t('message.copy.success'))
}
}
img.src = svgBase64
} catch (error) {
console.error('Copy failed:', error)
window.message.error(t('message.copy.failed'))
}
}, [getImgElement, t])
// 下载处理函数
const handleDownload = useCallback(
(format: 'svg' | 'png') => {
// 如果有自定义下载器,使用自定义实现
if (customDownloader) {
customDownloader(format)
return
}
try {
const imgElement = getImgElement()
if (!imgElement) return
const timestamp = Date.now()
if (format === 'svg') {
const svgData = new XMLSerializer().serializeToString(imgElement)
const blob = new Blob([svgData], { type: 'image/svg+xml' })
const url = URL.createObjectURL(blob)
download(url, `${prefix}-${timestamp}.svg`)
URL.revokeObjectURL(url)
} else if (format === 'png') {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
const img = new Image()
img.crossOrigin = 'anonymous'
const viewBox = imgElement.getAttribute('viewBox')?.split(' ').map(Number) || []
const width = viewBox[2] || imgElement.clientWidth || imgElement.getBoundingClientRect().width
const height = viewBox[3] || imgElement.clientHeight || imgElement.getBoundingClientRect().height
const svgData = new XMLSerializer().serializeToString(imgElement)
const svgBase64 = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svgData)))}`
img.onload = () => {
const scale = 3
canvas.width = width * scale
canvas.height = height * scale
if (ctx) {
ctx.scale(scale, scale)
ctx.drawImage(img, 0, 0, width, height)
}
canvas.toBlob((blob) => {
if (blob) {
const pngUrl = URL.createObjectURL(blob)
download(pngUrl, `${prefix}-${timestamp}.png`)
URL.revokeObjectURL(pngUrl)
}
}, 'image/png')
}
img.src = svgBase64
}
} catch (error) {
console.error('Download failed:', error)
}
},
[getImgElement, prefix, customDownloader]
)
return {
scale: transformRef.current.scale,
handleZoom,
handleCopyImage,
handleDownload,
renderTrigger // 导出渲染触发器,万一要用
}
}
export interface PreviewToolsOptions {
handleZoom?: (delta: number) => void
handleCopyImage?: () => Promise<void>
handleDownload?: (format: 'svg' | 'png') => void
}
/**
* 提供预览组件通用工具栏功能的自定义Hook
*/
export const usePreviewTools = ({ handleZoom, handleCopyImage, handleDownload }: PreviewToolsOptions) => {
const { t } = useTranslation()
const { registerTool, removeTool } = useCodeToolbar()
useEffect(() => {
// 根据提供的功能有选择性地注册工具
if (handleZoom) {
// 放大工具
registerTool({
...TOOL_SPECS['zoom-in'],
icon: <ZoomIn className="icon" />,
tooltip: t('code_block.preview.zoom_in'),
onClick: () => handleZoom(0.1)
})
// 缩小工具
registerTool({
...TOOL_SPECS['zoom-out'],
icon: <ZoomOut className="icon" />,
tooltip: t('code_block.preview.zoom_out'),
onClick: () => handleZoom(-0.1)
})
}
if (handleCopyImage) {
// 复制图片工具
registerTool({
...TOOL_SPECS['copy-image'],
icon: <FileImage className="icon" />,
tooltip: t('code_block.preview.copy.image'),
onClick: handleCopyImage
})
}
if (handleDownload) {
// 下载 SVG 工具
registerTool({
...TOOL_SPECS['download-svg'],
icon: <DownloadSvgIcon />,
tooltip: t('code_block.download.svg'),
onClick: () => handleDownload('svg')
})
// 下载 PNG 工具
registerTool({
...TOOL_SPECS['download-png'],
icon: <DownloadPngIcon />,
tooltip: t('code_block.download.png'),
onClick: () => handleDownload('png')
})
}
// 清理函数
return () => {
if (handleZoom) {
removeTool(TOOL_SPECS['zoom-in'].id)
removeTool(TOOL_SPECS['zoom-out'].id)
}
if (handleCopyImage) {
removeTool(TOOL_SPECS['copy-image'].id)
}
if (handleDownload) {
removeTool(TOOL_SPECS['download-svg'].id)
removeTool(TOOL_SPECS['download-png'].id)
}
}
}, [handleCopyImage, handleDownload, handleZoom, registerTool, removeTool, t])
}

View File

@@ -0,0 +1,718 @@
import { ToolbarButton } from '@renderer/pages/home/Inputbar/Inputbar'
import NarrowLayout from '@renderer/pages/home/Messages/NarrowLayout'
import { Tooltip } from 'antd'
import { debounce } from 'lodash'
import { CaseSensitive, ChevronDown, ChevronUp, User, WholeWord, X } from 'lucide-react'
import React, { useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
const HIGHLIGHT_CLASS = 'highlight'
const HIGHLIGHT_SELECT_CLASS = 'selected'
interface Props {
children?: React.ReactNode
searchTarget: React.RefObject<React.ReactNode> | React.RefObject<HTMLElement> | HTMLElement
/**
* 过滤`node``node`只会是`Node.TEXT_NODE`类型的文本节点
*
* 返回`true`表示该`node`会被搜索
*/
filter: (node: Node) => boolean
includeUser?: boolean
onIncludeUserChange?: (value: boolean) => void
}
enum SearchCompletedState {
NotSearched,
FirstSearched
}
enum SearchTargetIndex {
Next,
Prev
}
export interface ContentSearchRef {
disable(): void
enable(initialText?: string): void
// 搜索下一个并定位
searchNext(): void
// 搜索上一个并定位
searchPrev(): void
// 搜索并定位
search(): void
// 搜索但不定位,或者说是更新
silentSearch(): void
focus(): void
}
interface MatchInfo {
index: number
length: number
text: string
}
const escapeRegExp = (string: string): string => {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // $& means the whole matched string
}
const findWindowVerticalCenterElementIndex = (elementList: HTMLElement[]): number | null => {
if (!elementList || elementList.length === 0) {
return null
}
let closestElementIndex: number | null = null
let minVerticalDistance = Infinity
const windowCenterY = window.innerHeight / 2
for (let i = 0; i < elementList.length; i++) {
const element = elementList[i]
if (!(element instanceof HTMLElement)) {
continue
}
const rect = element.getBoundingClientRect()
if (rect.bottom < 0 || rect.top > window.innerHeight) {
continue
}
const elementCenterY = rect.top + rect.height / 2
const verticalDistance = Math.abs(elementCenterY - windowCenterY)
if (verticalDistance < minVerticalDistance) {
minVerticalDistance = verticalDistance
closestElementIndex = i
}
}
return closestElementIndex
}
const highlightText = (
textNode: Node,
searchText: string,
highlightClass: string,
isCaseSensitive: boolean,
isWholeWord: boolean
): HTMLSpanElement[] | null => {
const textNodeParentNode: HTMLElement | null = textNode.parentNode as HTMLElement
if (textNodeParentNode) {
if (textNodeParentNode.classList.contains(highlightClass)) {
return null
}
}
if (textNode.nodeType !== Node.TEXT_NODE || !textNode.textContent) {
return null
}
const textContent = textNode.textContent
const escapedSearchText = escapeRegExp(searchText)
// 检查搜索文本是否仅包含拉丁字母
const hasOnlyLatinLetters = /^[a-zA-Z\s]+$/.test(searchText)
// 只有当搜索文本仅包含拉丁字母时才应用大小写敏感
const regexFlags = hasOnlyLatinLetters && isCaseSensitive ? 'g' : 'gi'
const regexPattern = isWholeWord ? `\\b${escapedSearchText}\\b` : escapedSearchText
const regex = new RegExp(regexPattern, regexFlags)
let match
const matches: MatchInfo[] = []
while ((match = regex.exec(textContent)) !== null) {
if (typeof match.index === 'number' && typeof match[0] === 'string') {
matches.push({ index: match.index, length: match[0].length, text: match[0] })
} else {
console.error('Unexpected match format:', match)
}
}
if (matches.length === 0) {
return null
}
const parentNode = textNode.parentNode
if (!parentNode) {
return null
}
const fragment = document.createDocumentFragment()
let currentIndex = 0
const highlightTextSet = new Set<HTMLSpanElement>()
matches.forEach(({ index, length, text }) => {
if (index > currentIndex) {
fragment.appendChild(document.createTextNode(textContent.substring(currentIndex, index)))
}
const highlightSpan = document.createElement('span')
highlightSpan.className = highlightClass
highlightSpan.textContent = text // Use the matched text to preserve case if not case-sensitive
fragment.appendChild(highlightSpan)
highlightTextSet.add(highlightSpan)
currentIndex = index + length
})
if (currentIndex < textContent.length) {
fragment.appendChild(document.createTextNode(textContent.substring(currentIndex)))
}
parentNode.replaceChild(fragment, textNode)
return [...highlightTextSet]
}
const mergeAdjacentTextNodes = (node: HTMLElement) => {
const children = Array.from(node.childNodes)
const groups: Array<Node | { text: string; nodes: Node[] }> = []
let currentTextGroup: { text: string; nodes: Node[] } | null = null
for (const child of children) {
if (child.nodeType === Node.TEXT_NODE) {
if (currentTextGroup === null) {
currentTextGroup = {
text: child.textContent ?? '',
nodes: [child]
}
} else {
currentTextGroup.text += child.textContent
currentTextGroup.nodes.push(child)
}
} else {
if (currentTextGroup !== null) {
groups.push(currentTextGroup!)
currentTextGroup = null
}
groups.push(child)
}
}
if (currentTextGroup !== null) {
groups.push(currentTextGroup)
}
const newChildren = groups.map((group) => {
if (group instanceof Node) {
return group
} else {
return document.createTextNode(group.text)
}
})
node.replaceChildren(...newChildren)
}
// eslint-disable-next-line @eslint-react/no-forward-ref
export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
({ searchTarget, filter, includeUser = false, onIncludeUserChange }, ref) => {
const target: HTMLElement | null = (() => {
if (searchTarget instanceof HTMLElement) {
return searchTarget
} else {
return (searchTarget.current as HTMLElement) ?? null
}
})()
const containerRef = React.useRef<HTMLDivElement>(null)
const searchInputRef = React.useRef<HTMLInputElement>(null)
const [searchResultIndex, setSearchResultIndex] = useState(0)
const [totalCount, setTotalCount] = useState(0)
const [enableContentSearch, setEnableContentSearch] = useState(false)
const [searchCompleted, setSearchCompleted] = useState(SearchCompletedState.NotSearched)
const [isCaseSensitive, setIsCaseSensitive] = useState(false)
const [isWholeWord, setIsWholeWord] = useState(false)
const [shouldScroll, setShouldScroll] = useState(false)
const highlightTextSet = useState(new Set<Node>())[0]
const prevSearchText = useRef('')
const { t } = useTranslation()
const locateByIndex = (index: number, shouldScroll = true) => {
if (target) {
const highlightTextNodes = [...highlightTextSet] as HTMLElement[]
highlightTextNodes.sort((a, b) => {
const { top: aTop } = a.getBoundingClientRect()
const { top: bTop } = b.getBoundingClientRect()
return aTop - bTop
})
for (const node of highlightTextNodes) {
node.classList.remove(HIGHLIGHT_SELECT_CLASS)
}
setSearchResultIndex(index)
if (highlightTextNodes.length > 0) {
const highlightTextNode = highlightTextNodes[index] ?? null
if (highlightTextNode) {
highlightTextNode.classList.add(HIGHLIGHT_SELECT_CLASS)
if (shouldScroll) {
highlightTextNode.scrollIntoView({
behavior: 'smooth',
block: 'center'
// inline: 'center' 水平方向居中可能会导致 content 页面整体偏右, 使得左半部的内容被遮挡. 因此先注释掉该代码
})
}
}
}
}
}
const restoreHighlight = () => {
const highlightTextParentNodeSet = new Set<HTMLElement>()
// Make a copy because the set might be modified during iteration indirectly
const nodesToRestore = [...highlightTextSet]
for (const highlightTextNode of nodesToRestore) {
if (highlightTextNode.textContent) {
const textNode = document.createTextNode(highlightTextNode.textContent)
const node = highlightTextNode as HTMLElement
if (node.parentNode) {
highlightTextParentNodeSet.add(node.parentNode as HTMLElement)
node.replaceWith(textNode) // This removes the node from the DOM
}
}
}
highlightTextSet.clear() // Clear the original set after processing
for (const parentNode of highlightTextParentNodeSet) {
mergeAdjacentTextNodes(parentNode)
}
// highlightTextSet.clear() // Already cleared
}
const search = (searchTargetIndex?: SearchTargetIndex): number | null => {
const searchText = searchInputRef.current?.value.trim() ?? null
if (target && searchText !== null && searchText !== '') {
restoreHighlight()
const iter = document.createNodeIterator(target, NodeFilter.SHOW_TEXT)
let textNode: Node | null
const textNodeSet: Set<Node> = new Set()
while ((textNode = iter.nextNode())) {
if (filter(textNode)) {
textNodeSet.add(textNode)
}
}
const highlightTextSetTemp = new Set<HTMLSpanElement>()
for (const node of textNodeSet) {
const list = highlightText(node, searchText, HIGHLIGHT_CLASS, isCaseSensitive, isWholeWord)
if (list) {
list.forEach((node) => highlightTextSetTemp.add(node))
}
}
const highlightTextList = [...highlightTextSetTemp]
setTotalCount(highlightTextList.length)
highlightTextSetTemp.forEach((node) => highlightTextSet.add(node))
const changeIndex = () => {
let index: number
switch (searchTargetIndex) {
case SearchTargetIndex.Next:
{
index = (searchResultIndex + 1) % highlightTextList.length
}
break
case SearchTargetIndex.Prev:
{
index = (searchResultIndex - 1 + highlightTextList.length) % highlightTextList.length
}
break
default: {
index = searchResultIndex
}
}
return Math.max(index, 0)
}
const targetIndex = (() => {
switch (searchCompleted) {
case SearchCompletedState.NotSearched: {
setSearchCompleted(SearchCompletedState.FirstSearched)
const index = findWindowVerticalCenterElementIndex(highlightTextList)
if (index !== null) {
setSearchResultIndex(index)
return index
} else {
setSearchResultIndex(0)
return 0
}
}
case SearchCompletedState.FirstSearched: {
return changeIndex()
}
default: {
return null
}
}
})()
if (targetIndex === null) {
return null
} else {
const totalCount = highlightTextSet.size
if (targetIndex >= totalCount) {
return totalCount - 1
} else {
return targetIndex
}
}
} else {
return null
}
}
const _searchHandlerDebounce = debounce(() => {
implementation.search()
}, 300)
const searchHandler = useCallback(_searchHandlerDebounce, [_searchHandlerDebounce])
const userInputHandler = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.value.trim()
if (value.length === 0) {
restoreHighlight()
setTotalCount(0)
setSearchResultIndex(0)
setSearchCompleted(SearchCompletedState.NotSearched)
} else {
// 用户输入时允许滚动
setShouldScroll(true)
searchHandler()
}
prevSearchText.current = value
}
const keyDownHandler = (event: React.KeyboardEvent<HTMLInputElement>) => {
const { code, key, shiftKey } = event
if (key === 'Process') {
return
}
switch (code) {
case 'Enter':
{
if (shiftKey) {
implementation.searchPrev()
} else {
implementation.searchNext()
}
event.preventDefault()
}
break
case 'Escape':
{
implementation.disable()
}
break
}
}
const searchInputFocus = () => requestAnimationFrame(() => searchInputRef.current?.focus())
const userOutlinedButtonOnClick = () => {
if (onIncludeUserChange) {
onIncludeUserChange(!includeUser)
}
searchInputFocus()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
const implementation = {
disable() {
setEnableContentSearch(false)
restoreHighlight()
setShouldScroll(false)
},
enable(initialText?: string) {
setEnableContentSearch(true)
setShouldScroll(false) // Default to false, search itself might set it to true
if (searchInputRef.current) {
const inputEl = searchInputRef.current
if (initialText && initialText.trim().length > 0) {
inputEl.value = initialText
// Trigger search after setting initial text
// Need to make sure search() uses the new value
// and also to focus and select
requestAnimationFrame(() => {
inputEl.focus()
inputEl.select()
setShouldScroll(true)
const targetIndex = search()
if (targetIndex !== null) {
locateByIndex(targetIndex, true) // Ensure scrolling
} else {
// If search returns null (e.g., empty input or no matches with initial text), clear state
restoreHighlight()
setTotalCount(0)
setSearchResultIndex(0)
setSearchCompleted(SearchCompletedState.NotSearched)
}
})
} else {
requestAnimationFrame(() => {
inputEl.focus()
inputEl.select()
})
// Only search if there's existing text and no new initialText
if (inputEl.value.trim()) {
const targetIndex = search()
if (targetIndex !== null) {
setSearchResultIndex(targetIndex)
// locateByIndex(targetIndex, false); // Don't scroll if just enabling with existing text
}
}
}
}
},
searchNext() {
if (enableContentSearch) {
const targetIndex = search(SearchTargetIndex.Next)
if (targetIndex !== null) {
locateByIndex(targetIndex)
}
}
},
searchPrev() {
if (enableContentSearch) {
const targetIndex = search(SearchTargetIndex.Prev)
if (targetIndex !== null) {
locateByIndex(targetIndex)
}
}
},
resetSearchState() {
if (enableContentSearch) {
setSearchCompleted(SearchCompletedState.NotSearched)
// Maybe also reset index? Depends on desired behavior
// setSearchResultIndex(0);
}
},
search() {
if (enableContentSearch) {
const targetIndex = search()
if (targetIndex !== null) {
locateByIndex(targetIndex, shouldScroll)
} else {
// If search returns null (e.g., empty input), clear state
restoreHighlight()
setTotalCount(0)
setSearchResultIndex(0)
setSearchCompleted(SearchCompletedState.NotSearched)
}
}
},
silentSearch() {
if (enableContentSearch) {
const targetIndex = search()
if (targetIndex !== null) {
// 只更新索引,不触发滚动
locateByIndex(targetIndex, false)
}
}
},
focus() {
searchInputFocus()
}
}
useImperativeHandle(ref, () => ({
disable() {
implementation.disable()
},
enable(initialText?: string) {
implementation.enable(initialText)
},
searchNext() {
implementation.searchNext()
},
searchPrev() {
implementation.searchPrev()
},
search() {
implementation.search()
},
silentSearch() {
implementation.silentSearch()
},
focus() {
implementation.focus()
}
}))
// Re-run search when options change and search is active
useEffect(() => {
if (enableContentSearch && searchInputRef.current?.value.trim()) {
implementation.search()
}
}, [isCaseSensitive, isWholeWord, enableContentSearch, implementation]) // Add enableContentSearch dependency
const prevButtonOnClick = () => {
implementation.searchPrev()
searchInputFocus()
}
const nextButtonOnClick = () => {
implementation.searchNext()
searchInputFocus()
}
const closeButtonOnClick = () => {
implementation.disable()
}
const caseSensitiveButtonOnClick = () => {
setIsCaseSensitive(!isCaseSensitive)
searchInputFocus()
}
const wholeWordButtonOnClick = () => {
setIsWholeWord(!isWholeWord)
searchInputFocus()
}
return (
<Container ref={containerRef} style={enableContentSearch ? {} : { display: 'none' }}>
<NarrowLayout style={{ width: '100%' }}>
<SearchBarContainer>
<InputWrapper>
<Input
ref={searchInputRef}
onInput={userInputHandler}
onKeyDown={keyDownHandler}
placeholder={t('chat.assistant.search.placeholder')}
style={{ lineHeight: '20px' }}
/>
<ToolBar>
<Tooltip title={t('button.includes_user_questions')} mouseEnterDelay={0.8} placement="bottom">
<ToolbarButton type="text" onClick={userOutlinedButtonOnClick}>
<User size={18} style={{ color: includeUser ? 'var(--color-link)' : 'var(--color-icon)' }} />
</ToolbarButton>
</Tooltip>
<Tooltip title={t('button.case_sensitive')} mouseEnterDelay={0.8} placement="bottom">
<ToolbarButton type="text" onClick={caseSensitiveButtonOnClick}>
<CaseSensitive
size={18}
style={{ color: isCaseSensitive ? 'var(--color-link)' : 'var(--color-icon)' }}
/>
</ToolbarButton>
</Tooltip>
<Tooltip title={t('button.whole_word')} mouseEnterDelay={0.8} placement="bottom">
<ToolbarButton type="text" onClick={wholeWordButtonOnClick}>
<WholeWord size={18} style={{ color: isWholeWord ? 'var(--color-link)' : 'var(--color-icon)' }} />
</ToolbarButton>
</Tooltip>
</ToolBar>
</InputWrapper>
<Separator></Separator>
<SearchResults>
{searchCompleted !== SearchCompletedState.NotSearched ? (
totalCount > 0 ? (
<>
<SearchResultCount>{searchResultIndex + 1}</SearchResultCount>
<SearchResultSeparator>/</SearchResultSeparator>
<SearchResultTotalCount>{totalCount}</SearchResultTotalCount>
</>
) : (
<NoResults>{t('common.no_results')}</NoResults>
)
) : (
<SearchResultsPlaceholder>0/0</SearchResultsPlaceholder>
)}
</SearchResults>
<ToolBar>
<ToolbarButton type="text" onClick={prevButtonOnClick} disabled={totalCount === 0}>
<ChevronUp size={18} />
</ToolbarButton>
<ToolbarButton type="text" onClick={nextButtonOnClick} disabled={totalCount === 0}>
<ChevronDown size={18} />
</ToolbarButton>
<ToolbarButton type="text" onClick={closeButtonOnClick}>
<X size={18} />
</ToolbarButton>
</ToolBar>
</SearchBarContainer>
</NarrowLayout>
<Placeholder />
</Container>
)
}
)
ContentSearch.displayName = 'ContentSearch'
const Container = styled.div`
display: flex;
flex-direction: row;
z-index: 2;
`
const SearchBarContainer = styled.div`
border: 1px solid var(--color-primary);
border-radius: 10px;
transition: all 0.2s ease;
position: fixed;
top: 15px;
left: 20px;
right: 20px;
margin-bottom: 5px;
padding: 5px 15px;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--color-background);
flex: 1 1 auto; /* Take up input's previous space */
`
const Placeholder = styled.div`
width: 5px;
`
const InputWrapper = styled.div`
display: flex;
align-items: center;
flex: 1 1 auto; /* Take up input's previous space */
`
const Input = styled.input`
border: none;
color: var(--color-text);
background-color: transparent;
outline: none;
width: 100%;
padding: 0 5px; /* Adjust padding, wrapper will handle spacing */
flex: 1; /* Allow input to grow */
font-size: 14px;
font-family: Ubuntu;
`
const ToolBar = styled.div`
display: flex;
flex-direction: row;
align-items: center;
gap: tpx;
`
const Separator = styled.div`
width: 1px;
height: 1.5em;
background-color: var(--color-border);
margin-left: 2px;
margin-right: 2px;
flex: 0 0 auto;
`
const SearchResults = styled.div`
display: flex;
justify-content: center;
width: 80px;
margin: 0 2px;
flex: 0 0 auto;
color: var(--color-text-1);
font-size: 14px;
font-family: Ubuntu;
`
const SearchResultsPlaceholder = styled.span`
color: var(--color-text-1);
opacity: 0.5;
`
const NoResults = styled.span`
color: var(--color-text-1);
`
const SearchResultCount = styled.span`
color: var(--color-text);
`
const SearchResultSeparator = styled.span`
color: var(--color-text);
margin: 0 4px;
`
const SearchResultTotalCount = styled.span`
color: var(--color-text);
`

View File

@@ -0,0 +1,94 @@
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { Dropdown } from 'antd'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface ContextMenuProps {
children: React.ReactNode
onContextMenu?: (e: React.MouseEvent) => void
}
const ContextMenu: React.FC<ContextMenuProps> = ({ children, onContextMenu }) => {
const { t } = useTranslation()
const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null)
const [selectedQuoteText, setSelectedQuoteText] = useState<string>('')
const [selectedText, setSelectedText] = useState<string>('')
const handleContextMenu = useCallback(
(e: React.MouseEvent) => {
e.preventDefault()
const _selectedText = window.getSelection()?.toString()
if (_selectedText) {
const quotedText =
_selectedText
.split('\n')
.map((line) => `> ${line}`)
.join('\n') + '\n-------------'
setSelectedQuoteText(quotedText)
setContextMenuPosition({ x: e.clientX, y: e.clientY })
setSelectedText(_selectedText)
}
onContextMenu?.(e)
},
[onContextMenu]
)
useEffect(() => {
const handleClick = () => {
setContextMenuPosition(null)
}
document.addEventListener('click', handleClick)
return () => {
document.removeEventListener('click', handleClick)
}
}, [])
// 获取右键菜单项
const getContextMenuItems = (t: (key: string) => string, selectedQuoteText: string, selectedText: string) => [
{
key: 'copy',
label: t('common.copy'),
onClick: () => {
if (selectedText) {
navigator.clipboard
.writeText(selectedText)
.then(() => {
window.message.success({ content: t('message.copied'), key: 'copy-message' })
})
.catch(() => {
window.message.error({ content: t('message.copy.failed'), key: 'copy-message-failed' })
})
}
}
},
{
key: 'quote',
label: t('chat.message.quote'),
onClick: () => {
if (selectedQuoteText) {
EventEmitter.emit(EVENT_NAMES.QUOTE_TEXT, selectedQuoteText)
}
}
}
]
return (
<ContextContainer onContextMenu={handleContextMenu}>
{contextMenuPosition && (
<Dropdown
overlayStyle={{ position: 'fixed', left: contextMenuPosition.x, top: contextMenuPosition.y, zIndex: 1000 }}
menu={{ items: getContextMenuItems(t, selectedQuoteText, selectedText) }}
open={true}
trigger={['contextMenu']}>
<div />
</Dropdown>
)}
{children}
</ContextContainer>
)
}
const ContextContainer = styled.div``
export default ContextMenu

View File

@@ -0,0 +1,68 @@
import { SVGProps } from 'react'
// 基础下载图标
export const DownloadIcon = (props: SVGProps<SVGSVGElement>) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1.1em"
height="1.1em"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
viewBox="0 0 24 24"
{...props}>
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
<path d="M12 15V3" />
<polygon points="12,15 9,11 15,11" fill="currentColor" stroke="none" />
</svg>
)
// 带有文件类型的下载图标基础组件
const DownloadTypeIconBase = ({ type, ...props }: SVGProps<SVGSVGElement> & { type: string }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1.1em"
height="1.1em"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
viewBox="0 0 24 24"
{...props}>
<text
x="12"
y="7"
fontSize="8"
textAnchor="middle"
fill="currentColor"
stroke="currentColor"
strokeWidth="0.3"
letterSpacing="1"
fontFamily="Arial Black, sans-serif"
style={{
paintOrder: 'stroke',
fontStretch: 'expanded',
userSelect: 'none',
WebkitUserSelect: 'none',
MozUserSelect: 'none',
msUserSelect: 'none'
}}>
{type}
</text>
<path d="M21 16v3a2 2 0 01-2 2H5a2 2 0 01-2-2v-3" />
<path d="M12 17V10" />
<polygon points="12,17 9.5,14 14.5,14" fill="currentColor" stroke="none" />
</svg>
)
// JPG 文件下载图标
export const DownloadJpgIcon = (props: SVGProps<SVGSVGElement>) => <DownloadTypeIconBase type="JPG" {...props} />
// PNG 文件下载图标
export const DownloadPngIcon = (props: SVGProps<SVGSVGElement>) => <DownloadTypeIconBase type="PNG" {...props} />
// SVG 文件下载图标
export const DownloadSvgIcon = (props: SVGProps<SVGSVGElement>) => <DownloadTypeIconBase type="SVG" {...props} />

View File

@@ -149,15 +149,6 @@ export const BaseTypography = styled(Box)<{
text-align: ${(props) => props.textAlign || 'left'};
`
export const TypographyNormal = styled(BaseTypography)`
font-family: 'Ubuntu';
`
export const TypographyBold = styled(BaseTypography)`
font-family: 'Ubuntu';
font-weight: bold;
`
export const Container = styled.main<ContainerProps>`
display: flex;
flex-direction: column;

View File

@@ -35,7 +35,6 @@ const ListItemContainer = styled.div`
flex-direction: column;
justify-content: space-between;
position: relative;
font-family: Ubuntu;
cursor: pointer;
border: 1px solid transparent;

View File

@@ -1,4 +1,6 @@
import {
ArrowLeftOutlined,
ArrowRightOutlined,
CloseOutlined,
CodeOutlined,
CopyOutlined,
@@ -241,6 +243,22 @@ const MinappPopupContainer: React.FC = () => {
dispatch(setMinappsOpenLinkExternal(!minappsOpenLinkExternal))
}
/** navigate back in webview history */
const handleGoBack = (appid: string) => {
const webview = webviewRefs.current.get(appid)
if (webview && webview.canGoBack()) {
webview.goBack()
}
}
/** navigate forward in webview history */
const handleGoForward = (appid: string) => {
const webview = webviewRefs.current.get(appid)
if (webview && webview.canGoForward()) {
webview.goForward()
}
}
/** Title bar of the popup */
const Title = ({ appInfo, url }: { appInfo: AppInfo | null; url: string | null }) => {
if (!appInfo) return null
@@ -286,6 +304,16 @@ const MinappPopupContainer: React.FC = () => {
)}
<Spacer />
<ButtonsGroup className={isWindows || isLinux ? 'windows' : ''}>
<Tooltip title={t('minapp.popup.goBack')} mouseEnterDelay={0.8} placement="bottom">
<Button onClick={() => handleGoBack(appInfo.id)}>
<ArrowLeftOutlined />
</Button>
</Tooltip>
<Tooltip title={t('minapp.popup.goForward')} mouseEnterDelay={0.8} placement="bottom">
<Button onClick={() => handleGoForward(appInfo.id)}>
<ArrowRightOutlined />
</Button>
</Tooltip>
<Tooltip title={t('minapp.popup.refresh')} mouseEnterDelay={0.8} placement="bottom">
<Button onClick={() => handleReload(appInfo.id)}>
<ReloadOutlined />

View File

@@ -67,6 +67,11 @@ const WebviewContainer = memo(
style={WebviewStyle}
allowpopups={'true' as any}
partition="persist:webview"
useragent={
appid === 'google'
? 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Safari/537.36'
: undefined
}
/>
)
}

View File

@@ -57,7 +57,6 @@ const NameSpan = styled.span`
text-overflow: ellipsis;
white-space: nowrap;
cursor: help;
font-family: 'Ubuntu';
line-height: 30px;
`

View File

@@ -1,5 +1,5 @@
import { Provider } from '@renderer/types'
import { oauthWithAihubmix, oauthWithSiliconFlow } from '@renderer/utils/oauth'
import { oauthWithAihubmix, oauthWithSiliconFlow, oauthWithTokenFlux } from '@renderer/utils/oauth'
import { Button, ButtonProps } from 'antd'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
@@ -27,6 +27,10 @@ const OAuthButton: FC<Props> = ({ provider, onSuccess, ...buttonProps }) => {
if (provider.id === 'aihubmix') {
oauthWithAihubmix(handleSuccess)
}
if (provider.id === 'tokenflux') {
oauthWithTokenFlux()
}
}
return (

View File

@@ -1,6 +1,8 @@
import { backup } from '@renderer/services/BackupService'
import store from '@renderer/store'
import { IpcChannel } from '@shared/IpcChannel'
import { Modal, Progress } from 'antd'
import Logger from 'electron-log'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -20,6 +22,7 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
const [open, setOpen] = useState(true)
const [progressData, setProgressData] = useState<ProgressData>()
const { t } = useTranslation()
const skipBackupFile = store.getState().settings.skipBackupFile
useEffect(() => {
const removeListener = window.electron.ipcRenderer.on(IpcChannel.BackupProgress, (_, data: ProgressData) => {
@@ -32,7 +35,8 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
}, [])
const onOk = async () => {
await backup()
Logger.log('[BackupManager] ', skipBackupFile)
await backup(skipBackupFile)
setOpen(false)
}

View File

@@ -0,0 +1,89 @@
import HomeTabs from '@renderer/pages/home/Tabs/index'
import { Assistant, Topic } from '@renderer/types'
import { Popover } from 'antd'
import { FC, useEffect, useState } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import styled from 'styled-components'
import Scrollbar from '../Scrollbar'
interface Props {
children: React.ReactNode
activeAssistant: Assistant
setActiveAssistant: (assistant: Assistant) => void
activeTopic: Topic
setActiveTopic: (topic: Topic) => void
position: 'left' | 'right'
}
const FloatingSidebar: FC<Props> = ({
children,
activeAssistant,
setActiveAssistant,
activeTopic,
setActiveTopic,
position = 'left'
}) => {
const [open, setOpen] = useState(false)
useHotkeys('esc', () => {
setOpen(false)
})
const [maxHeight, setMaxHeight] = useState(Math.floor(window.innerHeight * 0.75))
useEffect(() => {
const handleResize = () => {
setMaxHeight(Math.floor(window.innerHeight * 0.75))
}
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
}
}, [])
const content = (
<PopoverContent maxHeight={maxHeight}>
<HomeTabs
activeAssistant={activeAssistant}
activeTopic={activeTopic}
setActiveAssistant={setActiveAssistant}
setActiveTopic={setActiveTopic}
position={position}
forceToSeeAllTab={true}
style={{
background: 'transparent',
border: 'none'
}}
/>
</PopoverContent>
)
return (
<Popover
open={open}
onOpenChange={setOpen}
content={content}
trigger={['hover', 'click', 'contextMenu']}
placement="bottomRight"
showArrow
mouseEnterDelay={0.8} // 800ms delay before showing
mouseLeaveDelay={20}
styles={{
body: {
padding: 0
}
}}>
{children}
</Popover>
)
}
const PopoverContent = styled(Scrollbar)<{ maxHeight: number }>`
max-height: ${(props) => props.maxHeight}px;
overflow-y: auto;
`
export default FloatingSidebar

View File

@@ -51,7 +51,6 @@ const MinAppsPopover: FC<Props> = ({ children }) => {
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
</Center>
)}
<App isLast app={minapps[0]} onClick={handleClose} size={50} />
</AppsContainer>
</PopoverContent>
)

View File

@@ -0,0 +1,97 @@
import { useChatContext } from '@renderer/hooks/useChatContext'
import { Topic } from '@renderer/types'
import { Button, Tooltip } from 'antd'
import { Copy, Save, Trash, X } from 'lucide-react'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface Props {
topic: Topic
}
const MultiSelectActionPopup: FC<Props> = ({ topic }) => {
const { t } = useTranslation()
const { toggleMultiSelectMode, selectedMessageIds, isMultiSelectMode, handleMultiSelectAction } =
useChatContext(topic)
const handleAction = (action: string) => {
handleMultiSelectAction(action, selectedMessageIds)
}
const handleClose = () => {
toggleMultiSelectMode(false)
}
if (!isMultiSelectMode) return null
// TODO: 视情况调整
// const isActionDisabled = selectedMessages.some((msg) => msg.role === 'user')
const isActionDisabled = false
return (
<Container>
<ActionBar>
<SelectionCount>{t('common.selectedMessages', { count: selectedMessageIds.length })}</SelectionCount>
<ActionButtons>
<Tooltip title={t('common.save')}>
<ActionButton icon={<Save size={16} />} disabled={isActionDisabled} onClick={() => handleAction('save')} />
</Tooltip>
<Tooltip title={t('common.copy')}>
<ActionButton icon={<Copy size={16} />} disabled={isActionDisabled} onClick={() => handleAction('copy')} />
</Tooltip>
<Tooltip title={t('common.delete')}>
<ActionButton danger icon={<Trash size={16} />} onClick={() => handleAction('delete')} />
</Tooltip>
</ActionButtons>
<Tooltip title={t('chat.navigation.close')}>
<ActionButton icon={<X size={16} />} onClick={handleClose} />
</Tooltip>
</ActionBar>
</Container>
)
}
const Container = styled.div`
width: 100%;
padding: 36px 20px;
background-color: var(--color-background);
border-top: 1px solid var(--color-border);
`
const ActionBar = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
`
const ActionButtons = styled.div`
display: flex;
gap: 16px;
`
const ActionButton = styled(Button)`
display: flex;
align-items: center;
justify-content: center;
padding: 8px 16px;
border-radius: 50%;
.anticon {
font-size: 16px;
}
&:hover {
background-color: var(--color-background-mute);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`
const SelectionCount = styled.div`
margin-right: 15px;
color: var(--color-text-2);
font-size: 14px;
`
export default MultiSelectActionPopup

View File

@@ -647,7 +647,6 @@ const QuickPanelItem = styled.div`
border-radius: 6px;
cursor: pointer;
transition: background-color 0.1s ease;
font-family: Ubuntu;
&.selected {
background-color: var(--selected-color);
&.focused {

View File

@@ -2,12 +2,13 @@ import { throttle } from 'lodash'
import { FC, useCallback, useEffect, useRef, useState } from 'react'
import styled from 'styled-components'
interface Props extends React.HTMLAttributes<HTMLDivElement> {
interface Props extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onScroll'> {
right?: boolean
ref?: any
ref?: React.RefObject<HTMLDivElement | null>
onScroll?: () => void // Custom onScroll prop for useScrollPosition's handleScroll
}
const Scrollbar: FC<Props> = ({ ref, ...props }: Props & { ref?: React.RefObject<HTMLDivElement | null> }) => {
const Scrollbar: FC<Props> = ({ ref: passedRef, children, onScroll: externalOnScroll, ...htmlProps }) => {
const [isScrolling, setIsScrolling] = useState(false)
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
@@ -21,18 +22,31 @@ const Scrollbar: FC<Props> = ({ ref, ...props }: Props & { ref?: React.RefObject
timeoutRef.current = setTimeout(() => setIsScrolling(false), 1500)
}, [])
const throttledHandleScroll = throttle(handleScroll, 200)
const throttledInternalScrollHandler = throttle(handleScroll, 200)
// Combined scroll handler
const combinedOnScroll = useCallback(() => {
// Event is available if needed by internal handler
throttledInternalScrollHandler() // Call internal logic
if (externalOnScroll) {
externalOnScroll() // Call external logic (from useScrollPosition)
}
}, [throttledInternalScrollHandler, externalOnScroll])
useEffect(() => {
return () => {
timeoutRef.current && clearTimeout(timeoutRef.current)
throttledHandleScroll.cancel()
throttledInternalScrollHandler.cancel()
}
}, [throttledHandleScroll])
}, [throttledInternalScrollHandler])
return (
<Container {...props} isScrolling={isScrolling} onScroll={throttledHandleScroll} ref={ref}>
{props.children}
<Container
{...htmlProps} // Pass other HTML attributes
isScrolling={isScrolling}
onScroll={combinedOnScroll} // Use the combined handler
ref={passedRef}>
{children}
</Container>
)
}

View File

@@ -1,4 +1,5 @@
import { isLinux, isMac, isWindows } from '@renderer/config/constant'
import { useFullscreen } from '@renderer/hooks/useFullscreen'
import useNavBackgroundColor from '@renderer/hooks/useNavBackgroundColor'
import type { FC, PropsWithChildren } from 'react'
import type { HTMLAttributes } from 'react'
@@ -25,7 +26,12 @@ export const NavbarCenter: FC<Props> = ({ children, ...props }) => {
}
export const NavbarRight: FC<Props> = ({ children, ...props }) => {
return <NavbarRightContainer {...props}>{children}</NavbarRightContainer>
const isFullscreen = useFullscreen()
return (
<NavbarRightContainer {...props} $isFullscreen={isFullscreen}>
{children}
</NavbarRightContainer>
)
}
const NavbarContainer = styled.div`
@@ -58,11 +64,11 @@ const NavbarCenterContainer = styled.div`
color: var(--color-text-1);
`
const NavbarRightContainer = styled.div`
const NavbarRightContainer = styled.div<{ $isFullscreen: boolean }>`
min-width: var(--topic-list-width);
display: flex;
align-items: center;
padding: 0 12px;
padding-right: ${isWindows ? '140px' : isLinux ? '120px' : '12px'};
padding-right: ${({ $isFullscreen }) => ($isFullscreen ? '12px' : isWindows ? '140px' : isLinux ? '120px' : '12px')};
justify-content: flex-end;
`

View File

@@ -3,6 +3,7 @@ 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 { useFullscreen } from '@renderer/hooks/useFullscreen'
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
import { useMinapps } from '@renderer/hooks/useMinapps'
import useNavBackgroundColor from '@renderer/hooks/useNavBackgroundColor'
@@ -68,8 +69,13 @@ const Sidebar: FC = () => {
})
}
const isFullscreen = useFullscreen()
return (
<Container id="app-sidebar" style={{ backgroundColor, zIndex: minappShow ? 10000 : 'initial' }}>
<Container
$isFullscreen={isFullscreen}
id="app-sidebar"
style={{ backgroundColor, zIndex: minappShow ? 10000 : 'initial' }}>
{isEmoji(avatar) ? (
<EmojiAvatar onClick={onEditUser} className="sidebar-avatar" size={31} fontSize={18}>
{avatar}
@@ -311,7 +317,7 @@ const PinnedApps: FC = () => {
)
}
const Container = styled.div`
const Container = styled.div<{ $isFullscreen: boolean }>`
display: flex;
flex-direction: column;
align-items: center;
@@ -319,9 +325,9 @@ const Container = styled.div`
padding-bottom: 12px;
width: var(--sidebar-width);
min-width: var(--sidebar-width);
height: ${isMac ? 'calc(100vh - var(--navbar-height))' : '100vh'};
height: ${({ $isFullscreen }) => (isMac && !$isFullscreen ? 'calc(100vh - var(--navbar-height))' : '100vh')};
-webkit-app-region: drag !important;
margin-top: ${isMac ? 'var(--navbar-height)' : 0};
margin-top: ${({ $isFullscreen }) => (isMac && !$isFullscreen ? 'var(--navbar-height)' : 0)};
.sidebar-avatar {
margin-bottom: ${isMac ? '12px' : '12px'};

View File

@@ -3,8 +3,6 @@ export const DEFAULT_CONTEXTCOUNT = 5
export const DEFAULT_MAX_TOKENS = 4096
export const DEFAULT_KNOWLEDGE_DOCUMENT_COUNT = 6
export const DEFAULT_KNOWLEDGE_THRESHOLD = 0.0
export const FONT_FAMILY =
"Ubuntu, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif"
export const platform = window.electron?.process?.platform
export const isMac = platform === 'darwin'
@@ -12,6 +10,7 @@ export const isWindows = platform === 'win32' || platform === 'win64'
export const isLinux = platform === 'linux'
export const SILICON_CLIENT_ID = 'SFaJLLq0y6CAMoyDm81aMu'
export const TOKENFLUX_HOST = 'https://tokenflux.ai'
// Messages loading configuration
export const INITIAL_MESSAGES_COUNT = 20

View File

@@ -18,6 +18,7 @@ import FlowithAppLogo from '@renderer/assets/images/apps/flowith.svg?url'
import GeminiAppLogo from '@renderer/assets/images/apps/gemini.png?url'
import GensparkLogo from '@renderer/assets/images/apps/genspark.jpg?url'
import GithubCopilotLogo from '@renderer/assets/images/apps/github-copilot.webp?url'
import GoogleAppLogo from '@renderer/assets/images/apps/google.svg?url'
import GrokAppLogo from '@renderer/assets/images/apps/grok.png?url'
import GrokXAppLogo from '@renderer/assets/images/apps/grok-x.png?url'
import HikaLogo from '@renderer/assets/images/apps/hika.webp?url'
@@ -163,8 +164,9 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [
{
id: 'minimax',
name: '海螺',
url: 'https://hailuoai.com/',
logo: HailuoModelLogo
url: 'https://chat.minimaxi.com/',
logo: HailuoModelLogo,
bodered: true
},
{
id: 'groq',
@@ -178,6 +180,16 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [
url: 'https://claude.ai/',
logo: ClaudeAppLogo
},
{
id: 'google',
name: 'Google',
url: 'https://google.com/',
logo: GoogleAppLogo,
bodered: true,
style: {
padding: 5
}
},
{
id: 'baidu-ai-chat',
name: '文心一言',

View File

@@ -34,8 +34,10 @@ import DianxinModelLogo from '@renderer/assets/images/models/dianxin.png'
import DianxinModelLogoDark from '@renderer/assets/images/models/dianxin_dark.png'
import DoubaoModelLogo from '@renderer/assets/images/models/doubao.png'
import DoubaoModelLogoDark from '@renderer/assets/images/models/doubao_dark.png'
import EmbeddingModelLogo from '@renderer/assets/images/models/embedding.png'
import EmbeddingModelLogoDark from '@renderer/assets/images/models/embedding.png'
import {
default as EmbeddingModelLogo,
default as EmbeddingModelLogoDark
} from '@renderer/assets/images/models/embedding.png'
import FlashaudioModelLogo from '@renderer/assets/images/models/flashaudio.png'
import FlashaudioModelLogoDark from '@renderer/assets/images/models/flashaudio_dark.png'
import FluxModelLogo from '@renderer/assets/images/models/flux.png'
@@ -44,14 +46,15 @@ import GeminiModelLogo from '@renderer/assets/images/models/gemini.png'
import GeminiModelLogoDark from '@renderer/assets/images/models/gemini_dark.png'
import GemmaModelLogo from '@renderer/assets/images/models/gemma.png'
import GemmaModelLogoDark from '@renderer/assets/images/models/gemma_dark.png'
import GoogleModelLogo from '@renderer/assets/images/models/google.png'
import GoogleModelLogoDark from '@renderer/assets/images/models/google.png'
import { default as GoogleModelLogo, default as GoogleModelLogoDark } from '@renderer/assets/images/models/google.png'
import ChatGPT35ModelLogo from '@renderer/assets/images/models/gpt_3.5.png'
import ChatGPT4ModelLogo from '@renderer/assets/images/models/gpt_4.png'
import ChatGptModelLogoDakr from '@renderer/assets/images/models/gpt_dark.png'
import ChatGPT35ModelLogoDark from '@renderer/assets/images/models/gpt_dark.png'
import ChatGPT4ModelLogoDark from '@renderer/assets/images/models/gpt_dark.png'
import ChatGPTo1ModelLogoDark from '@renderer/assets/images/models/gpt_dark.png'
import {
default as ChatGPT4ModelLogoDark,
default as ChatGPT35ModelLogoDark,
default as ChatGptModelLogoDakr,
default as ChatGPTo1ModelLogoDark
} from '@renderer/assets/images/models/gpt_dark.png'
import ChatGPTo1ModelLogo from '@renderer/assets/images/models/gpt_o1.png'
import GrokModelLogo from '@renderer/assets/images/models/grok.png'
import GrokModelLogoDark from '@renderer/assets/images/models/grok_dark.png'
@@ -86,22 +89,28 @@ import MicrosoftModelLogo from '@renderer/assets/images/models/microsoft.png'
import MicrosoftModelLogoDark from '@renderer/assets/images/models/microsoft_dark.png'
import MidjourneyModelLogo from '@renderer/assets/images/models/midjourney.png'
import MidjourneyModelLogoDark from '@renderer/assets/images/models/midjourney_dark.png'
import MinicpmModelLogo from '@renderer/assets/images/models/minicpm.webp'
import MinicpmModelLogoDark from '@renderer/assets/images/models/minicpm.webp'
import {
default as MinicpmModelLogo,
default as MinicpmModelLogoDark
} from '@renderer/assets/images/models/minicpm.webp'
import MinimaxModelLogo from '@renderer/assets/images/models/minimax.png'
import MinimaxModelLogoDark from '@renderer/assets/images/models/minimax_dark.png'
import MistralModelLogo from '@renderer/assets/images/models/mixtral.png'
import MistralModelLogoDark from '@renderer/assets/images/models/mixtral_dark.png'
import MoonshotModelLogo from '@renderer/assets/images/models/moonshot.png'
import MoonshotModelLogoDark from '@renderer/assets/images/models/moonshot_dark.png'
import NousResearchModelLogo from '@renderer/assets/images/models/nousresearch.png'
import NousResearchModelLogoDark from '@renderer/assets/images/models/nousresearch.png'
import {
default as NousResearchModelLogo,
default as NousResearchModelLogoDark
} from '@renderer/assets/images/models/nousresearch.png'
import NvidiaModelLogo from '@renderer/assets/images/models/nvidia.png'
import NvidiaModelLogoDark from '@renderer/assets/images/models/nvidia_dark.png'
import PalmModelLogo from '@renderer/assets/images/models/palm.png'
import PalmModelLogoDark from '@renderer/assets/images/models/palm_dark.png'
import PerplexityModelLogo from '@renderer/assets/images/models/perplexity.png'
import PerplexityModelLogoDark from '@renderer/assets/images/models/perplexity.png'
import {
default as PerplexityModelLogo,
default as PerplexityModelLogoDark
} from '@renderer/assets/images/models/perplexity.png'
import PixtralModelLogo from '@renderer/assets/images/models/pixtral.png'
import PixtralModelLogoDark from '@renderer/assets/images/models/pixtral_dark.png'
import QwenModelLogo from '@renderer/assets/images/models/qwen.png'
@@ -118,6 +127,8 @@ import SunoModelLogo from '@renderer/assets/images/models/suno.png'
import SunoModelLogoDark from '@renderer/assets/images/models/suno_dark.png'
import TeleModelLogo from '@renderer/assets/images/models/tele.png'
import TeleModelLogoDark from '@renderer/assets/images/models/tele_dark.png'
import TokenFluxModelLogo from '@renderer/assets/images/models/tokenflux.png'
import TokenFluxModelLogoDark from '@renderer/assets/images/models/tokenflux_dark.png'
import UpstageModelLogo from '@renderer/assets/images/models/upstage.png'
import UpstageModelLogoDark from '@renderer/assets/images/models/upstage_dark.png'
import ViduModelLogo from '@renderer/assets/images/models/vidu.png'
@@ -146,6 +157,8 @@ const visionAllowedModels = [
'gemini-2\\.5',
'gemini-exp',
'claude-3',
'claude-sonnet-4',
'claude-opus-4',
'vision',
'glm-4v',
'qwen-vl',
@@ -232,7 +245,7 @@ export const FUNCTION_CALLING_REGEX = new RegExp(
)
export const CLAUDE_SUPPORTED_WEBSEARCH_REGEX = new RegExp(
`\\b(?:claude-3(-|\\.)(7|5)-sonnet(?:-[\\w-]+)|claude-3(-|\\.)5-haiku(?:-[\\w-]+))\\b`,
`\\b(?:claude-3(-|\\.)(7|5)-sonnet(?:-[\\w-]+)|claude-3(-|\\.)5-haiku(?:-[\\w-]+)|claude-sonnet-4(?:-[\\w-]+)?|claude-opus-4(?:-[\\w-]+)?)\\b`,
'i'
)
@@ -367,7 +380,8 @@ export function getModelLogo(modelId: string) {
perplexity: isLight ? PerplexityModelLogo : PerplexityModelLogoDark,
sonar: isLight ? PerplexityModelLogo : PerplexityModelLogoDark,
'bge-': BgeModelLogo,
'voyage-': VoyageModelLogo
'voyage-': VoyageModelLogo,
tokenflux: isLight ? TokenFluxModelLogo : TokenFluxModelLogoDark
}
for (const key in logoMap) {
@@ -419,6 +433,30 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
group: 'Qwen'
}
],
burncloud: [
{ id: 'claude-3-7-sonnet-20250219-thinking', provider: 'burncloud', name: 'Claude 3.7 thinking', group: 'Claude' },
{ id: 'claude-3-7-sonnet-20250219', provider: 'burncloud', name: 'Claude 3.7 Sonnet', group: 'Claude 3.7' },
{ id: 'claude-3-5-sonnet-20241022', provider: 'burncloud', name: 'Claude 3.5 Sonnet', group: 'Claude 3.5' },
{ id: 'claude-3-5-haiku-20241022', provider: 'burncloud', name: 'Claude 3.5 Haiku', group: 'Claude 3.5' },
{ id: 'gpt-4.5-preview', provider: 'burncloud', name: 'gpt-4.5-preview', group: 'gpt-4.5' },
{ id: 'gpt-4o', provider: 'burncloud', name: 'GPT-4o', group: 'GPT 4o' },
{ id: 'gpt-4o-mini', provider: 'burncloud', name: 'GPT-4o-mini', group: 'GPT 4o' },
{ id: 'o3', provider: 'burncloud', name: 'GPT-o1-mini', group: 'o1' },
{ id: 'o3-mini', provider: 'burncloud', name: 'GPT-o1-preview', group: 'o1' },
{ id: 'o1-mini', provider: 'burncloud', name: 'GPT-o1-mini', group: 'o1' },
{ id: 'gemini-2.5-pro-preview-03-25', provider: 'burncloud', name: 'Gemini 2.5 Preview', group: 'Geminit 2.5' },
{ id: 'gemini-2.5-pro-exp-03-25', provider: 'burncloud', name: 'Gemini 2.5 Pro Exp', group: 'Geminit 2.5' },
{ id: 'gemini-2.0-flash-lite', provider: 'burncloud', name: 'Gemini 2.0 Flash Lite', group: 'Geminit 2.0' },
{ id: 'gemini-2.0-flash-exp', provider: 'burncloud', name: 'Gemini 2.0 Flash Exp', group: 'Geminit 2.0' },
{ id: 'gemini-2.0-flash', provider: 'burncloud', name: 'Gemini 2.0 Flash', group: 'Geminit 2.0' },
{ id: 'deepseek-r1', name: 'DeepSeek-R1', provider: 'burncloud', group: 'deepseek-ai' },
{ id: 'deepseek-v3', name: 'DeepSeek-V3', provider: 'burncloud', group: 'deepseek-ai' }
],
o3: [
{
id: 'gpt-4o',
@@ -686,6 +724,18 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
}
],
anthropic: [
{
id: 'claude-sonnet-4-20250514',
provider: 'anthropic',
name: 'Claude Sonnet 4',
group: 'Claude 4'
},
{
id: 'claude-opus-4-20250514',
provider: 'anthropic',
name: 'Claude Opus 4',
group: 'Claude 4'
},
{
id: 'claude-3-7-sonnet-20250219',
provider: 'anthropic',
@@ -725,46 +775,34 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
],
'gitee-ai': [
{
id: 'DeepSeek-R1-Distill-Qwen-32B',
name: 'DeepSeek-R1-Distill-Qwen-32B',
id: 'Qwen3-30B-A3B',
name: 'Qwen3-30B-A3B',
provider: 'gitee-ai',
group: 'DeepSeek'
group: 'Qwen'
},
{
id: 'DeepSeek-R1-Distill-Qwen-1.5B',
name: 'DeepSeek-R1-Distill-Qwen-1.5B',
id: 'Qwen3-32B',
name: 'Qwen3-32B',
provider: 'gitee-ai',
group: 'DeepSeek'
group: 'Qwen'
},
{
id: 'DeepSeek-R1-Distill-Qwen-14B',
name: 'DeepSeek-R1-Distill-Qwen-14B',
id: 'Qwen3-8B',
name: 'Qwen3-8B',
provider: 'gitee-ai',
group: 'DeepSeek'
group: 'Qwen'
},
{
id: 'DeepSeek-R1-Distill-Qwen-7B',
name: 'DeepSeek-R1-Distill-Qwen-7B',
id: 'Qwen3-4B',
name: 'Qwen3-4B',
provider: 'gitee-ai',
group: 'DeepSeek'
group: 'Qwen'
},
{
id: 'DeepSeek-V3',
name: 'DeepSeek-V3',
id: 'Qwen3-0.6B',
name: 'Qwen3-0.6B',
provider: 'gitee-ai',
group: 'DeepSeek'
},
{
id: 'DeepSeek-R1',
name: 'DeepSeek-R1',
provider: 'gitee-ai',
group: 'DeepSeek'
},
{
id: 'deepseek-coder-33B-instruct',
name: 'deepseek-coder-33B-instruct',
provider: 'gitee-ai',
group: 'DeepSeek'
group: 'Qwen'
},
{
id: 'Qwen2.5-72B-Instruct',
@@ -803,11 +841,23 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
group: 'Qwen'
},
{
id: 'QwQ-32B-Preview',
name: 'QwQ-32B-Preview',
id: 'Qwen2.5-VL-32B-Instruct',
name: 'Qwen2.5-VL-32B-Instruct',
provider: 'gitee-ai',
group: 'Qwen'
},
{
id: 'QwQ-32B',
name: 'QwQ-32B',
provider: 'gitee-ai',
group: 'Qwen'
},
{
id: 'Align-DS-V',
name: 'Align-DS-V',
provider: 'gitee-ai',
group: 'Align'
},
{
id: 'Yi-34B-Chat',
name: 'Yi-34B-Chat',
@@ -820,6 +870,12 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
provider: 'gitee-ai',
group: 'THUDM'
},
{
id: 'deepseek-coder-33B-instruct',
name: 'deepseek-coder-33B-instruct',
provider: 'gitee-ai',
group: 'DeepSeek'
},
{
id: 'codegeex4-all-9b',
name: 'codegeex4-all-9b',
@@ -844,6 +900,48 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
provider: 'gitee-ai',
group: 'OpenGVLab'
},
{
id: 'DeepSeek-R1-Distill-Qwen-32B',
name: 'DeepSeek-R1-Distill-Qwen-32B',
provider: 'gitee-ai',
group: 'DeepSeek'
},
{
id: 'DeepSeek-R1-Distill-Qwen-1.5B',
name: 'DeepSeek-R1-Distill-Qwen-1.5B',
provider: 'gitee-ai',
group: 'DeepSeek'
},
{
id: 'DeepSeek-R1-Distill-Qwen-14B',
name: 'DeepSeek-R1-Distill-Qwen-14B',
provider: 'gitee-ai',
group: 'DeepSeek'
},
{
id: 'DeepSeek-R1-Distill-Qwen-7B',
name: 'DeepSeek-R1-Distill-Qwen-7B',
provider: 'gitee-ai',
group: 'DeepSeek'
},
{
id: 'DeepSeek-V3',
name: 'DeepSeek-V3',
provider: 'gitee-ai',
group: 'DeepSeek'
},
{
id: 'DeepSeek-R1',
name: 'DeepSeek-R1',
provider: 'gitee-ai',
group: 'DeepSeek'
},
{
id: 'gemma-3-27b-it',
name: 'gemma-3-27b-it',
provider: 'gitee-ai',
group: 'Gemma'
},
{
id: 'bge-large-zh-v1.5',
name: 'bge-large-zh-v1.5',
@@ -2074,6 +2172,68 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
name: 'Qwen2.5 72B Instruct',
group: 'Qwen'
}
],
tokenflux: [
{
id: 'gpt-4.1',
provider: 'tokenflux',
name: 'GPT-4.1',
group: 'GPT-4.1'
},
{
id: 'gpt-4.1-mini',
provider: 'tokenflux',
name: 'GPT-4.1 Mini',
group: 'GPT-4.1'
},
{
id: 'claude-sonnet-4',
provider: 'tokenflux',
name: 'Claude Sonnet 4',
group: 'Claude'
},
{
id: 'claude-3-7-sonnet',
provider: 'tokenflux',
name: 'Claude 3.7 Sonnet',
group: 'Claude'
},
{
id: 'gemini-2.5-pro',
provider: 'tokenflux',
name: 'Gemini 2.5 Pro',
group: 'Gemini'
},
{
id: 'gemini-2.5-flash',
provider: 'tokenflux',
name: 'Gemini 2.5 Flash',
group: 'Gemini'
},
{
id: 'deepseek-r1',
provider: 'tokenflux',
name: 'DeepSeek R1',
group: 'DeepSeek'
},
{
id: 'deepseek-v3',
provider: 'tokenflux',
name: 'DeepSeek V3',
group: 'DeepSeek'
},
{
id: 'qwen-max',
provider: 'tokenflux',
name: 'Qwen Max',
group: 'Qwen'
},
{
id: 'qwen-plus',
provider: 'tokenflux',
name: 'Qwen Plus',
group: 'Qwen'
}
]
}
@@ -2245,6 +2405,20 @@ export function isOpenAILLMModel(model: Model): boolean {
return false
}
export function isOpenAIModel(model: Model): boolean {
if (!model) {
return false
}
return model.id.includes('gpt') || isOpenAIReasoningModel(model)
}
export function isSupportedFlexServiceTier(model: Model): boolean {
if (!model) {
return false
}
return (model.id.includes('o3') && !model.id.includes('o3-mini')) || model.id.includes('o4-mini')
}
export function isSupportedReasoningEffortOpenAIModel(model: Model): boolean {
return (
(model.id.includes('o1') && !(model.id.includes('o1-preview') || model.id.includes('o1-mini'))) ||
@@ -2350,7 +2524,12 @@ export function isClaudeReasoningModel(model?: Model): boolean {
if (!model) {
return false
}
return model.id.includes('claude-3-7-sonnet') || model.id.includes('claude-3.7-sonnet')
return (
model.id.includes('claude-3-7-sonnet') ||
model.id.includes('claude-3.7-sonnet') ||
model.id.includes('claude-sonnet-4') ||
model.id.includes('claude-opus-4')
)
}
export const isSupportedThinkingTokenClaudeModel = isClaudeReasoningModel
@@ -2484,6 +2663,10 @@ export function isWebSearchModel(model: Model): boolean {
return true
}
if (provider.id === 'grok') {
return true
}
return false
}
@@ -2514,6 +2697,16 @@ export function getOpenAIWebSearchParams(assistant: Assistant, model: Model): Re
if (assistant.enableWebSearch) {
const webSearchTools = getWebSearchTools(model)
if (model.provider === 'grok') {
return {
search_parameters: {
mode: 'auto',
return_citations: true,
sources: [{ type: 'web' }, { type: 'x' }, { type: 'news' }]
}
}
}
if (model.provider === 'hunyuan') {
return { enable_enhancement: true, citation: true, search_info: true }
}
@@ -2613,10 +2806,11 @@ export const THINKING_TOKEN_MAP: Record<string, { min: number; max: number }> =
'qwen-turbo-.*$': { min: 0, max: 38912 },
'qwen3-0\\.6b$': { min: 0, max: 30720 },
'qwen3-1\\.7b$': { min: 0, max: 30720 },
'qwen3-.*$': { min: 0, max: 38912 },
'qwen3-.*$': { min: 1024, max: 38912 },
// Claude models
'claude-3[.-]7.*sonnet.*$': { min: 0, max: 64000 }
'claude-3[.-]7.*sonnet.*$': { min: 1024, max: 64000 },
'claude-(:?sonnet|opus)-4.*$': { min: 1024, max: 64000 }
}
export const findTokenLimit = (modelId: string): { min: number; max: number } | undefined => {

View File

@@ -7,6 +7,7 @@ import AnthropicProviderLogo from '@renderer/assets/images/providers/anthropic.p
import BaichuanProviderLogo from '@renderer/assets/images/providers/baichuan.png'
import BaiduCloudProviderLogo from '@renderer/assets/images/providers/baidu-cloud.svg'
import BailianProviderLogo from '@renderer/assets/images/providers/bailian.png'
import BurnCloudProviderLogo from '@renderer/assets/images/providers/burncloud.png'
import DeepSeekProviderLogo from '@renderer/assets/images/providers/deepseek.png'
import DmxapiProviderLogo from '@renderer/assets/images/providers/DMXAPI.png'
import FireworksProviderLogo from '@renderer/assets/images/providers/fireworks.png'
@@ -37,12 +38,15 @@ import SiliconFlowProviderLogo from '@renderer/assets/images/providers/silicon.p
import StepProviderLogo from '@renderer/assets/images/providers/step.png'
import TencentCloudProviderLogo from '@renderer/assets/images/providers/tencent-cloud-ti.png'
import TogetherProviderLogo from '@renderer/assets/images/providers/together.png'
import TokenFluxProviderLogo from '@renderer/assets/images/providers/tokenflux.png'
import BytedanceProviderLogo from '@renderer/assets/images/providers/volcengine.png'
import VoyageAIProviderLogo from '@renderer/assets/images/providers/voyageai.png'
import XirangProviderLogo from '@renderer/assets/images/providers/xirang.png'
import ZeroOneProviderLogo from '@renderer/assets/images/providers/zero-one.png'
import ZhipuProviderLogo from '@renderer/assets/images/providers/zhipu.png'
import { TOKENFLUX_HOST } from './constant'
const PROVIDER_LOGO_MAP = {
openai: OpenAiProviderLogo,
silicon: SiliconFlowProviderLogo,
@@ -61,6 +65,7 @@ const PROVIDER_LOGO_MAP = {
xirang: XirangProviderLogo,
anthropic: AnthropicProviderLogo,
aihubmix: AiHubMixProviderLogo,
burncloud: BurnCloudProviderLogo,
gemini: GoogleProviderLogo,
stepfun: StepProviderLogo,
doubao: BytedanceProviderLogo,
@@ -88,7 +93,8 @@ const PROVIDER_LOGO_MAP = {
gpustack: GPUStackProviderLogo,
alayanew: AlayaNewProviderLogo,
voyageai: VoyageAIProviderLogo,
qiniu: QiniuProviderLogo
qiniu: QiniuProviderLogo,
tokenflux: TokenFluxProviderLogo
} as const
export function getProviderLogo(providerId: string) {
@@ -121,6 +127,17 @@ export const PROVIDER_CONFIG = {
models: 'https://docs.o3.fan/models'
}
},
burncloud: {
api: {
url: 'https://ai.burncloud.com'
},
websites: {
official: 'https://ai.burncloud.com/',
apiKey: 'https://ai.burncloud.com/token',
docs: 'https://ai.burncloud.com/docs',
models: 'https://ai.burncloud.com/pricing'
}
},
ppio: {
api: {
url: 'https://api.ppinfra.com/v3/openai'
@@ -584,5 +601,16 @@ export const PROVIDER_CONFIG = {
docs: 'https://developer.qiniu.com/aitokenapi',
models: 'https://developer.qiniu.com/aitokenapi/12883/model-list'
}
},
tokenflux: {
api: {
url: TOKENFLUX_HOST
},
websites: {
official: TOKENFLUX_HOST,
apiKey: `${TOKENFLUX_HOST}/dashboard/api-keys`,
docs: `${TOKENFLUX_HOST}/docs`,
models: `${TOKENFLUX_HOST}/models`
}
}
}

View File

@@ -37,10 +37,14 @@ const AntdProvider: FC<PropsWithChildren> = ({ children }) => {
},
Collapse: {
headerBg: 'transparent'
},
Tooltip: {
fontSize: 13
}
},
token: {
colorPrimary: '#00b96b'
colorPrimary: '#00b96b',
fontFamily: 'var(--font-family)'
}
}}>
{children}

View File

@@ -0,0 +1,182 @@
import { useTheme } from '@renderer/context/ThemeProvider'
import { useMermaid } from '@renderer/hooks/useMermaid'
import { useSettings } from '@renderer/hooks/useSettings'
import { HighlightChunkResult, ShikiPreProperties, shikiStreamService } from '@renderer/services/ShikiStreamService'
import { ThemeMode } from '@renderer/types'
import { getHighlighter, getMarkdownIt, getShiki, loadLanguageIfNeeded, loadThemeIfNeeded } from '@renderer/utils/shiki'
import * as cmThemes from '@uiw/codemirror-themes-all'
import type React from 'react'
import { createContext, type PropsWithChildren, use, useCallback, useEffect, useMemo, useState } from 'react'
interface CodeStyleContextType {
highlightCodeChunk: (trunk: string, language: string, callerId: string) => Promise<HighlightChunkResult>
cleanupTokenizers: (callerId: string) => void
getShikiPreProperties: (language: string) => Promise<ShikiPreProperties>
highlightCode: (code: string, language: string) => Promise<string>
shikiMarkdownIt: (code: string) => Promise<string>
themeNames: string[]
activeShikiTheme: string
activeCmTheme: any
languageMap: Record<string, string>
}
const defaultCodeStyleContext: CodeStyleContextType = {
highlightCodeChunk: async () => ({ lines: [], recall: 0 }),
cleanupTokenizers: () => {},
getShikiPreProperties: async () => ({ class: '', style: '', tabindex: 0 }),
highlightCode: async () => '',
shikiMarkdownIt: async () => '',
themeNames: ['auto'],
activeShikiTheme: 'auto',
activeCmTheme: null,
languageMap: {}
}
const CodeStyleContext = createContext<CodeStyleContextType>(defaultCodeStyleContext)
export const CodeStyleProvider: React.FC<PropsWithChildren> = ({ children }) => {
const { codeEditor, codePreview } = useSettings()
const { theme } = useTheme()
const [shikiThemes, setShikiThemes] = useState({})
useMermaid()
useEffect(() => {
if (!codeEditor.enabled) {
getShiki().then(({ bundledThemes }) => {
setShikiThemes(bundledThemes)
})
}
}, [codeEditor.enabled])
// 获取支持的主题名称列表
const themeNames = useMemo(() => {
// CodeMirror 主题
// 更保险的做法可能是硬编码主题列表
if (codeEditor.enabled) {
return ['auto', 'light', 'dark']
.concat(Object.keys(cmThemes))
.filter((item) => typeof cmThemes[item as keyof typeof cmThemes] !== 'function')
.filter((item) => !/^(defaultSettings)/.test(item as string) && !/(Style)$/.test(item as string))
}
// Shiki 主题
return ['auto', ...Object.keys(shikiThemes)]
}, [codeEditor.enabled, shikiThemes])
// 获取当前使用的 Shiki 主题名称(只用于代码预览)
const activeShikiTheme = useMemo(() => {
const field = theme === ThemeMode.light ? 'themeLight' : 'themeDark'
const codeStyle = codePreview[field]
if (!codeStyle || codeStyle === 'auto' || !themeNames.includes(codeStyle)) {
return theme === ThemeMode.light ? 'one-light' : 'material-theme-darker'
}
return codeStyle
}, [theme, codePreview, themeNames])
// 获取当前使用的 CodeMirror 主题对象(只用于编辑器)
const activeCmTheme = useMemo(() => {
const field = theme === ThemeMode.light ? 'themeLight' : 'themeDark'
let themeName = codeEditor[field]
if (!themeName || themeName === 'auto' || !themeNames.includes(themeName)) {
themeName = theme === ThemeMode.light ? 'materialLight' : 'dark'
}
return cmThemes[themeName as keyof typeof cmThemes] || themeName
}, [theme, codeEditor, themeNames])
// 一些语言的别名
const languageMap = useMemo(() => {
return {
bash: 'shell',
'objective-c++': 'objective-cpp',
svg: 'xml',
vab: 'vb'
} as Record<string, string>
}, [])
useEffect(() => {
// 在组件卸载时清理 Worker
return () => {
shikiStreamService.dispose()
}
}, [])
// 流式代码高亮,返回已高亮的 token lines
const highlightCodeChunk = useCallback(
async (trunk: string, language: string, callerId: string) => {
const normalizedLang = languageMap[language as keyof typeof languageMap] || language.toLowerCase()
return shikiStreamService.highlightCodeChunk(trunk, normalizedLang, activeShikiTheme, callerId)
},
[activeShikiTheme, languageMap]
)
// 清理代码高亮资源
const cleanupTokenizers = useCallback((callerId: string) => {
shikiStreamService.cleanupTokenizers(callerId)
}, [])
// 获取 Shiki pre 标签属性
const getShikiPreProperties = useCallback(
async (language: string) => {
const normalizedLang = languageMap[language as keyof typeof languageMap] || language.toLowerCase()
return shikiStreamService.getShikiPreProperties(normalizedLang, activeShikiTheme)
},
[activeShikiTheme, languageMap]
)
const highlightCode = useCallback(
async (code: string, language: string) => {
const highlighter = await getHighlighter()
await loadLanguageIfNeeded(highlighter, language)
await loadThemeIfNeeded(highlighter, activeShikiTheme)
return highlighter.codeToHtml(code, { lang: language, theme: activeShikiTheme })
},
[activeShikiTheme]
)
// 使用 Shiki 和 Markdown-it 渲染代码
const shikiMarkdownIt = useCallback(
async (code: string) => {
const renderer = await getMarkdownIt(activeShikiTheme, code)
if (!renderer) {
return code
}
return renderer.render(code)
},
[activeShikiTheme]
)
const contextValue = useMemo(
() => ({
highlightCodeChunk,
cleanupTokenizers,
getShikiPreProperties,
highlightCode,
shikiMarkdownIt,
themeNames,
activeShikiTheme,
activeCmTheme,
languageMap
}),
[
highlightCodeChunk,
cleanupTokenizers,
getShikiPreProperties,
highlightCode,
shikiMarkdownIt,
themeNames,
activeShikiTheme,
activeCmTheme,
languageMap
]
)
return <CodeStyleContext value={contextValue}>{children}</CodeStyleContext>
}
export const useCodeStyle = () => {
const context = use(CodeStyleContext)
if (!context) {
throw new Error('useCodeStyle must be used within a CodeStyleProvider')
}
return context
}

View File

@@ -0,0 +1,33 @@
import { createContext, ReactNode, use, useState } from 'react'
interface MessageEditingContextType {
editingMessageId: string | null
startEditing: (messageId: string) => void
stopEditing: () => void
}
const MessageEditingContext = createContext<MessageEditingContextType | null>(null)
export function MessageEditingProvider({ children }: { children: ReactNode }) {
const [editingMessageId, setEditingMessageId] = useState<string | null>(null)
const startEditing = (messageId: string) => {
setEditingMessageId(messageId)
}
const stopEditing = () => {
setEditingMessageId(null)
}
return (
<MessageEditingContext value={{ editingMessageId, startEditing, stopEditing }}>{children}</MessageEditingContext>
)
}
export function useMessageEditing() {
const context = use(MessageEditingContext)
if (!context) {
throw new Error('useMessageEditing must be used within a MessageEditingProvider')
}
return context
}

View File

@@ -0,0 +1,76 @@
import { NotificationQueue } from '@renderer/queue/NotificationQueue'
import { Notification } from '@renderer/types/notification'
import { isFocused } from '@renderer/utils/window'
import { notification } from 'antd'
import React, { createContext, use, useEffect, useMemo } from 'react'
type NotificationContextType = {
open: typeof notification.open
destroy: typeof notification.destroy
}
const typeMap: Record<string, 'info' | 'success' | 'warning' | 'error'> = {
error: 'error',
success: 'success',
warning: 'warning',
info: 'info',
progress: 'info',
action: 'info'
}
const NotificationContext = createContext<NotificationContextType | undefined>(undefined)
export const NotificationProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [api, contextHolder] = notification.useNotification({
stack: {
threshold: 3
},
showProgress: true
})
useEffect(() => {
const queue = NotificationQueue.getInstance()
const listener = async (notification: Notification) => {
// 判断是否需要系统通知
if (notification.channel === 'system' || !isFocused()) {
window.api.notification.send(notification)
return
}
return new Promise<void>((resolve) => {
api.open({
message: notification.title,
description:
notification.message.length > 50 ? notification.message.slice(0, 47) + '...' : notification.message,
duration: 3,
placement: 'topRight',
type: typeMap[notification.type] || 'info',
key: notification.id,
onClose: resolve
})
})
}
queue.subscribe(listener)
return () => queue.unsubscribe(listener)
}, [api])
const value = useMemo(
() => ({
open: api.open,
destroy: api.destroy
}),
[api]
)
return (
<NotificationContext value={value}>
{contextHolder}
{children}
</NotificationContext>
)
}
export const useNotification = () => {
const ctx = use(NotificationContext)
if (!ctx) throw new Error('useNotification must be used within a NotificationProvider')
return ctx
}

View File

@@ -1,109 +0,0 @@
import { useTheme } from '@renderer/context/ThemeProvider'
import { useMermaid } from '@renderer/hooks/useMermaid'
import { useSettings } from '@renderer/hooks/useSettings'
import { CodeCacheService } from '@renderer/services/CodeCacheService'
import { type CodeStyleVarious, ThemeMode } from '@renderer/types'
import type React from 'react'
import { createContext, type PropsWithChildren, use, useCallback, useMemo } from 'react'
import { bundledLanguages, bundledThemes, createHighlighter, type Highlighter } from 'shiki'
let highlighterPromise: Promise<Highlighter> | null = null
async function getHighlighter() {
if (!highlighterPromise) {
highlighterPromise = createHighlighter({
langs: ['javascript', 'typescript', 'python', 'java', 'markdown'],
themes: ['one-light', 'material-theme-darker']
})
}
return await highlighterPromise
}
interface SyntaxHighlighterContextType {
codeToHtml: (code: string, language: string, enableCache: boolean) => Promise<string>
}
const SyntaxHighlighterContext = createContext<SyntaxHighlighterContextType | undefined>(undefined)
export const SyntaxHighlighterProvider: React.FC<PropsWithChildren> = ({ children }) => {
const { theme } = useTheme()
const { codeStyle } = useSettings()
useMermaid()
const highlighterTheme = useMemo(() => {
if (!codeStyle || codeStyle === 'auto') {
return theme === ThemeMode.light ? 'one-light' : 'material-theme-darker'
}
return codeStyle
}, [theme, codeStyle])
const codeToHtml = useCallback(
async (_code: string, language: string, enableCache: boolean) => {
{
if (!_code) return ''
const key = CodeCacheService.generateCacheKey(_code, language, highlighterTheme)
const cached = enableCache ? CodeCacheService.getCachedResult(key) : null
if (cached) return cached
const languageMap: Record<string, string> = {
vab: 'vb'
}
const mappedLanguage = languageMap[language] || language
const code = _code?.trimEnd() ?? ''
const escapedCode = code?.replace(/[<>]/g, (char) => ({ '<': '&lt;', '>': '&gt;' })[char]!)
try {
const highlighter = await getHighlighter()
if (!highlighter.getLoadedThemes().includes(highlighterTheme)) {
const themeImportFn = bundledThemes[highlighterTheme]
if (themeImportFn) {
await highlighter.loadTheme(await themeImportFn())
}
}
if (!highlighter.getLoadedLanguages().includes(mappedLanguage)) {
const languageImportFn = bundledLanguages[mappedLanguage]
if (languageImportFn) {
await highlighter.loadLanguage(await languageImportFn())
}
}
// 生成高亮HTML
const html = highlighter.codeToHtml(code, {
lang: mappedLanguage,
theme: highlighterTheme
})
// 设置缓存
if (enableCache) {
CodeCacheService.setCachedResult(key, html, _code.length)
}
return html
} catch (error) {
console.debug(`Error highlighting code for language '${mappedLanguage}':`, error)
return `<pre style="padding: 10px"><code>${escapedCode}</code></pre>`
}
}
},
[highlighterTheme]
)
return <SyntaxHighlighterContext value={{ codeToHtml }}>{children}</SyntaxHighlighterContext>
}
export const useSyntaxHighlighter = () => {
const context = use(SyntaxHighlighterContext)
if (!context) {
throw new Error('useSyntaxHighlighter must be used within a SyntaxHighlighterProvider')
}
return context
}
export const codeThemes = ['auto', ...Object.keys(bundledThemes)] as CodeStyleVarious[]

View File

@@ -19,7 +19,6 @@ declare global {
message: MessageInstance
modal: HookAPI
keyv: KeyvStorage
mermaid: any
store: any
navigate: NavigateFunction
}

View File

@@ -3,6 +3,7 @@ import { isLocalAi } from '@renderer/config/env'
import { useTheme } from '@renderer/context/ThemeProvider'
import db from '@renderer/databases'
import i18n from '@renderer/i18n'
import KnowledgeQueue from '@renderer/queue/KnowledgeQueue'
import { useAppDispatch } from '@renderer/store'
import { setAvatar, setFilesPath, setResourcesPath, setUpdateState } from '@renderer/store/runtime'
import { delay, runAsyncFunction } from '@renderer/utils'
@@ -24,6 +25,11 @@ export function useAppInit() {
const avatar = useLiveQuery(() => db.settings.get('image://avatar'))
const { theme } = useTheme()
useEffect(() => {
document.getElementById('spinner')?.remove()
console.timeEnd('init')
}, [])
useUpdateHandler()
useFullScreenNotice()
@@ -32,7 +38,6 @@ export function useAppInit() {
}, [avatar, dispatch])
useEffect(() => {
document.getElementById('spinner')?.remove()
runAsyncFunction(async () => {
const { isPackaged } = await window.api.getAppInfo()
if (isPackaged && autoCheckUpdate) {
@@ -88,7 +93,7 @@ export function useAppInit() {
}, [dispatch])
useEffect(() => {
import('@renderer/queue/KnowledgeQueue')
KnowledgeQueue.checkAllBases()
}, [])
useEffect(() => {

View File

@@ -15,7 +15,7 @@ import {
updateTopic,
updateTopics
} from '@renderer/store/assistants'
import { setDefaultModel, setTopicNamingModel, setTranslateModel } from '@renderer/store/llm'
import { setDefaultModel, setQuickAssistantModel, setTopicNamingModel, setTranslateModel } from '@renderer/store/llm'
import { Assistant, AssistantSettings, Model, Topic } from '@renderer/types'
import { useCallback, useMemo } from 'react'
@@ -103,15 +103,17 @@ export function useDefaultAssistant() {
}
export function useDefaultModel() {
const { defaultModel, topicNamingModel, translateModel } = useAppSelector((state) => state.llm)
const { defaultModel, topicNamingModel, translateModel, quickAssistantModel } = useAppSelector((state) => state.llm)
const dispatch = useAppDispatch()
return {
defaultModel,
topicNamingModel,
translateModel,
quickAssistantModel,
setDefaultModel: (model: Model) => dispatch(setDefaultModel({ model })),
setTopicNamingModel: (model: Model) => dispatch(setTopicNamingModel({ model })),
setTranslateModel: (model: Model) => dispatch(setTranslateModel({ model }))
setTranslateModel: (model: Model) => dispatch(setTranslateModel({ model })),
setQuickAssistantModel: (model: Model) => dispatch(setQuickAssistantModel({ model }))
}
}

View File

@@ -0,0 +1,185 @@
import { useMessageOperations } from '@renderer/hooks/useMessageOperations'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { RootState } from '@renderer/store'
import { messageBlocksSelectors } from '@renderer/store/messageBlock'
import { selectMessagesForTopic } from '@renderer/store/newMessage'
import { setActiveTopic, setSelectedMessageIds, toggleMultiSelectMode } from '@renderer/store/runtime'
import { Topic } from '@renderer/types'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useDispatch, useSelector, useStore } from 'react-redux'
export const useChatContext = (activeTopic: Topic) => {
const { t } = useTranslation()
const dispatch = useDispatch()
const store = useStore<RootState>()
const { deleteMessage } = useMessageOperations(activeTopic)
const [messageRefs, setMessageRefs] = useState<Map<string, HTMLElement>>(new Map())
const isMultiSelectMode = useSelector((state: RootState) => state.runtime.chat.isMultiSelectMode)
const selectedMessageIds = useSelector((state: RootState) => state.runtime.chat.selectedMessageIds)
useEffect(() => {
const unsubscribe = EventEmitter.on(EVENT_NAMES.CHANGE_TOPIC, () => {
dispatch(toggleMultiSelectMode(false))
})
return () => unsubscribe()
}, [dispatch])
useEffect(() => {
dispatch(setActiveTopic(activeTopic))
}, [dispatch, activeTopic])
const handleToggleMultiSelectMode = useCallback(
(value: boolean) => {
dispatch(toggleMultiSelectMode(value))
},
[dispatch]
)
const registerMessageElement = useCallback((id: string, element: HTMLElement | null) => {
setMessageRefs((prev) => {
const newRefs = new Map(prev)
if (element) {
newRefs.set(id, element)
} else {
newRefs.delete(id)
}
return newRefs
})
}, [])
const locateMessage = useCallback(
(messageId: string) => {
const messageElement = messageRefs.get(messageId)
if (messageElement) {
// 检查消息是否可见
const display = window.getComputedStyle(messageElement).display
if (display === 'none') {
// 如果消息隐藏,需要处理显示逻辑
// 查找消息并设置为选中状态
const state = store.getState()
const messages = selectMessagesForTopic(state, activeTopic.id)
const message = messages.find((m) => m.id === messageId)
if (message) {
// 这里需要实现设置消息为选中状态的逻辑
// 可能需要调用其他函数或修改状态
}
}
// 滚动到消息位置
messageElement.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
},
[messageRefs, store, activeTopic.id]
)
const handleSelectMessage = useCallback(
(messageId: string, selected: boolean) => {
dispatch(
setSelectedMessageIds(
selected ? [...selectedMessageIds, messageId] : selectedMessageIds.filter((id) => id !== messageId)
)
)
},
[dispatch, selectedMessageIds]
)
const handleMultiSelectAction = useCallback(
async (actionType: string, messageIds: string[]) => {
if (messageIds.length === 0) {
window.message.warning(t('chat.multiple.select.empty'))
return
}
const state = store.getState()
const messages = selectMessagesForTopic(state, activeTopic.id)
const messageBlocks = messageBlocksSelectors.selectEntities(state)
switch (actionType) {
case 'delete':
window.modal.confirm({
title: t('message.delete.confirm.title'),
content: t('message.delete.confirm.content', { count: messageIds.length }),
okButtonProps: { danger: true },
centered: true,
onOk: async () => {
try {
await Promise.all(messageIds.map((messageId) => deleteMessage(messageId)))
window.message.success(t('message.delete.success'))
handleToggleMultiSelectMode(false)
} catch (error) {
console.error('Failed to delete messages:', error)
window.message.error(t('message.delete.failed'))
}
}
})
break
case 'save': {
const assistantMessages = messages.filter((msg) => messageIds.includes(msg.id))
if (assistantMessages.length > 0) {
const contentToSave = assistantMessages
.map((msg) => {
return msg.blocks
.map((blockId) => {
const block = messageBlocks[blockId]
return block && 'content' in block ? block.content : ''
})
.filter(Boolean)
.join('\n')
.trim()
})
.join('\n\n---\n\n')
const fileName = `chat_export_${new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-')}.md`
await window.api.file.save(fileName, contentToSave)
window.message.success({ content: t('message.save.success.title'), key: 'save-messages' })
handleToggleMultiSelectMode(false)
} else {
window.message.warning(t('message.save.no.assistant'))
}
break
}
case 'copy': {
const assistantMessages = messages.filter((msg) => messageIds.includes(msg.id))
if (assistantMessages.length > 0) {
const contentToCopy = assistantMessages
.map((msg) => {
return msg.blocks
.map((blockId) => {
const block = messageBlocks[blockId]
return block && 'content' in block ? block.content : ''
})
.filter(Boolean)
.join('\n')
.trim()
})
.join('\n\n---\n\n')
navigator.clipboard.writeText(contentToCopy)
window.message.success({ content: t('message.copied'), key: 'copy-messages' })
handleToggleMultiSelectMode(false)
} else {
window.message.warning(t('message.copy.no.assistant'))
}
break
}
default:
break
}
},
[t, store, activeTopic.id, deleteMessage, handleToggleMultiSelectMode]
)
return {
isMultiSelectMode,
selectedMessageIds,
toggleMultiSelectMode: handleToggleMultiSelectMode,
handleMultiSelectAction,
handleSelectMessage,
activeTopic,
locateMessage,
messageRefs,
registerMessageElement
}
}

View File

@@ -0,0 +1,18 @@
import { IpcChannel } from '@shared/IpcChannel'
import { useEffect, useState } from 'react'
export function useFullscreen() {
const [isFullscreen, setIsFullscreen] = useState(false)
useEffect(() => {
const cleanup = window.electron.ipcRenderer.on(IpcChannel.FullscreenStatusChanged, (_, fullscreen) => {
setIsFullscreen(fullscreen)
})
return () => {
cleanup()
}
}, [])
return isFullscreen
}

View File

@@ -1,22 +1,25 @@
import { createSelector } from '@reduxjs/toolkit'
import store, { useAppDispatch, useAppSelector } from '@renderer/store'
import { addMCPServer, deleteMCPServer, setMCPServers, updateMCPServer } from '@renderer/store/mcp'
import { MCPServer } from '@renderer/types'
import { IpcChannel } from '@shared/IpcChannel'
import { useMemo } from 'react'
const ipcRenderer = window.electron.ipcRenderer
// Listen for server changes from main process
ipcRenderer.on(IpcChannel.Mcp_ServersChanged, (_event, servers) => {
window.electron.ipcRenderer.on(IpcChannel.Mcp_ServersChanged, (_event, servers) => {
store.dispatch(setMCPServers(servers))
})
ipcRenderer.on(IpcChannel.Mcp_AddServer, (_event, server: MCPServer) => {
window.electron.ipcRenderer.on(IpcChannel.Mcp_AddServer, (_event, server: MCPServer) => {
store.dispatch(addMCPServer(server))
})
const selectMcpServers = (state) => state.mcp.servers
const selectActiveMcpServers = createSelector([selectMcpServers], (servers) =>
servers.filter((server) => server.isActive)
)
export const useMCPServers = () => {
const mcpServers = useAppSelector((state) => state.mcp.servers)
const activedMcpServers = useMemo(() => mcpServers.filter((server) => server.isActive), [mcpServers])
const mcpServers = useAppSelector(selectMcpServers)
const activedMcpServers = useAppSelector(selectActiveMcpServers)
const dispatch = useAppDispatch()
return {

View File

@@ -1,54 +1,76 @@
import { useTheme } from '@renderer/context/ThemeProvider'
import { EventEmitter } from '@renderer/services/EventService'
import { ThemeMode } from '@renderer/types'
import { loadScript, runAsyncFunction } from '@renderer/utils'
import { useEffect, useRef } from 'react'
import { useEffect, useState } from 'react'
// 跟踪 mermaid 模块状态,单例模式
let mermaidModule: any = null
let mermaidLoading = false
let mermaidLoadPromise: Promise<any> | null = null
/**
* 导入 mermaid 库
*/
const loadMermaidModule = async () => {
if (mermaidModule) return mermaidModule
if (mermaidLoading && mermaidLoadPromise) return mermaidLoadPromise
mermaidLoading = true
mermaidLoadPromise = import('mermaid')
.then((module) => {
mermaidModule = module.default || module
mermaidLoading = false
return mermaidModule
})
.catch((error) => {
mermaidLoading = false
throw error
})
return mermaidLoadPromise
}
export const useMermaid = () => {
const { theme } = useTheme()
const mermaidLoaded = useRef(false)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// 初始化 mermaid 并监听主题变化
useEffect(() => {
runAsyncFunction(async () => {
if (!window.mermaid) {
await loadScript('https://unpkg.com/mermaid@11.6.0/dist/mermaid.min.js')
}
let mounted = true
if (!mermaidLoaded.current) {
await window.mermaid.initialize({
startOnLoad: false,
const initialize = async () => {
try {
setIsLoading(true)
const mermaid = await loadMermaidModule()
if (!mounted) return
mermaid.initialize({
startOnLoad: false, // 禁用自动启动
theme: theme === ThemeMode.dark ? 'dark' : 'default'
})
mermaidLoaded.current = true
EventEmitter.emit('mermaid-loaded')
}
})
}, [theme])
useEffect(() => {
const handleWheel = (e: WheelEvent) => {
if (e.ctrlKey || e.metaKey) {
const mermaidElement = (e.target as HTMLElement).closest('.mermaid')
if (!mermaidElement) return
const svg = mermaidElement.querySelector('svg')
if (!svg) return
const currentScale = parseFloat(svg.style.transform?.match(/scale\((.*?)\)/)?.[1] || '1')
const delta = e.deltaY < 0 ? 0.1 : -0.1
const newScale = Math.max(0.1, Math.min(3, currentScale + delta))
const container = svg.parentElement
if (container) {
container.style.overflow = 'auto'
container.style.position = 'relative'
svg.style.transformOrigin = 'top left'
svg.style.transform = `scale(${newScale})`
setError(null)
} catch (error) {
setError(error instanceof Error ? error.message : 'Failed to initialize Mermaid')
} finally {
if (mounted) {
setIsLoading(false)
}
}
}
document.addEventListener('wheel', handleWheel, { passive: true })
return () => document.removeEventListener('wheel', handleWheel)
}, [])
initialize()
return () => {
mounted = false
}
}, [theme])
return {
mermaid: mermaidModule,
isLoading,
error
}
}

View File

@@ -3,7 +3,7 @@ import Logger from '@renderer/config/logger'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { estimateUserPromptUsage } from '@renderer/services/TokenService'
import store, { type RootState, useAppDispatch, useAppSelector } from '@renderer/store'
import { messageBlocksSelectors, updateOneBlock } from '@renderer/store/messageBlock'
import { updateOneBlock } from '@renderer/store/messageBlock'
import { newMessagesActions, selectMessagesForTopic } from '@renderer/store/newMessage'
import {
appendAssistantResponseThunk,
@@ -13,6 +13,7 @@ import {
deleteSingleMessageThunk,
initiateTranslationThunk,
regenerateAssistantResponseThunk,
removeBlocksThunk,
resendMessageThunk,
resendUserMessageWithEditThunk,
updateMessageAndBlocksThunk,
@@ -22,21 +23,8 @@ import type { Assistant, Model, Topic } from '@renderer/types'
import type { Message, MessageBlock } from '@renderer/types/newMessage'
import { MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage'
import { abortCompletion } from '@renderer/utils/abortController'
import { findFileBlocks } from '@renderer/utils/messageUtils/find'
import { useCallback } from 'react'
const findMainTextBlockId = (message: Message): string | undefined => {
if (!message || !message.blocks) return undefined
const state = store.getState()
for (const blockId of message.blocks) {
const block = messageBlocksSelectors.selectById(state, String(blockId))
if (block && block.type === MessageBlockType.MAIN_TEXT) {
return block.id
}
}
return undefined
}
const selectMessagesState = (state: RootState) => state.messages
export const selectNewTopicLoading = createSelector(
@@ -113,36 +101,6 @@ export function useMessageOperations(topic: Topic) {
[dispatch, topic.id]
)
/**
* 在用户消息的主文本块被编辑后重新发送该消息。 / Resends a user message after its main text block has been edited.
* Dispatches resendUserMessageWithEditThunk.
*/
const resendUserMessageWithEdit = useCallback(
async (message: Message, editedContent: string, assistant: Assistant) => {
const mainTextBlockId = findMainTextBlockId(message)
if (!mainTextBlockId) {
console.error('Cannot resend edited message: Main text block not found.')
return
}
const files = findFileBlocks(message).map((block) => block.file)
const usage = await estimateUserPromptUsage({ content: editedContent, files })
const messageUpdates: Partial<Message> & Pick<Message, 'id'> = {
id: message.id,
updatedAt: new Date().toISOString(),
usage
}
await dispatch(
newMessagesActions.updateMessage({ topicId: topic.id, messageId: message.id, updates: messageUpdates })
)
// 对于message的修改会在下面的thunk中保存
await dispatch(resendUserMessageWithEditThunk(topic.id, message, mainTextBlockId, editedContent, assistant))
},
[dispatch, topic.id]
)
/**
* 清除当前或指定主题的所有消息。 / Clears all messages for the current or specified topic.
* Dispatches clearTopicMessagesThunk.
@@ -309,29 +267,127 @@ export function useMessageOperations(topic: Topic) {
)
/**
* Updates properties of specific message blocks (e.g., content).
* Uses the generalized thunk for persistence.
* Updates message blocks by comparing original and edited blocks.
* Handles adding, updating, and removing blocks in a single operation.
* @param messageId The ID of the message to update
* @param editedBlocks The complete set of blocks after editing
*/
const editMessageBlocks = useCallback(
async (messageId: string, updates: Partial<MessageBlock>) => {
async (messageId: string, editedBlocks: MessageBlock[]) => {
if (!topic?.id) {
console.error('[editMessageBlocks] Topic prop is not valid.')
return
}
const blockUpdatesListProcessed = {
updatedAt: new Date().toISOString(),
...updates
}
try {
// 1. Get the current state of the message and its blocks
const state = store.getState()
const message = state.messages.entities[messageId]
if (!message) {
console.error('[editMessageBlocks] Message not found:', messageId)
return
}
const messageUpdates: Partial<Message> & Pick<Message, 'id'> = {
id: messageId,
updatedAt: new Date().toISOString()
}
// 2. Get all original blocks
const originalBlocks = message.blocks
? (message.blocks
.map((blockId) => state.messageBlocks.entities[blockId])
.filter((block) => block !== undefined) as MessageBlock[])
: []
await dispatch(updateMessageAndBlocksThunk(topic.id, messageUpdates, [blockUpdatesListProcessed]))
// 3. Create sets for efficient comparison
const originalBlockIds = new Set(originalBlocks.map((block) => block.id))
const editedBlockIds = new Set(editedBlocks.map((block) => block.id))
// 4. Identify blocks to remove, update, and add
const blockIdsToRemove = originalBlocks
.filter((block) => !editedBlockIds.has(block.id))
.map((block) => block.id)
const blocksToUpdate = editedBlocks
.filter((block) => originalBlockIds.has(block.id))
.map((block) => ({
...block,
updatedAt: new Date().toISOString()
}))
const blocksToAdd = editedBlocks
.filter((block) => !originalBlockIds.has(block.id))
.map((block) => ({
...block,
updatedAt: new Date().toISOString()
}))
// 5. Prepare message update with new block IDs
const updatedBlockIds = editedBlocks.map((block) => block.id)
const messageUpdates: Partial<Message> & Pick<Message, 'id'> = {
id: messageId,
updatedAt: new Date().toISOString(),
blocks: updatedBlockIds
}
// 6. Log operations for debugging
console.log('[editMessageBlocks] Operations:', {
blocksToRemove: blockIdsToRemove.length,
blocksToUpdate: blocksToUpdate.length,
blocksToAdd: blocksToAdd.length
})
// 7. Update Redux state and database
// First update message and add/update blocks
if (blocksToAdd.length > 0) {
await dispatch(updateMessageAndBlocksThunk(topic.id, messageUpdates, blocksToAdd))
}
if (blocksToUpdate.length > 0) {
await dispatch(updateMessageAndBlocksThunk(topic.id, messageUpdates, blocksToUpdate))
}
// Then remove blocks if needed
if (blockIdsToRemove.length > 0) {
await dispatch(removeBlocksThunk(topic.id, messageId, blockIdsToRemove))
}
} catch (error) {
console.error('[editMessageBlocks] Failed to update message blocks:', error)
}
},
[dispatch, topic.id]
[dispatch, topic?.id]
)
/**
* 在用户消息的主文本块被编辑后重新发送该消息。 / Resends a user message after its main text block has been edited.
* Dispatches resendUserMessageWithEditThunk.
*/
const resendUserMessageWithEdit = useCallback(
async (message: Message, editedBlocks: MessageBlock[], assistant: Assistant) => {
await editMessageBlocks(message.id, editedBlocks)
const mainTextBlock = editedBlocks.find((block) => block.type === MessageBlockType.MAIN_TEXT)
if (!mainTextBlock) {
console.error('[resendUserMessageWithEdit] Main text block not found in edited blocks')
return
}
const fileBlocks = editedBlocks.filter(
(block) => block.type === MessageBlockType.FILE || block.type === MessageBlockType.IMAGE
)
const files = fileBlocks.map((block) => block.file).filter((file) => file !== undefined)
const usage = await estimateUserPromptUsage({ content: mainTextBlock.content, files })
const messageUpdates: Partial<Message> & Pick<Message, 'id'> = {
id: message.id,
updatedAt: new Date().toISOString(),
usage
}
await dispatch(
newMessagesActions.updateMessage({ topicId: topic.id, messageId: message.id, updates: messageUpdates })
)
// 对于message的修改会在下面的thunk中保存
await dispatch(resendUserMessageWithEditThunk(topic.id, message, assistant))
},
[dispatch, editMessageBlocks, topic.id]
)
/**

View File

@@ -8,6 +8,7 @@ import {
setOpenedOneOffMinapp
} from '@renderer/store/runtime'
import { MinAppType } from '@renderer/types'
import { useCallback } from 'react'
/**
* Usage:
@@ -29,74 +30,86 @@ export const useMinappPopup = () => {
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)) {
const openMinapp = useCallback(
(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 (openedKeepAliveMinapps.length < maxKeepAliveMinapps) {
dispatch(setOpenedKeepAliveMinapps([app, ...openedKeepAliveMinapps]))
} else {
// 缓存数量达到上限,移除最后一个,添加新的
dispatch(setOpenedKeepAliveMinapps([app, ...openedKeepAliveMinapps.slice(0, maxKeepAliveMinapps - 1)]))
}
dispatch(setOpenedOneOffMinapp(null))
//if the minapp is not keep alive, open it as one-off minapp
dispatch(setOpenedOneOffMinapp(app))
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
}
},
[dispatch, maxKeepAliveMinapps, openedKeepAliveMinapps]
)
/** a wrapper of openMinapp(app, true) */
const openMinappKeepAlive = (app: MinAppType) => {
openMinapp(app, true)
}
const openMinappKeepAlive = useCallback(
(app: MinAppType) => {
openMinapp(app, true)
},
[openMinapp]
)
/** 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)
}
})
}
const openMinappById = useCallback(
(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)
}
})
},
[openMinapp]
)
/** 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))
}
const closeMinapp = useCallback(
(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
}
dispatch(setCurrentMinappId(''))
dispatch(setMinappShow(false))
return
},
[dispatch, openedKeepAliveMinapps, openedOneOffMinapp]
)
/** Close all minapps (popup hides and all minapps unloaded) */
const closeAllMinapps = () => {
const closeAllMinapps = useCallback(() => {
dispatch(setOpenedKeepAliveMinapps([]))
dispatch(setOpenedOneOffMinapp(null))
dispatch(setCurrentMinappId(''))
dispatch(setMinappShow(false))
}
}, [dispatch])
/** Hide the minapp popup (only one-off minapp unloaded) */
const hideMinappPopup = () => {
const hideMinappPopup = useCallback(() => {
if (!minappShow) return
if (openedOneOffMinapp) {
@@ -104,7 +117,7 @@ export const useMinappPopup = () => {
dispatch(setCurrentMinappId(''))
}
dispatch(setMinappShow(false))
}
}, [dispatch, minappShow, openedOneOffMinapp])
return {
openMinapp,

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