Compare commits

..

193 Commits

Author SHA1 Message Date
MyPrototypeWhat
0ba76af300 refactor: reorganize routing and clean up unused components in Discover and MCP Servers pages
- Introduced a new routers.ts file to centralize routing logic for the Discover page.
- Updated DiscoverContent to utilize dynamic routing based on the new routers configuration.
- Removed commented-out routes and unnecessary imports from various components.
- Simplified navigation logic in MCP Servers pages by adjusting route paths.
- Cleaned up Navbar components in multiple pages for better maintainability.
2025-06-09 19:38:11 +08:00
MyPrototypeWhat
f2c52dfe89 Merge remote-tracking branch 'origin/feat/sidebar' into feat/cherry-store-render 2025-06-09 17:49:42 +08:00
MyPrototypeWhat
880d325028 fix: add missing icon import in AppsPage component 2025-06-09 17:49:30 +08:00
MyPrototypeWhat
2eb421a1de Merge tag 'feat/sidebar' into feat/cherry-store-render 2025-06-09 17:48:54 +08:00
MyPrototypeWhat
4a0924ce15 Merge branch 'feat/cherry-store-render' into feat/sidebar 2025-06-09 17:42:18 +08:00
MyPrototypeWhat
b7eef3b753 refactor: update ESLint configuration and clean up useDiscoverCategories logic
- Added new path to ESLint configuration for better linting coverage.
- Refactored useDiscoverCategories to simplify category handling and improve URL path matching logic.
- Removed unnecessary items from initialCategories for clarity and maintainability.
2025-06-09 16:19:00 +08:00
MyPrototypeWhat
d3f5887980 refactor: update Tailwind CSS integration in electron.vite.config.ts
- Changed the import of Tailwind CSS plugin to a dynamic import for improved performance.
- Ensured compatibility with the existing Vite configuration while maintaining functionality.
2025-06-09 16:08:03 +08:00
Teo
8db2059605 style(color.scss): update border color to improve UI consistency 2025-06-09 15:54:20 +08:00
Teo
d11b98dfbb feat(Inputbar): add SettingButton component for settings access 2025-06-09 15:54:16 +08:00
MyPrototypeWhat
38330c4c81 refactor: update layout of AgentsPage and AppsPage, remove Navbar components
- Refactored AgentsPage and AppsPage to enhance layout by replacing Navbar components with div wrappers.
- Integrated Tailwind CSS for improved styling consistency.
- Adjusted input components for better alignment and spacing.
2025-06-09 15:43:34 +08:00
kangfenmao
b762cfd60b feat: enhance summarization prompt and add topic sidebar visibility toggle 2025-06-09 15:39:30 +08:00
MyPrototypeWhat
278397f7c8 fix: update sidebar icons and enhance DiscoverPage layout
- Updated DiscoverPage to include full height and width styling for better layout.
- Modified sidebar icon visibility logic in migration to ensure 'discover' is added correctly while filtering out specific icons.
- Changed default sidebar icons to replace 'store' with 'discover' for consistency.
2025-06-09 15:27:48 +08:00
MyPrototypeWhat
c6d5faff73 feat: update TypeScript configuration and enhance discover page layout
- Added support for the @modelcontextprotocol/sdk in tsconfig.node.json.
- Updated import paths in provider.ts to include .js extensions.
- Enhanced AgentsPage layout by integrating Navbar and Input components.
- Refactored DiscoverPage to remove DialogManagerProvider and related components, simplifying the structure.
- Removed unused dialog components and hooks to streamline the discover functionality.
- Minor adjustments to AssistantSettings and Vercel tabs for improved code clarity.
2025-06-09 15:22:25 +08:00
kangfenmao
9cac8fba56 feat: add event listener to MainSidebar for topic tab navigation 2025-06-09 15:04:44 +08:00
MyPrototypeWhat
b7d9949832 Merge remote-tracking branch 'origin/main' into feat/cherry-store-render 2025-06-09 14:47:41 +08:00
kangfenmao
b4665509ab fix: set WindowService transparency to false for consistent behavior across platforms 2025-06-09 14:45:12 +08:00
kangfenmao
21e88b02ea refactor: simplify HomeTabs component by removing unused imports and commented code, update AssistantAddItem hover styles 2025-06-09 14:39:29 +08:00
kangfenmao
10caef2c4c refactor: clean up MainSidebar and useChat hooks, remove unused state handling and improve topic selection logic 2025-06-09 14:19:11 +08:00
kangfenmao
6ea1bcc7d1 fix: invert transparency setting for WindowService based on OS 2025-06-09 13:49:40 +08:00
kangfenmao
06a60c4871 feat: implement ChatNavbar component and enhance MainNavbar with search functionality 2025-06-09 12:05:41 +08:00
kangfenmao
684367bf7c fix: adjust navbar and title bar dimensions, update icon handling 2025-06-09 11:50:30 +08:00
kangfenmao
75b9e2f408 feat: new app sidebar 2025-06-09 11:20:41 +08:00
Wang Jiyuan
e13b136484 feat: add prompt variables description (#6991)
* feat: add prompt variables description

* fix: remove comment
2025-06-09 10:41:26 +08:00
自由的世界人
9c5fa57936 fix: update README files to enhance navigation and add project badges (#6982)
* fix: update README files to enhance navigation and add project badges

* fix: english version

* fix: sponsor link error
2025-06-08 21:47:22 +08:00
kangfenmao
7e201522d0 fix: remove topic or message did not delte releated files
This reverts commit df35f25502.
2025-06-08 13:23:48 +08:00
自由的世界人
df35f25502 fix: streamline file selection and ensure deletion of topic-related f… (#6872)
* fix: streamline file selection and ensure deletion of topic-related files

* fix: improve file deletion logic
2025-06-08 12:42:53 +08:00
George·Dong
f9e557763e fix(migrate): old translateModel incorrect (#6965)
* fix(migrate): old translateModel incorrect

* fix(migrate): old translateModel incorrect

* feat(models): improve default model init

* fix(migrate): update translateModel check

* fix(migrate): update translateModel check
2025-06-08 12:38:50 +08:00
beyondkmp
eafd814caf fix(BackupManager): add content length to WebDAV file upload options (#6977)
feat(BackupManager): add content length to WebDAV file upload options
2025-06-08 12:36:20 +08:00
kangfenmao
b84f7bf596 fix: prevent textarea from focusing when in fullscreen mode 2025-06-08 11:37:38 +08:00
kangfenmao
c1d753b7fe refactor: update input tools configuration to hide unused tools and set initial collapsed state 2025-06-08 11:30:50 +08:00
kangfenmao
3350f58422 fix: cannot remove assistat tag 2025-06-08 11:11:52 +08:00
自由的世界人
8c617872e0 fix: Implement label folding, drag-and-drop sorting of assistants within labels, and drag-and-drop sorting of labels (#6735)
* fix: add collapsible tags in AssistantsTab for better organization

* fix: implement drag-and-drop functionality for reordering assistants in tags

* fix: implement drag-and-drop functionality for reordering tags in AssistantTagsPopup

* fix: eslint error
2025-06-08 11:03:39 +08:00
purefkh
a333c635cb fix: prevent emoji picker from closing unexpectedly with IME 2025-06-08 10:55:54 +08:00
Caelan
a244057b3a feat: dmxapi images to image (#6935)
新增改图,合并图
2025-06-08 10:54:46 +08:00
tommyzhang100504
79d7ffcbad build: 增加自动更新文档中版本号的github workflow (#6971)
更新程序
2025-06-08 10:49:56 +08:00
Wang Jiyuan
2d985c1f91 refactor: better semantic of obsidian export options (#6926) 2025-06-08 00:02:15 +08:00
fullex
5879ccbeb2 fix: update default translate model to deepseek-v3 (#6960)
fix: update translate model to new default in llm state and migration logic
2025-06-07 21:44:28 +08:00
Wang Jiyuan
7887f4867d fix: voyage ai can't be used on text embedding (#6950) 2025-06-07 21:40:14 +08:00
Doekin
c38a6cdfbf feat(restoreFromWebdav): make credentials and path optional (#6922)
* feat(BackupService): add feedback messages for backup operations

* feat(WebDAV): Allow optional username, password, and path for unauthenticated access
2025-06-07 21:25:08 +08:00
Wang Jiyuan
ea7766db44 fix: update silicon docs and models (#6953) 2025-06-07 17:29:07 +08:00
fullex
a5012ce49e fix: set message translate dropdown height (#6954)
fix: improve dropdown menu styling and placement in MessageMenubar component

- Set a maximum height and overflow behavior for the dropdown menu to enhance usability.
- Changed the dropdown placement from "topRight" to "top" for better alignment with the UI.
2025-06-07 17:17:02 +08:00
Wang Jiyuan
d3da4f4623 fix: couldn't edit text when sent file-only message (#6930) 2025-06-07 00:19:21 +08:00
purefkh
7f12c2f8b8 fix: set thinking budget to 0 for gemini-2.5-flash when reasoning effort is off (#6917) 2025-06-06 22:11:18 +08:00
Wang Jiyuan
9ba2dea148 fix: message editor doesn't resize (#6924)
* fix: message editor doesn't resize

* fix: remove console log

* fix: optimize useEffect dependencies and improve textarea resizing logic

---------

Co-authored-by: Pleasurecruise <3196812536@qq.com>
2025-06-06 22:02:48 +08:00
Doekin
653bfa1f17 refactor: unified image viewer with integrated context menu (#6892)
* fix(Markdown): eliminate hydration error from image `<div>` nested in `<p>`

Signed-off-by: Chan Lee <Leetimemp@gmail.com>

* feat: add support for reading local files in binary format

Signed-off-by: Chan Lee <Leetimemp@gmail.com>

* refactor(ImageViewer): Consolidate image rendering for unified display and context menu

Signed-off-by: Chan Lee <Leetimemp@gmail.com>

---------

Signed-off-by: Chan Lee <Leetimemp@gmail.com>
2025-06-06 18:52:45 +08:00
one
fa00b5b173 feat(SelectionAssistant): add the "quote" action (#6868)
* feat(SelectionAssistant): add the "quote" action

* fix: i18n for "高级"

* refactor: move quote-to-main to WindowService

* refactor: move formatQuotedText to renderer
2025-06-06 18:12:38 +08:00
fullex
70fb6393b6 fix(SelectionAssistant): add explorer.exe to filterlist 2025-06-06 17:32:16 +08:00
kangfenmao
5b379666f4 refactor: move minapp settings to minapp page 2025-06-06 17:30:16 +08:00
kangfenmao
3cb34d30a9 refactor: remove isPreset messages and assistant.messages 2025-06-06 15:54:32 +08:00
beyondkmp
d47c93b4d8 feat: add set feed url functionality for early access (#5723)
* feat: add update channel functionality for beta testing

- Introduced a new IPC channel for setting the update channel.
- Implemented logic in AppUpdater to handle update channel changes.
- Updated settings to include a beta testing toggle, allowing users to switch between stable and beta update channels.
- Enhanced the settings UI to reflect the new beta testing option.

* add i18n

* update i18n

* update i18n

* refactor: rename update channel to feed URL and update related functionality

- Changed IPC channel from App_SetUpdateChannel to App_SetFeedUrl.
- Updated AppUpdater to set feed URL instead of update channel.
- Modified preload and settings to reflect the new feed URL functionality.
- Added constants for production and early access feed URLs.

* refactor: remove setAutoUpdate method from API

- Eliminated the setAutoUpdate method from the API object in preload index, streamlining the IPC communication interface.

* refactor: update early access feed URL and improve tooltip descriptions

- Changed EARLY_ACCESS_FEED_URL to point to the latest GitHub release.
- Simplified the setEarlyAccess function to directly set the feed URL.
- Added tooltips for early access settings in multiple languages to inform users about potential instability and the need for data backup.

* feat(migrate): add early access setting to state configuration

- Introduced a new state setting 'earlyAccess' and initialized it to false in the migration configuration.

* fix(i18n): update early access tooltip translations for clarity

- Revised the tooltip descriptions for the early access feature in English, Simplified Chinese, and Traditional Chinese to enhance clarity and ensure consistency in messaging regarding potential instability and the importance of data backup.

* feat: introduce FeedUrl enum for centralized feed URL management

- Added a new enum `FeedUrl` in the constants file to define production and early access feed URLs.
- Updated relevant IPC handlers and services to utilize the `FeedUrl` enum for type safety and consistency.
- Refactored the configuration manager to include methods for getting and setting the feed URL using the new enum.

* feat(settings): initialize early access and auto-update settings in AboutSettings component

- Added initialization for early access and auto-check update settings in the AboutSettings component to enhance user configuration options.

---------

Co-authored-by: beyondkmp <beyondkmkp@gmail.com>
2025-06-06 15:48:54 +08:00
SuYao
bc5cc4bf02 hotfix: enhance OpenAI stream handling and error management (#6541)
fix: enhance OpenAI stream handling and error management

- Updated the `openAIChunkToTextDelta` function to include error handling with a try-catch block, improving robustness during stream processing.
- Refined the `readableStreamAsyncIterable` function to ensure proper handling of stream completion and errors, including a return method for cleanup.
- Adjusted type definitions for better clarity and consistency in the handling of async iterables.
2025-06-06 15:18:16 +08:00
SuYao
8efa7d25f8 fix(Inputbar): remove unnecessary flex properties from Inputbar styles (#6902) 2025-06-06 15:16:12 +08:00
fullex
59195fec1a fix(SelectionAssistant): default disabled (#6897)
fix: selection default off
2025-06-06 14:19:55 +08:00
one
14e6a80049 fix(SelectionToolbar): prevent dragging the demo (#6888) 2025-06-06 12:34:02 +08:00
one
67ab36e0ea refactor(SelectionToolbar): add transition effects to action buttons (#6869)
* refactor(SelectionToolbar): add transition effects to action buttons

* refactor: reduce transition duration
2025-06-06 09:24:11 +08:00
fullex
dfc32967ed fix(SelectionAssistant): support selection when alt key pressed (#6865)
fix: support alt key selection
2025-06-06 08:29:00 +08:00
rainnoon
aa3c376def fix(Inputbar): fix textarea expansion and collapse issues with long text (CherryHQ#6857) (#6873)
fix(Inputbar): fix textarea expansion and collapse issues with long text (#6857)
2025-06-06 04:05:17 +08:00
自由的世界人
61c58caf78 hotfix: gemini-2.5-pro-preview-06-05 using error (#6870) 2025-06-06 03:42:52 +08:00
one
b402cdf7ff perf: improve responsiveness on streaming formulas (#6659)
* perf: improve performance on streaming formulas

* refactor: create throttlers for blocks

* refactor: use LRU cache for better memory management
2025-06-06 03:07:59 +08:00
one
d80513d011 refactor(CodePreview): improve the triggering timing for highlighting (#6866) 2025-06-06 00:41:03 +08:00
Doekin
4bcfbf785f feat: enable rendering and download of inline base64-encoded images (#6669)
This commit introduces support for displaying and downloading
inline base64-encoded images (specifically PNG and JPEG formats)
within Markdown content.

Key changes:
- Modified 'urlTransform' in the Markdown component to allow 'data:image/png'
  and 'data:image/jpeg' URLs, enabling their rendering.
- Updated the 'download' utility to handle 'data:' URLs,
  allowing users to save these inline images.

Signed-off-by: Chan Lee <Leetimemp@gmail.com>
2025-06-06 00:29:47 +08:00
SuYao
b722dab56b fix(OpenAIProvider): ensure tool_calls are only yielded when present (#6861)
This update modifies the OpenAIProvider to yield tool_calls only if they exist and have a length greater than zero, improving the handling of delta content. Additionally, a minor cleanup was performed by removing an unnecessary blank line in the code.
2025-06-05 22:49:29 +08:00
fullex
6165e4a47f fix(SelectionToolbar): prevent CSS updates in demo mode 2025-06-05 19:50:49 +08:00
fullex
b829abed2d fix(SelectionAssistant): ignore CtrlKey mode when ctrl+click (#6843)
fix(SelectionService): add mouse-down listener for multi-selection in ctrlkey mode
2025-06-05 19:05:17 +08:00
kangfenmao
36f56ba9aa chore(version): 1.4.1 2025-06-05 16:30:09 +08:00
Pleasurecruise
022b11cf6c fix: Improve the switching logic in multi-tab state 2025-06-05 16:26:34 +08:00
LiuVaayne
8d6662cb48 chore: remove unused Delete tokenflux_painting_page.md (#6840)
Delete tokenflux_painting_page.md
2025-06-05 16:23:00 +08:00
kangfenmao
a59a45f109 fix(AssistantsTab): remove untagged group title
This commit updates the AssistantsTab component to only display group titles for tagged assistants, excluding the 'untagged' category. This change enhances the UI by reducing clutter and improving clarity in the display of assistant groups.
2025-06-05 16:18:54 +08:00
SuYao
6337561f65 chore: update OpenAI package to version 5.1.0 and adjust related patches (#6838)
* chore: update OpenAI package to version 5.1.0 and adjust related patches

- Updated OpenAI dependency from version 4.96.0 to 5.1.0 in package.json and yarn.lock.
- Removed obsolete patch for OpenAI 4.96.0 and added new patch for OpenAI 5.1.0.
- Adjusted types for image handling in OpenAIResponseProvider to use Uploadable instead of FileLike.
- Minor code refactoring for better clarity and maintainability.

* refactor(OpenAIResponseProvider): remove logging for image generation process
2025-06-05 16:10:40 +08:00
kangfenmao
fbbc94028d refactor(i18n): reorganize Notion settings localization strings in ja-jp.json
This commit restructures the localization strings for Notion settings in the Japanese language file, moving them from a nested structure to a more accessible format. This change improves clarity and maintainability of the localization data.
2025-06-05 15:59:24 +08:00
kangfenmao
93d955c4b9 feat: optimize UI interface display 2025-06-05 15:51:03 +08:00
LiuVaayne
1c71e6d474 support tokenflux image generation for [Flux.1 Kontext] (#6705)
* Add support for TokenFlux image generation service

This commit integrates TokenFlux as a new painting provider with dynamic
form generation based on model schemas, real-time generation polling,
and full painting history management.

Key features:
- Dynamic form rendering from JSON schema input parameters
- Model selection with pricing display
- Real-time generation status polling
- Integration with existing painting workflow and file management
- Provider-specific painting state management

The implementation follows existing patterns from other painting pages
while adding TokenFlux-specific functionality like schema-based form
generation and asynchronous polling for generation results.

* Add image upload support and comparison view to TokenFlux

Implements file upload handling for image parameters with base64
conversion, random seed generation, and side-by-side comparison
layout when input images are present.

* Refactor TokenFlux to use service class and components

Extract form rendering logic to DynamicFormRender component and API
logic to TokenFluxService class. Simplifies the main component by
removing duplicate code for model fetching, image generation polling,
and form field rendering.

* Refactor TokenFlux to fix state management and polling

- Change painting field from modelId to model for consistency
- Fix updatePaintingState to use functional state updates
- Add automatic polling for in-progress generations on mount
- Group models by provider in the selection dropdown
- Separate prompt from other input params in form data handling
- Improve error handling in the paintings store

* Auto-select first model when models are loaded

* Add image generation UI localization strings

Add translation keys for model selection, input parameters, image
labels, pricing display, and form validation across all supported
locales (en-us, ja-jp, ru-ru, zh-cn, zh-tw). Update TokenFluxPage
component to use localized strings instead of hardcoded English text.

* fix: Add a right border to the first child of the ImageComparisonSection

* style: Remove padding from UploadedImageContainer in TokenFluxPage

* feat: Implement caching for TokenFlux model fetching and update image upload handling

* feat: Enhance localization support by adding language context handling in TokenFluxPage

* refactor: Simplify layout structure in TokenFluxPage by removing unnecessary SectionGroup components and improving section title styling

---------

Co-authored-by: kangfenmao <kangfenmao@qq.com>
2025-06-05 15:47:51 +08:00
Murphy
b2d10b7a6b fix: add blank lines between reasoning summary parts (#6827)
Co-authored-by: Chen Tao <70054568+eeee0717@users.noreply.github.com>
2025-06-05 15:39:56 +08:00
George·Dong
1215bcb046 refactor: enhance export functions (#5854)
* feat(markdown-export): add option to show model name in export

* refactor(export): Refactor the Obsidian export modal to Ant Design style

* refactor(obsidian-export): export to obsidian using markdown interface & support COT

* feat(markdown-export): optimize COT export style, support export model & provider name

Add a new setting to toggle displaying the model provider alongside the
model name in markdown exports. Update the export logic to include the
provider name when enabled, improving context and clarity of exported
messages. Also fix invalid filename character removal regex for Mac.

* feat(export): add option to export reasoning in Joplin notes

Introduce a new setting to toggle exporting reasoning details when
exporting topics or messages to Joplin. Update the export function to
handle raw messages and convert them to markdown with or without
reasoning based on the setting. This improves the export feature by
allowing users to include more detailed context in their Joplin notes.

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

* fix(settings): remove duplicate showModelNameInMarkdown state

* feat(export): add CoT export for notion & optmize notion export

* feat(export): update Notion settings i18n

* fix(utils): correct citation markdown formatting

Swap citation title and URL positions in markdown links to ensure
the link text displays the title (or URL if title is missing) and
the link points to the correct URL. This improves citation clarity.
2025-06-05 14:41:53 +08:00
fullex
9195a0324e fix(SelectionAssistant): ignore ctrl pressing when user is zooming in/out (#6822)
* fix(SelectionService): ignore ctrl pressing when user is zomming in/out

* chore: rename function

* fix: reset listener status
2025-06-05 14:28:50 +08:00
熊可狸
acbec213e8 hotfix: ensure show token usage setting defaults to true (#6828)
Hotfix: ensure show token usage setting defaults to true
2025-06-05 14:02:09 +08:00
熊可狸
e2a08e31e8 feat(Settings): Add token count display toggle (#6772)
* feat(Settings): add token count toggle

* fix(i18n): update token usage messages for zh-cn and zh-tw locales

* fix(InstallNpxUv): optimize checkBinaries function with useCallback for better performance

---------

Co-authored-by: Pleasurecruise <3196812536@qq.com>
2025-06-05 12:46:20 +08:00
SuYao
e479ee3dbc feat(constants): expand supported file extensions and categorize text… (#6815)
* feat(constants): expand supported file extensions and categorize text file types

* refactor(constants): remove binary file extensions

* refactor(constants): remove Xcode project
2025-06-05 12:32:28 +08:00
Wang Jiyuan
f6462ef998 fix: OpenAI provider api check doesn't handle error (#6769) 2025-06-05 12:09:37 +08:00
Murphy
dcdf49a5ce fix: sync active topic after rename (#6804)
Co-authored-by: Chen Tao <70054568+eeee0717@users.noreply.github.com>
2025-06-05 09:44:11 +08:00
SuYao
74f72fa5b6 fix(AnthropicProvider): update usage and metrics handling to prevent TypeError (#6813) 2025-06-05 09:33:40 +08:00
one
36f33fed75 fix: use monospace font for theme colorpicker (#6816) 2025-06-05 09:33:26 +08:00
fullex
eb7c05fd4c fix(SelectionAssistant): JetBrains IDEs, Remote desktop, Gaming, PDF views, etc (#6809)
fix: jetbrains ides, remote desktop, pdf views, etcs
2025-06-04 23:56:05 +08:00
SuYao
cb746fd722 hotfix: gemini auto thinking (#6810) 2025-06-04 23:25:42 +08:00
one
0449bc359a fix(MermaidPreview): debounce mermaid rendering to alleviate flickering (#6675) 2025-06-04 23:09:47 +08:00
one
d3e51ffb1c fix: codeblock overflow in bubble style (#6773)
* refactor: revert CodeBlockView style change

* fix: codeblock width and overflow

* refactor: improve CodeEditor border

* revert: context-menu-container width for message group
2025-06-04 19:56:31 +08:00
fullex
77eb70626c feat(SelectionAssistant): fullscreen game/presentation mode 2025-06-04 19:09:35 +08:00
fullex
345c4f096e fix: transparent window flashing when show (#6755)
* fix: avoid SelectionAssistant toolbar flashing

* add comments
2025-06-04 19:07:07 +08:00
Zhaker
a4aab3fd4e fix: correct variable name obsidianVault in Obsidian export (#6796) 2025-06-04 18:28:59 +08:00
自由的世界人
ecf770e183 fix: optimize multilingual display of documents (#6793)
Update Sidebar.tsx
2025-06-04 17:57:25 +08:00
Lucas
d58911ac60 fix(ci): Update the nightly-build workflow (#6791)
Update the branch name from `develop` to `main`
2025-06-04 17:37:13 +08:00
one
bb0a35b920 fix: chat navigation triggering (#6774)
* fix: exclude MessageEditor

* fix: more accurate triggering area
2025-06-04 17:34:51 +08:00
fullex
403649f2ea feat(SelectionAssistant): Smart Translation ( aka BiDirectionTranslate) (#6715)
* feat(Translation): enhance translation functionality and UI improvements

- Added secondary text color variables in color.scss for better UI contrast.
- Updated translation configuration to include language codes for better language handling.
- Enhanced translation UI with new language selection options and improved loading indicators.
- Implemented smart translation tips in multiple language JSON files for user guidance.
- Refactored translation logic to streamline message processing and error handling.

* feat(Translation): expand language options and update localization files

- Added new languages (Polish, Turkish, Thai, Vietnamese, Indonesian, Urdu, Malay) to translation options in translate.ts.
- Updated localization JSON files (en-us, ja-jp, ru-ru, zh-cn, zh-tw) to include translations for the new languages.
- Enhanced language detection logic in translate.ts to support new language codes.
2025-06-04 17:11:53 +08:00
fullex
958f8387d0 fix(SelectionAssistant): customCSS should not override background (#6746)
fix: customCSS should not override background
2025-06-04 17:11:31 +08:00
beyondkmp
9c89676030 refactor(BackupManager, WebDav): streamline WebDAV client initialization and enhance directory listing functionality (#6784)
Co-authored-by: beyondkmp <beyondkmkp@gmail.com>
2025-06-04 12:51:21 +08:00
one
34ec018840 fix: prevent message overflow when minimized width (#6775) 2025-06-04 11:50:56 +08:00
one
1be103a249 chore(gitignore): exclude cursor settings (#6779) 2025-06-04 11:48:58 +08:00
Wang Jiyuan
f83f8bb789 Fix: outdated provider websites and models (#6766)
* fix: inappropriate provider websites (openrouter, grok)

* fix: outdated model list (grok)
2025-06-04 00:00:33 +08:00
beyondkmp
cc2810b117 feat(AppUpdater): implement localized update dialog (#6742)
feat(AppUpdater): implement localized update dialog with new translations for multiple languages

Co-authored-by: beyondkmp <beyondkmkp@gmail.com>
2025-06-03 12:34:12 +08:00
SuYao
be1dae7ef0 hotfix: update qwen3 model identification logic to use startsWith for im… (#6738)
fix: update qwen3 model identification logic to use startsWith for improved accuracy
2025-06-03 10:50:31 +08:00
SuYao
446d26d8dc hotfix(OpenAIProvider): remove redundant 'unkown' chunk (#6737)
fix(OpenAIProvider): remove redundant 'unknown' yield case in chunk processing
2025-06-03 10:48:11 +08:00
May
7724b49ec4 fix: mcp uv&bun installation status icon in nav bar not updated after… (#6654)
fix: mcp uv&bun installation status icon in nav bar not updated after installed

Signed-off-by: aprilandjan <merlin.ye@qq.com>
2025-06-02 23:29:23 +08:00
Zhaker
ecbd283779 fix: assistant emoji displaying incorrectly in specific situations #6243 (#6280)
* fix:  ssistant emoji displaying incorrectly in specific situations

* chore: remove unuse import

* fix: ensure default emoji

* fix: remove redundant min-width in AssistantItem and EmojiIcon components; enhance emoji click handling

---------

Co-authored-by: 自由的世界人 <3196812536@qq.com>
2025-06-02 23:25:54 +08:00
Wang Jiyuan
389f750d7b fix: qwen3 cannot name a topic (#6722)
* fix: qwen3 cannot name a topic

* feat: Display error message when topic naming fails
2025-06-02 23:18:41 +08:00
Wang Jiyuan
23eaae80c8 fix: token usage not updated after editing message (#6725)
fix: update token usage when edit message
2025-06-02 23:17:40 +08:00
one
8f8c2f852e test: more unit tests for message rendering (#6663)
* refactor(encodeHTML): remove duplicate definition

* test(Scrollbar): update snapshot

* test: add more tests

Add tests for
- MainTextBlock
- ThinkingBlock
- Markdown
- CitationTooltip
2025-06-02 17:36:25 +08:00
George Zhao
13f7269e36 fix: adjust sidebar icon margins based on fullscreen state 2025-06-02 17:36:04 +08:00
fullex
0cd62a07fb feat(SelectionService): enhance trigger mode handling and update predefined blacklist 2025-06-02 17:34:41 +08:00
icarus
20b55693cb fix: provider o3 docs not found 2025-06-02 17:34:05 +08:00
Pleasurecruise
74cccf2c09 fix: replace franc with franc-min for improved performance 2025-06-02 17:31:49 +08:00
Doekin
54d20aa99b fix(OpenAIProvider): prevent atob error with non-base64 image URLs (#6673)
Signed-off-by: Chan Lee <Leetimemp@gmail.com>
2025-06-02 13:29:42 +08:00
one
2c8086f078 refactor: sort mentioned models in QuickPanel (#6666) 2025-06-01 20:04:20 +08:00
kangfenmao
ea061a3ba6 chore(version): 1.4.0 2025-06-01 16:59:50 +08:00
fullex
28a6ba1b5d feat(SelectionAssistant): predefined apps filter list (#6662)
* feat: predefined app blacklist

* fix

* fix

* fix

* fix: improve filter list processing in SelectionFilterListModal
2025-05-31 21:51:58 +08:00
one
8b793a9ca9 fix: thinking time reset (#6665)
* fix: thinking time reset

* fix: update theme listener to properly handle theme updates

---------

Co-authored-by: Pleasurecruise <3196812536@qq.com>
2025-05-31 11:08:15 +08:00
kangfenmao
fe1cf5d605 chore(version): 1.4.0-rc.3 2025-05-30 15:18:46 +08:00
Rudbeckia.hirta.L
f0335b5aaa fix: The edit button cannot be used after using MCP. 修复对话中使用 MCP 后编辑按钮消失的问题 (#6623)
fix: The edit button cannot be used after using MCP.
2025-05-30 15:15:59 +08:00
fullex
6c394ec375 fix: interrupting in shell and improve pdf readers 2025-05-30 15:11:29 +08:00
beyondkmp
9f49ce6dc9 refactor: Theme improve (#6619)
* refactor(IpcChannel): rename theme change event and streamline theme handling

- Updated the IpcChannel enum to rename 'theme:change' to 'theme:updated' for clarity.
- Refactored theme handling in ipc.ts to utilize a new ThemeService, simplifying theme updates and event broadcasting.
- Adjusted various components to consistently use the updated theme variable naming convention.

* refactor(Theme): standardize theme handling across components

- Updated theme retrieval to use 'actualTheme' instead of 'theme' for consistency.
- Changed default theme setting from 'auto' to 'system' in ConfigManager and related components.
- Adjusted theme handling in various components to reflect the new naming convention and ensure proper theme application.

* fix(Theme): improve theme handling and migration logic

- Added a console log for debugging theme transitions in ThemeProvider.
- Updated ThemeService to ensure theme is set correctly when changed.
- Incremented version number in store configuration to reflect changes.
- Enhanced migration logic to convert 'auto' theme setting to 'system' for better consistency.

* feat(Theme): add getTheme IPC channel and improve theme management

- Introduced a new IPC channel 'App_GetTheme' to retrieve the current theme.
- Updated ThemeService to include a method for getting the current theme.
- Refactored theme initialization in WindowService to ensure proper theme setup.
- Enhanced theme handling in various components to utilize the new theme retrieval method.

* fix(ThemeService): improve theme initialization and retrieval logic

- Set default theme to 'system' and updated theme initialization to handle legacy versions.
- Enhanced getTheme method to return both the current theme and the actual theme based on nativeTheme settings.
- Removed redundant initTheme method from ThemeService and ensured themeService is imported in WindowService for proper initialization.
- Updated ThemeProvider to handle the new structure of the theme retrieval response.

* refactor(Settings): remove theme management from settings

- Eliminated theme-related state and actions from the settings slice.
- Updated useSettings hook to remove theme handling functionality.
- Cleaned up imports by removing unused ThemeMode type.

* refactor(Theme): update theme retrieval in GeneralSettings and HomeWindow

- Restored theme retrieval in GeneralSettings and HomeWindow components.
- Adjusted imports to ensure proper theme management.
- Updated theme condition checks to utilize the ThemeMode enumeration for consistency.

* refactor(Theme): update theme terminology and retrieval in Sidebar and DisplaySettings

- Changed theme label from 'auto' to 'system' in multiple localization files for consistency.
- Updated Sidebar component to reflect the new theme terminology.
- Adjusted DisplaySettings to display the updated theme label.

* refactor(ThemeProvider): initialize theme state from API response

* refactor(ThemeProvider): reset theme state to default values and streamline initialization logic

* refactor(Theme): enhance theme management by incorporating 'system' mode and updating state handling

- Updated ThemeService to include 'system' as a valid theme option.
- Refactored ThemeProvider to utilize useSettings for theme state management and ensure proper initialization.
- Adjusted useSettings to include theme setting functionality.
- Modified settings slice to manage theme state effectively.

* refactor(WindowService, ThemeProvider, Messages, HomeWindow): streamline imports and clean up unused variables

- Removed duplicate import of ThemeService in WindowService.
- Adjusted import order in ThemeProvider for clarity.
- Simplified useSettings destructuring in Messages component.
- Cleaned up unused ThemeMode import in HomeWindow.

* refactor(Theme): standardize theme usage across components by replacing 'actualTheme' with 'theme'

- Updated components to consistently use 'theme' instead of 'actualTheme' for better clarity and maintainability.
- Adjusted ThemeProvider to reflect changes in theme state management.
- Ensured all relevant components are aligned with the new theme structure.

* refactor(Theme): remove unused theme retrieval functionality

- Eliminated the App_GetTheme channel and associated methods from ThemeService and IPC handling.
- Updated components to use the new theme structure, replacing 'actualTheme' with 'settedTheme' for consistency.
- Ensured all theme-related functionalities are streamlined and aligned with the latest changes.

* refactor(Theme): update theme variable usage in ChatFlowHistory and GeneralSettings

- Replaced 'theme' with 'settedTheme' in ChatFlowHistory for consistency with recent theme structure changes.
- Simplified theme destructuring in GeneralSettings by removing unused 'themeMode' variable.
- Ensured alignment with the latest theme management updates across components.

* refactor(Theme): update theme variable in GeneralSettings component

- Replaced 'themeMode' with 'theme' in GeneralSettings for consistency with recent theme structure changes.
- Ensured alignment with the latest theme management updates across components.

---------

Co-authored-by: beyondkmp <beyondkmkp@gmail.com>
2025-05-30 15:10:58 +08:00
自由的世界人
0df331cf8a feat: improve translation setting logic (#6463)
* feat: add auto-detect language option and improve translation logic

* feat: remove auto-detect language option and add bidirectional translation settings

* fix: remove unused model removal function from TranslatePage component

* feat: add language detection and bidirectional translation utilities

* feat: update translation settings to include bidirectional translation tips and remove deprecated options

* fix: improve interaction

* fix: change cld3-asm to franc

* fix: ui/ux

* fix: change eslint

* fix: update

* Revert "fix: update"

This reverts commit 1126a5cce9.

* Reapply "fix: update"

This reverts commit 82b7890f92.

* fix: setloading missing
2025-05-30 13:49:39 +08:00
kangfenmao
a5a04e1df7 lint: fix eslint error and build:check 2025-05-30 13:44:58 +08:00
kangfenmao
170d1a3a9c fix(Messages, WebSearchProviderSetting): remove unused variables and update provider logo styling 2025-05-30 13:29:32 +08:00
MyPrototypeWhat
ce941b6532 fix(MainTextBlock): update whiteSpace style for user messages to 'pre-wrap' 2025-05-30 12:12:06 +08:00
kangfenmao
c5fc7df258 test(scrollbar): fix snapshot mismatched 2025-05-30 12:11:13 +08:00
one
30844b8e21 refactor(SvgPreview): use shadow dom 2025-05-30 12:02:53 +08:00
Caelan
99b00cedb4 feat: dmxapi generate multiple image (#6632)
* chore(version): 1.3.8

* 新增自动添加

* 图片自增功能优化

---------

Co-authored-by: kangfenmao <kangfenmao@qq.com>
2025-05-30 10:35:21 +08:00
fullex
63242384d6 fix: setting tab font size 2025-05-30 10:28:21 +08:00
kangfenmao
e83d31a232 refactor(Scrollbar, Messages): clean up scrollbar component and styles
- Removed unused 'right' prop from Scrollbar component.
- Increased scrolling timeout duration for better user experience.
- Updated scrollbar styles to simplify color handling.
- Adjusted Messages component to remove unnecessary props and added margin for better layout.
- Added responsive styles to CitationBlock for improved mobile display.
2025-05-30 10:27:13 +08:00
fullex
65c7b720de feat(SelectionAssistant): improve selection in browsers and pdf readers (#6618)
fix: improve browsers and pdf readers selection
2025-05-29 22:12:03 +08:00
kangfenmao
77ecfbac9f chore(version): 1.4.0-rc.2 2025-05-29 19:58:55 +08:00
fullex
1a090a7c51 feat: add "Regenerate" in action window 2025-05-29 19:56:34 +08:00
fullex
a88bf104df feat(SelectionAssistant): add "Remember Window Size" functionality
- Introduced a new setting to remember the last adjusted size of the action window.
- Updated ConfigManager, SelectionService, and IPC channels to handle the new feature.
- Enhanced UI components to allow users to toggle the "Remember Size" option.
- Localized the new setting in multiple languages.
2025-05-29 19:55:54 +08:00
kangfenmao
c9caa5f46b revert: fix: english serif font rendering issue #6224
This reverts commit 5dd508b4f4.
2025-05-29 18:51:49 +08:00
kangfenmao
96ae5df1f1 test(QuickPanelView): integrate Redux store into tests and refactor rendering logic
- Added a mock Redux store to the QuickPanelView tests for better state management.
- Refactored test rendering to use a wrapper function for consistent provider usage.
- Updated Scrollbar test to verify throttle behavior with new delay and options.
2025-05-29 18:12:52 +08:00
kangfenmao
6048f42740 refactor: standardize variable naming and improve tag calculation logic
- Renamed variables for consistency, changing `AssistantsTabSortType` to `assistantsTabSortType`.
- Refactored tag calculation in `useTags` to utilize `uniq` and `flatMap` for better performance and readability.
- Updated localization files to remove unnecessary characters in quick trigger messages.
- Enhanced the `AssistantItem` component by extracting menu item creation logic and sorting functions for better maintainability.
2025-05-29 18:02:55 +08:00
neko engineer
5b199aa736 feat: 调整分组的效果 (#6561)
1,未分组标签改为未分组
2,列表展示效果持久化
3,增加一个管理列表展示效过的store

Co-authored-by: linshuhao <nmnm1996>
2025-05-29 15:40:32 +08:00
kangfenmao
a6bb58bb45 feat(DisplaySettings): add theme color presets and zoom settings
- Introduced a new color selection feature in DisplaySettings, allowing users to choose from predefined theme color presets.
- Added a dedicated section for zoom settings in the DisplaySettings component, enhancing user customization options.
- Updated localization files to include new zoom settings titles in multiple languages.
2025-05-29 15:35:32 +08:00
kangfenmao
a78db10798 refactor(Messages): enhance message rendering and navigation exclusions
- Updated styles for message content and group containers to improve layout.
- Added new selectors to exclude additional elements from navigation.
- Implemented conditional rendering for mentions in message content.
- Simplified token display logic in message tokens component.
2025-05-29 15:30:03 +08:00
kangfenmao
479b3ccfb7 refactor(Scrollbar, Chat, Messages): improve scroll handling and clean up component structure 2025-05-29 15:30:03 +08:00
kangfenmao
f916002a71 refactor(Chat, Messages): simplify maxWidth calculations and remove unused showAssistants variable 2025-05-29 15:30:03 +08:00
Teo
c5208eeaef feat(theme): 用户自定义主题色 (#4613)
* feat(theme): 用户自定义主题色

* refactor(QuickPanel): integrate user theme for dynamic color handling

* refactor(ThemeProvider): separate user theme initialization into its own useEffect

* refactor(useUserTheme): move theme initialization logic into a dedicated function

* feat(settings): enhance color picker with presets and update styles for ant-collapse

* feat: Refactor theme management to use userTheme object for colorPrimary
2025-05-29 15:29:35 +08:00
Alain
2e8cbdc4aa fix(provider): update Qiniu's name and logo, fix gitee typo (#6593)
* fix(provider): update Qiniu's name and logo

* fix(provider): typo
2025-05-29 13:32:46 +08:00
kangfenmao
77b0dfc8d3 fix(PROVIDER_CONFIG): update website URLs from ppinfra.com to ppio.cn 2025-05-29 10:17:24 +08:00
fullex
c5c5681cfd feat(SelectionAssistant): support Shift+Click & enhance Ctrl key mode (#6566)
* feat: add filter mode and list functionality to selection assistant

- Introduced new filter mode options (default, whitelist, blacklist) for the selection assistant.
- Added methods to set and get filter mode and filter list in ConfigManager.
- Enhanced SelectionService to manage filter mode and list, affecting text selection processing.
- Updated UI components to allow users to configure filter settings.
- Localized new filter settings in multiple languages.

* feat: support Shift+Click & enhance Ctrl key method

* fix: remove comments
2025-05-29 10:10:55 +08:00
one
808afa053f fix(SvgPreview): dragging and sanitizing (#6568)
* fix(SvgPreview): dragging

* fix(SvgPreview): sanitize svg content
2025-05-29 09:51:15 +08:00
one
cb75d01fd3 fix(style): global cursor style for scrollbar thumb 2025-05-29 09:46:19 +08:00
one
3ae7bbf304 refactor: chat navigation triggering (#6576)
* refactor(ChatNavigation): move down the navigation bar

* refactor: attach listeners to MessagesContainer for better triggering experience

* refactor: add delay to Tooltips

* refactor: exclude some toolbars areas from triggering
2025-05-29 09:44:55 +08:00
one
fc3d536433 fix(HealthCheck): add a disclaimer (#6570)
* fix(HealthCheck): add a disclaimer

* fix: remove duplicates in zh-tw.json
2025-05-28 23:37:00 +08:00
kangfenmao
36abf3f099 Revert "fix: Repair abnormal line break display"
This reverts commit 3d7fd5a30c.
2025-05-28 20:04:59 +08:00
stevending1st
3d7fd5a30c fix: Repair abnormal line break display 2025-05-28 17:10:19 +08:00
fullex
f83d9fc03c feat(SelectionAssistant): App Filter / 应用筛选 (#6519)
feat: add filter mode and list functionality to selection assistant

- Introduced new filter mode options (default, whitelist, blacklist) for the selection assistant.
- Added methods to set and get filter mode and filter list in ConfigManager.
- Enhanced SelectionService to manage filter mode and list, affecting text selection processing.
- Updated UI components to allow users to configure filter settings.
- Localized new filter settings in multiple languages.
2025-05-28 16:25:21 +08:00
kangfenmao
94e6ba759e fix: suppress exhaustive-deps warnings in multiple components
- Added eslint-disable comments for react-hooks/exhaustive-deps in CustomCollapse, DmxapiPage, SelectionActionApp, ActionGeneral, and ActionTranslate components to prevent warnings related to missing dependencies in useEffect hooks.
2025-05-28 16:24:53 +08:00
suyao
c8c30f327b fix(OpenAIProvider): adjust reasoning effort setting to default to 'medium' when set to 'auto' 2025-05-28 16:20:54 +08:00
kangfenmao
72fae1af25 fix: update artifact patterns in release workflow
- Modified the artifact patterns in the GitHub Actions release workflow to include 'dist/rc*.yml' for better versioning support.
2025-05-28 16:19:41 +08:00
kangfenmao
98f8bacdc8 chore(version): 1.4.0-rc.1 2025-05-28 15:51:44 +08:00
FunJim
06f6da725d fix: add custom parameters to OpenAI generateImageByChat requests 2025-05-28 15:47:18 +08:00
fullex
d24eabb97c fix[SelectionAssistant]: interrupting in terminal apps (#6549)
fix: interrupting in terminal apps
2025-05-28 13:09:10 +08:00
suyao
eca3f1d71e fix: update token limits for Claude-4 models and refine reasoning checks in OpenAIProvider
- Adjusted max token limit for 'claude-sonnet-4' and 'claude-opus-4' models from 64000 to 32000.
- Simplified reasoning checks in OpenAIProvider to combine conditions for supported models, enhancing code clarity.
2025-05-28 09:41:08 +08:00
beyondkmp
87d178773a fix: update TikToken implementation and remove js-tiktoken dependency
- Replaced the existing TikToken implementation with a placeholder error message indicating it is not implemented.
- Removed the js-tiktoken dependency from package.json to streamline the project.
- Updated yarn.lock to reflect changes in dependencies and checksums.
2025-05-28 08:55:52 +08:00
George Zhao
02cb005668 fix: increase max cache limit and update slider marks in MiniAppSettings (#6414)
* fix: increase max cache limit and update slider marks in MiniAppSettings

* fix: adjust max cache limit and update slider marks in MiniAppSettings

* Update MiniAppSettings.tsx

---------

Co-authored-by: George Zhao <georgezhao@SKJLAB>
2025-05-28 01:06:17 +08:00
nmnmtttt
cf1d5c098f feat: Assistant add tag (#6065)
* feat: 添加助手标签显示逻辑
-增加助手的标签属性
-能够删除,修改,调整助手的标签
Signed-off-by: LeeSH <shuhao_lin@fzzixun.com>

* fix: 修复不能输入新增标签的问题

* feat: 完善不同状态下,提示文本展示

* feat: 调整标签展示逻辑
1,左键调整列表页展示逻辑
2,新增标签改为使用+号提示

* feat: 移除搜索栏可以直接增加tag值的功能
Signed-off-by: LeeSH <shuhao_lin@fzzixun.com>

* fix: 修复点击不能切换话题的bug

* feat:  调整了标签修改的交互
1,添加和管理分开处理
2,可以点击标签之间切换
3,点击删除可以之间移除所有关联助手的标签

tips:为了简单实现,标签本身不具有具体类,都是助手的子属性。所以如果关联的所有助手都没了该属性,标签会直接消失,而且标签目前无法排序

Signed-off-by: LeeSH <shuhao_lin@fzzixun.com>

* feat:优化标签管理
1,列表状态管理向上提,切换左侧列表不会影响原来的列表状态
2,标签名称增加最大宽度
3,标签内的助手顺序,参照原顺序排列
4,增加标签ui,提示语调整
5,标签管理ui,提示语调整
6,标签管理增加标签暂时态,防止误删没有其他助手的标签项的时候,标签在弹窗内整个消失(如果关闭弹窗那标签就无法找回)
7,如果没有标签的时候,右键仅展示添加标签

Signed-off-by: LeeSH <shuhao_lin@fzzixun.com>

---------

Signed-off-by: LeeSH <shuhao_lin@fzzixun.com>
Co-authored-by: linshuhao <nmnm1996>
Co-authored-by: Lee SH <shuhao_lin@fzzixun.com>
2025-05-27 21:57:15 +08:00
purefkh
65273b055c feat: support system prompt variables (#5995)
* feat: support system prompt variables

* feat: add tip

* fix ci test fail
2025-05-27 21:49:25 +08:00
beyondkmp
f171839830 chore: refine file exclusion patterns in electron-builder configuration (#6502)
- Updated exclusion patterns to ensure more comprehensive filtering of unnecessary files and directories during the build process.
- Added additional file types and configurations to the exclusion list for better optimization.

Co-authored-by: beyondkmp <beyondkmkp@gmail.com>
2025-05-27 21:46:36 +08:00
fullex
8f9a5642f2 feat[SelectionAssistant]: add faq&feedback link (#6531)
feat: add FAQ button to Selection Assistant settings
2025-05-27 21:45:49 +08:00
SuYao
e906d5db25 fix: Optimize error message formatting (#5988)
* fix: Optimize error message formatting

* fix: improve error unit test

* refactor: simplify error handling in ErrorBlock component

- Replaced custom StyledAlert with a more streamlined Alert component for error messages.
- Reduced complexity by removing unnecessary JSX wrappers and improving readability.
- Adjusted styling for the Alert component to maintain visual consistency.

* fix: update error handling in ErrorBlock component

- Removed unnecessary message prop from Alert component to simplify error display.
- Maintained existing error handling logic while improving code clarity.
2025-05-27 21:45:04 +08:00
fullex
80c09a07dc refactor: TrayService & ConfigManager (#6526)
* refactor: TrayService

- Removed the App_RestartTray channel from IpcChannel and its usage in ipc.ts and preload/index.ts.
- Updated TrayService to handle configuration changes without the need for a restart.
- Enhanced ConfigManager to notify subscribers on language and quick assistant settings changes.
- Adjusted QuickAssistantSettings to close the mini window based on the quick assistant's enable state.

* refactor: enhance configuration management

- Updated ConfigManager to consolidate setting and notification logic into a single method, setAndNotify.
- Modified IPC handler to accept an additional parameter for notification control.
- Adjusted QuickAssistantSettings to utilize the new parameter for enabling notifications during configuration changes.
2025-05-27 21:11:49 +08:00
chenxue
af6145600a feat: aihubmix painting support imagen (#6525)
* add imagen

* feat: support imagen model

* update proxy notice

---------

Co-authored-by: zhaochenxue <zhaochenxue@bixin.cn>
2025-05-27 21:02:02 +08:00
kangfenmao
42bda59392 feat: add default painting provider support and update routing
- Introduced defaultPaintingProvider in settings to manage selected painting provider.
- Updated Sidebar component to reflect the selected painting provider in the route.
- Enhanced PaintingsRoutePage to dispatch the default painting provider based on URL parameters.
- Added PaintingProvider type to define available options for painting providers.
2025-05-27 17:38:00 +08:00
chenxue
e73f6505e9 feat: painting aihubmix support model: gpt-image-1 (#6486)
* update select style

* add openai painting

* support base64 response

* update config

* fix upload preview bug

* fix remix default model

* fix history data

* feat: optimize structure

* fix history data

---------

Co-authored-by: zhaochenxue <zhaochenxue@bixin.cn>
2025-05-27 17:01:39 +08:00
kangfenmao
332aa45618 chore: remove electron-icon-builder 2025-05-27 16:57:31 +08:00
kangfenmao
253075e332 fix: remove tiktoken 2025-05-27 16:57:18 +08:00
shiquda
737b8f02b1 feat: add title to selection action button in compact mode (#6498)
* feat: add title prototype to selection action button in compact mode

* fix: optimize the display name logic for action buttons in the selection toolbar
2025-05-27 12:51:49 +08:00
MyPrototypeWhat
2a996e2c9a fix(MainTextBlock): adjust whiteSpace style for user role messages (#6501) 2025-05-27 12:10:09 +08:00
kangfenmao
c77d627077 chore: update electron-builder configuration to refine file exclusion patterns
- Added exclusions for various distribution directories and module types to optimize the build process.
- Updated license file exclusions to be more inclusive of different casing variations.
2025-05-27 10:06:39 +08:00
kangfenmao
11daf93094 chore: update @google/genai to version 1.0.1 and remove GeminiService references
- Updated the @google/genai dependency in package.json and yarn.lock to version 1.0.1.
- Removed the GeminiService and its related references from the codebase to streamline functionality.
- Introduced a new CacheService for managing cached data effectively.
2025-05-27 10:04:41 +08:00
beyondkmp
44b07ee35d fix: adjust order of tools in CodeToolbar constants for correct display 2025-05-27 09:38:02 +08:00
fullex
b24de23219 feat: integrate custom CSS support in SelectionAssistant 2025-05-27 09:37:23 +08:00
fullex
431e2aaa13 fix[SelectionAssistant]: remove console.log (#6474)
fix: remove console.log
2025-05-26 20:11:13 +08:00
beyondkmp
9896c75a2e fix: cannot run from yarn dev (#6468) 2025-05-26 19:20:38 +08:00
beyondkmp
94cec70737 chore: removed unused dependencies to reduce size (#6464)
* chore: update package dependencies and refactor BackupManager to use fs.promises

- Removed unused dependencies: fetch-socks and fs-extra from package.json and yarn.lock.
- Updated BackupManager to utilize fs.promises for file system operations, improving consistency and modernizing the codebase.
- Ensured all file operations in BackupManager are handled with promises for better error handling and readability.

* chore: add fs-extra dependency and refactor BackupManager for improved file handling

- Added fs-extra to package.json and updated yarn.lock to enhance file system operations.
- Refactored BackupManager to utilize fs-extra methods for better readability and functionality, replacing fs.promises with fs-extra equivalents for directory and file operations.

---------

Co-authored-by: beyondkmp <beyondkmkp@gmail.com>
2025-05-26 18:34:57 +08:00
fullex
2ba4e51e93 feat: Selection Assistant / 划词助手 (#5900)
* feat(selection): implement selection assistant with toolbar and action management

- Added selection assistant functionality including a toolbar for actions.
- Introduced new settings for enabling/disabling the selection assistant and configuring its behavior.
- Implemented action items for built-in functionalities like translate, explain, and copy.
- Integrated selection service to manage selection events and actions.
- Updated localization files to support new selection assistant features in multiple languages.
- Added new components for action management and user interaction within the selection assistant.

* chore: update selection-hook to version 0.9.10 and exclude prebuilds from packaging

* fix: toolbar hiding

* feat: enhance error handling and service management in main index

* fix: improve logical coordinate handling in SelectionService

* fix: update URL loading and coordinate conversion in SelectionService

* fix: replace console.error with Logger for error handling in SelectionService

* refactor(SelectionService): enhance preloaded action window management

* chore(electron-builder): add filter for .node build files in configuration

* fix: toolbar position calculating for multi monitor

* fix: update selection assistant configuration and improve error handling in SelectionService

* fix: SelectionActionUserModal layout

* feat: add hints for custom search URL in multiple languages

* fix: update calculateToolbarPosition to ensure integer return type and round position values

* feat: add action window opacity setting and update related UI components

refactor: SelectionActionsList

* chore: enhance tooltip for trigger mode settings

* fix: console.log

* chore: update selection-hook to version 0.9.12

* fix: integrate language settings into selection components

* fix: filter out default assistant from user predefined assistants in selection modal

* chore: update selection-hook package version to 0.9.13

* chore: update selection-hook package version to 0.9.14
2025-05-26 16:50:52 +08:00
one
665a62080b test: more unit tests (#5130)
* test: more unit tests

- Adjust vitest configuration to handle main process and renderer process tests separately
- Add unit tests for main process utils
- Add unit tests for the renderer process
- Add three component tests to verify vitest usage: `DragableList`, `Scrollbar`, `QuickPanelView`
- Add an e2e startup test to verify playwright usage
- Extract `splitApiKeyString` and add tests for it
- Add and format some comments

* fix: mock individual properties

* test: add tests for CustomTag

* test: add tests for ExpandableText

* test: conditional rendering tooltip of tag

* chore: update dependencies
2025-05-26 16:50:26 +08:00
fullex
a05a7e45cc chore: update electron-builder configuration and package dependencies
- Modified electron-builder.yml to refine file inclusion/exclusion patterns.
- Removed and re-added dependencies in package.json for consistency and updated yarn.lock to reflect these changes.
- Cleaned up unnecessary entries in yarn.lock to streamline the dependency tree.
2025-05-26 16:49:09 +08:00
kangfenmao
f8e9216270 refactor: remove early return for empty MCP servers in MCPToolsButton
- Eliminated the conditional return for empty active MCP servers to streamline the component rendering logic.
2025-05-26 16:24:19 +08:00
lizhixuan
475c1e38df feat: refactor store to discover transition and enhance UI components
- Updated package.json to include 'usehooks-ts' and upgraded 'lucide-react' to version 0.511.0.
- Replaced 'store' with 'discover' in the routing and sidebar components for improved navigation.
- Introduced new DiscoverPage and related components for better organization of content.
- Enhanced localization support by adding Chinese translations for the discover feature.
- Removed deprecated store components to streamline the codebase and improve maintainability.
2025-05-18 16:08:26 +08:00
MyPrototypeWhat
80289f1dc3 feat: update store components and add dialog management functionality
- Updated package.json to use the latest version of the 'motion' library.
- Refactored store components to improve organization and user experience, including the addition of AssistantCard and MiniAppCard components.
- Introduced a DialogManager for handling dialog states and interactions.
- Enhanced StoreContent and StoreSidebar components to support new item types and improved layout.
- Added new JSON data for mini-apps and updated store categories for better accessibility.
2025-05-16 19:11:46 +08:00
MyPrototypeWhat
ef16558947 feat: enhance Prettier configuration and update store components
- Added Tailwind CSS support to Prettier configuration with new settings for styles and functions.
- Updated package.json to include the prettier-plugin-tailwindcss dependency.
- Refactored various store components for improved layout and organization, including adjustments to error handling and component structure.
- Enhanced CSS styles for better responsiveness and visual consistency across components.
2025-05-15 18:18:54 +08:00
MyPrototypeWhat
c799f15fcc feat: update store components and enhance assistant functionality
- Refactored store components to improve organization and user experience, including the introduction of new GridView and ListView components.
- Implemented a detail dialog for displaying item information and installation options.
- Enhanced the store sidebar with collapsible categories for better navigation.
- Updated data structures to support dynamic subcategory handling and improved filtering capabilities.
- Added utility functions for dialog and collapsible components to streamline UI interactions.
2025-05-14 17:17:24 +08:00
MyPrototypeWhat
802402e922 feat: add store categories and items with enhanced filtering functionality
- Introduced new JSON files for store categories and assistant items to improve organization and accessibility.
- Implemented a conversion script to dynamically generate the assistant items list from agents data.
- Refactored store components to utilize the new data structure, enhancing the store layout and user experience.
- Added loading states and error handling for category and item fetching processes.
- Created new GridView and ListView components for displaying store items in different formats.
2025-05-13 19:25:37 +08:00
lizhixuan
37482bca7b feat: refactor electron and vitest configuration for dynamic imports and improved structure
- Updated electron.vite.config.ts to use dynamic imports for Tailwind CSS.
- Refactored vitest.config.ts to asynchronously retrieve renderer configuration from electron.vite.config.
- Enhanced plugin and alias management for better maintainability and performance.
2025-05-12 23:37:11 +08:00
lizhixuan
184713dba8 feat: enhance store page with new components and functionality
- Updated component paths in components.json for better organization.
- Added 'motion' library to package.json for animations.
- Refactored TypeScript configuration to include new renderer paths.
- Implemented new StoreContent and StoreSidebar components for improved store layout.
- Integrated store categories and items with filtering capabilities.
- Enhanced UI with Tailwind CSS animations and styles for a better user experience.
2025-05-12 22:58:54 +08:00
MyPrototypeWhat
0a0956cfc4 feat: update store page and integrate new UI components
- Updated Tailwind CSS configuration and styles for the store page.
- Added new UI components including Card, Badge, DropdownMenu, and Sidebar for enhanced user experience.
- Implemented store categories and items with filtering functionality.
- Introduced mobile responsiveness with a custom hook for detecting mobile devices.
- Enhanced theme management to support dynamic theme changes.
- Added new utility functions for improved class name management.
2025-05-12 19:28:23 +08:00
MyPrototypeWhat
0a0bbad77f feat: integrate Tailwind CSS and add store page functionality
- Introduced Tailwind CSS for styling by adding a new configuration file and global styles.
- Created a new Store page with a button to test configuration success.
- Updated routing to include the Store page and added corresponding sidebar icon.
- Enhanced the settings store to include the new Store icon in the sidebar.
- Updated translations for the Store page in Chinese.
- Added utility functions for class name management using Tailwind CSS.
2025-05-09 16:36:50 +08:00
322 changed files with 25404 additions and 8396 deletions

View File

@@ -53,7 +53,7 @@ jobs:
- name: Check out Git repository
uses: actions/checkout@v4
with:
ref: develop
ref: main
- name: Install Node.js
uses: actions/setup-node@v4

View File

@@ -113,5 +113,40 @@ jobs:
allowUpdates: true
makeLatest: false
tag: ${{ steps.get-tag.outputs.tag }}
artifacts: 'dist/*.exe,dist/*.zip,dist/*.dmg,dist/*.AppImage,dist/*.snap,dist/*.deb,dist/*.rpm,dist/*.tar.gz,dist/latest*.yml,dist/*.blockmap'
artifacts: 'dist/*.exe,dist/*.zip,dist/*.dmg,dist/*.AppImage,dist/*.snap,dist/*.deb,dist/*.rpm,dist/*.tar.gz,dist/latest*.yml,dist/rc*.yml,dist/*.blockmap'
token: ${{ secrets.GITHUB_TOKEN }}
dispatch-docs-update:
needs: release
if: success() && github.repository == 'CherryHQ/cherry-studio' # 确保所有构建成功且在主仓库中运行
runs-on: ubuntu-latest
steps:
- name: Get release tag
id: get-tag
shell: bash
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "tag=${{ github.event.inputs.tag }}" >> $GITHUB_OUTPUT
else
echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
fi
- name: Check if tag is pre-release
id: check-tag
shell: bash
run: |
TAG="${{ steps.get-tag.outputs.tag }}"
if [[ "$TAG" == *"rc"* || "$TAG" == *"pre-release"* ]]; then
echo "is_pre_release=true" >> $GITHUB_OUTPUT
else
echo "is_pre_release=false" >> $GITHUB_OUTPUT
fi
- name: Dispatch update-download-version workflow to cherry-studio-docs
if: steps.check-tag.outputs.is_pre_release == 'false'
uses: peter-evans/repository-dispatch@v3
with:
token: ${{ secrets.REPO_DISPATCH_TOKEN }}
repository: CherryHQ/cherry-studio-docs
event-type: update-download-version
client-payload: '{"version": "${{ steps.get-tag.outputs.tag }}"}'

9
.gitignore vendored
View File

@@ -45,10 +45,15 @@ stats.html
local
.aider*
.cursorrules
.cursor/rules
.cursor/*
# test
# vitest
coverage
.vitest-cache
vitest.config.*.timestamp-*
# playwright
playwright-report
test-results
YOUR_MEMORY_FILE_PATH

View File

@@ -4,5 +4,8 @@
"printWidth": 120,
"trailingComma": "none",
"endOfLine": "lf",
"bracketSameLine": true
"bracketSameLine": true,
"tailwindStylesheet": "./src/renderer/src/assets/styles/tailwind.css",
"tailwindFunctions": ["clsx"],
"plugins": ["prettier-plugin-tailwindcss"],
}

View File

@@ -0,0 +1,71 @@
diff --git a/dist/utils/tiktoken.cjs b/dist/utils/tiktoken.cjs
index 973b0d0e75aeaf8de579419af31b879b32975413..f23c7caa8b9dc8bd404132725346a4786f6b278b 100644
--- a/dist/utils/tiktoken.cjs
+++ b/dist/utils/tiktoken.cjs
@@ -1,25 +1,14 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.encodingForModel = exports.getEncoding = void 0;
-const lite_1 = require("js-tiktoken/lite");
const async_caller_js_1 = require("./async_caller.cjs");
const cache = {};
const caller = /* #__PURE__ */ new async_caller_js_1.AsyncCaller({});
async function getEncoding(encoding) {
- if (!(encoding in cache)) {
- cache[encoding] = caller
- .fetch(`https://tiktoken.pages.dev/js/${encoding}.json`)
- .then((res) => res.json())
- .then((data) => new lite_1.Tiktoken(data))
- .catch((e) => {
- delete cache[encoding];
- throw e;
- });
- }
- return await cache[encoding];
+ throw new Error("TikToken Not implemented");
}
exports.getEncoding = getEncoding;
async function encodingForModel(model) {
- return getEncoding((0, lite_1.getEncodingNameForModel)(model));
+ throw new Error("TikToken Not implemented");
}
exports.encodingForModel = encodingForModel;
diff --git a/dist/utils/tiktoken.js b/dist/utils/tiktoken.js
index 8e41ee6f00f2f9c7fa2c59fa2b2f4297634b97aa..aa5f314a6349ad0d1c5aea8631a56aad099176e0 100644
--- a/dist/utils/tiktoken.js
+++ b/dist/utils/tiktoken.js
@@ -1,20 +1,9 @@
-import { Tiktoken, getEncodingNameForModel, } from "js-tiktoken/lite";
import { AsyncCaller } from "./async_caller.js";
const cache = {};
const caller = /* #__PURE__ */ new AsyncCaller({});
export async function getEncoding(encoding) {
- if (!(encoding in cache)) {
- cache[encoding] = caller
- .fetch(`https://tiktoken.pages.dev/js/${encoding}.json`)
- .then((res) => res.json())
- .then((data) => new Tiktoken(data))
- .catch((e) => {
- delete cache[encoding];
- throw e;
- });
- }
- return await cache[encoding];
+ throw new Error("TikToken Not implemented");
}
export async function encodingForModel(model) {
- return getEncoding(getEncodingNameForModel(model));
+ throw new Error("TikToken Not implemented");
}
diff --git a/package.json b/package.json
index 36072aecf700fca1bc49832a19be832eca726103..90b8922fba1c3d1b26f78477c891b07816d6238a 100644
--- a/package.json
+++ b/package.json
@@ -37,7 +37,6 @@
"ansi-styles": "^5.0.0",
"camelcase": "6",
"decamelize": "1.2.0",
- "js-tiktoken": "^1.0.12",
"langsmith": ">=0.2.8 <0.4.0",
"mustache": "^4.2.0",
"p-queue": "^6.6.2",

View File

@@ -1,85 +0,0 @@
diff --git a/core.js b/core.js
index 862d66101f441fb4f47dfc8cff5e2d39e1f5a11e..6464bebbf696c39d35f0368f061ea4236225c162 100644
--- a/core.js
+++ b/core.js
@@ -159,7 +159,7 @@ class APIClient {
Accept: 'application/json',
'Content-Type': 'application/json',
'User-Agent': this.getUserAgent(),
- ...getPlatformHeaders(),
+ // ...getPlatformHeaders(),
...this.authHeaders(opts),
};
}
diff --git a/core.mjs b/core.mjs
index 05dbc6cfde51589a2b100d4e4b5b3c1a33b32b89..789fbb4985eb952a0349b779fa83b1a068af6e7e 100644
--- a/core.mjs
+++ b/core.mjs
@@ -152,7 +152,7 @@ export class APIClient {
Accept: 'application/json',
'Content-Type': 'application/json',
'User-Agent': this.getUserAgent(),
- ...getPlatformHeaders(),
+ // ...getPlatformHeaders(),
...this.authHeaders(opts),
};
}
diff --git a/error.mjs b/error.mjs
index 7d19f5578040afa004bc887aab1725e8703d2bac..59ec725b6142299a62798ac4bdedb63ba7d9932c 100644
--- a/error.mjs
+++ b/error.mjs
@@ -36,7 +36,7 @@ export class APIError extends OpenAIError {
if (!status || !headers) {
return new APIConnectionError({ message, cause: castToError(errorResponse) });
}
- const error = errorResponse?.['error'];
+ const error = errorResponse?.['error'] || errorResponse;
if (status === 400) {
return new BadRequestError(status, error, message, headers);
}
diff --git a/resources/embeddings.js b/resources/embeddings.js
index aae578404cb2d09a39ac33fc416f1c215c45eecd..25c54b05bdae64d5c3b36fbb30dc7c8221b14034 100644
--- a/resources/embeddings.js
+++ b/resources/embeddings.js
@@ -36,6 +36,9 @@ class Embeddings extends resource_1.APIResource {
// No encoding_format specified, defaulting to base64 for performance reasons
// See https://github.com/openai/openai-node/pull/1312
let encoding_format = hasUserProvidedEncodingFormat ? body.encoding_format : 'base64';
+ if (body.model.includes('jina')) {
+ encoding_format = undefined;
+ }
if (hasUserProvidedEncodingFormat) {
Core.debug('Request', 'User defined encoding_format:', body.encoding_format);
}
@@ -47,7 +50,7 @@ class Embeddings extends resource_1.APIResource {
...options,
});
// if the user specified an encoding_format, return the response as-is
- if (hasUserProvidedEncodingFormat) {
+ if (hasUserProvidedEncodingFormat || body.model.includes('jina')) {
return response;
}
// in this stage, we are sure the user did not specify an encoding_format
diff --git a/resources/embeddings.mjs b/resources/embeddings.mjs
index 0df3c6cc79a520e54acb4c2b5f77c43b774035ff..aa488b8a11b2c413c0a663d9a6059d286d7b5faf 100644
--- a/resources/embeddings.mjs
+++ b/resources/embeddings.mjs
@@ -10,6 +10,9 @@ export class Embeddings extends APIResource {
// No encoding_format specified, defaulting to base64 for performance reasons
// See https://github.com/openai/openai-node/pull/1312
let encoding_format = hasUserProvidedEncodingFormat ? body.encoding_format : 'base64';
+ if (body.model.includes('jina')) {
+ encoding_format = undefined;
+ }
if (hasUserProvidedEncodingFormat) {
Core.debug('Request', 'User defined encoding_format:', body.encoding_format);
}
@@ -21,7 +24,7 @@ export class Embeddings extends APIResource {
...options,
});
// if the user specified an encoding_format, return the response as-is
- if (hasUserProvidedEncodingFormat) {
+ if (hasUserProvidedEncodingFormat || body.model.includes('jina')) {
return response;
}
// in this stage, we are sure the user did not specify an encoding_format

View File

@@ -0,0 +1,279 @@
diff --git a/client.js b/client.js
index 33b4ff6309d5f29187dab4e285d07dac20340bab..8f568637ee9e4677585931fb0284c8165a933f69 100644
--- a/client.js
+++ b/client.js
@@ -433,7 +433,7 @@ class OpenAI {
'User-Agent': this.getUserAgent(),
'X-Stainless-Retry-Count': String(retryCount),
...(options.timeout ? { 'X-Stainless-Timeout': String(Math.trunc(options.timeout / 1000)) } : {}),
- ...(0, detect_platform_1.getPlatformHeaders)(),
+ // ...(0, detect_platform_1.getPlatformHeaders)(),
'OpenAI-Organization': this.organization,
'OpenAI-Project': this.project,
},
diff --git a/client.mjs b/client.mjs
index c34c18213073540ebb296ea540b1d1ad39527906..1ce1a98256d7e90e26ca963582f235b23e996e73 100644
--- a/client.mjs
+++ b/client.mjs
@@ -430,7 +430,7 @@ export class OpenAI {
'User-Agent': this.getUserAgent(),
'X-Stainless-Retry-Count': String(retryCount),
...(options.timeout ? { 'X-Stainless-Timeout': String(Math.trunc(options.timeout / 1000)) } : {}),
- ...getPlatformHeaders(),
+ // ...getPlatformHeaders(),
'OpenAI-Organization': this.organization,
'OpenAI-Project': this.project,
},
diff --git a/core/error.js b/core/error.js
index a12d9d9ccd242050161adeb0f82e1b98d9e78e20..fe3a5462480558bc426deea147f864f12b36f9bd 100644
--- a/core/error.js
+++ b/core/error.js
@@ -40,7 +40,7 @@ class APIError extends OpenAIError {
if (!status || !headers) {
return new APIConnectionError({ message, cause: (0, errors_1.castToError)(errorResponse) });
}
- const error = errorResponse?.['error'];
+ const error = errorResponse?.['error'] || errorResponse;
if (status === 400) {
return new BadRequestError(status, error, message, headers);
}
diff --git a/core/error.mjs b/core/error.mjs
index 83cefbaffeb8c657536347322d8de9516af479a2..63334b7972ec04882aa4a0800c1ead5982345045 100644
--- a/core/error.mjs
+++ b/core/error.mjs
@@ -36,7 +36,7 @@ export class APIError extends OpenAIError {
if (!status || !headers) {
return new APIConnectionError({ message, cause: castToError(errorResponse) });
}
- const error = errorResponse?.['error'];
+ const error = errorResponse?.['error'] || errorResponse;
if (status === 400) {
return new BadRequestError(status, error, message, headers);
}
diff --git a/resources/embeddings.js b/resources/embeddings.js
index 2404264d4ba0204322548945ebb7eab3bea82173..8f1bc45cc45e0797d50989d96b51147b90ae6790 100644
--- a/resources/embeddings.js
+++ b/resources/embeddings.js
@@ -5,52 +5,64 @@ exports.Embeddings = void 0;
const resource_1 = require("../core/resource.js");
const utils_1 = require("../internal/utils.js");
class Embeddings extends resource_1.APIResource {
- /**
- * Creates an embedding vector representing the input text.
- *
- * @example
- * ```ts
- * const createEmbeddingResponse =
- * await client.embeddings.create({
- * input: 'The quick brown fox jumped over the lazy dog',
- * model: 'text-embedding-3-small',
- * });
- * ```
- */
- create(body, options) {
- const hasUserProvidedEncodingFormat = !!body.encoding_format;
- // No encoding_format specified, defaulting to base64 for performance reasons
- // See https://github.com/openai/openai-node/pull/1312
- let encoding_format = hasUserProvidedEncodingFormat ? body.encoding_format : 'base64';
- if (hasUserProvidedEncodingFormat) {
- (0, utils_1.loggerFor)(this._client).debug('embeddings/user defined encoding_format:', body.encoding_format);
- }
- const response = this._client.post('/embeddings', {
- body: {
- ...body,
- encoding_format: encoding_format,
- },
- ...options,
- });
- // if the user specified an encoding_format, return the response as-is
- if (hasUserProvidedEncodingFormat) {
- return response;
- }
- // in this stage, we are sure the user did not specify an encoding_format
- // and we defaulted to base64 for performance reasons
- // we are sure then that the response is base64 encoded, let's decode it
- // the returned result will be a float32 array since this is OpenAI API's default encoding
- (0, utils_1.loggerFor)(this._client).debug('embeddings/decoding base64 embeddings from base64');
- return response._thenUnwrap((response) => {
- if (response && response.data) {
- response.data.forEach((embeddingBase64Obj) => {
- const embeddingBase64Str = embeddingBase64Obj.embedding;
- embeddingBase64Obj.embedding = (0, utils_1.toFloat32Array)(embeddingBase64Str);
- });
- }
- return response;
- });
- }
+ /**
+ * Creates an embedding vector representing the input text.
+ *
+ * @example
+ * ```ts
+ * const createEmbeddingResponse =
+ * await client.embeddings.create({
+ * input: 'The quick brown fox jumped over the lazy dog',
+ * model: 'text-embedding-3-small',
+ * });
+ * ```
+ */
+ create(body, options) {
+ const hasUserProvidedEncodingFormat = !!body.encoding_format;
+ // No encoding_format specified, defaulting to base64 for performance reasons
+ // See https://github.com/openai/openai-node/pull/1312
+ let encoding_format = hasUserProvidedEncodingFormat
+ ? body.encoding_format
+ : "base64";
+ if (body.model.includes("jina")) {
+ encoding_format = undefined;
+ }
+ if (hasUserProvidedEncodingFormat) {
+ (0, utils_1.loggerFor)(this._client).debug(
+ "embeddings/user defined encoding_format:",
+ body.encoding_format
+ );
+ }
+ const response = this._client.post("/embeddings", {
+ body: {
+ ...body,
+ encoding_format: encoding_format,
+ },
+ ...options,
+ });
+ // if the user specified an encoding_format, return the response as-is
+ if (hasUserProvidedEncodingFormat || body.model.includes("jina")) {
+ return response;
+ }
+ // in this stage, we are sure the user did not specify an encoding_format
+ // and we defaulted to base64 for performance reasons
+ // we are sure then that the response is base64 encoded, let's decode it
+ // the returned result will be a float32 array since this is OpenAI API's default encoding
+ (0, utils_1.loggerFor)(this._client).debug(
+ "embeddings/decoding base64 embeddings from base64"
+ );
+ return response._thenUnwrap((response) => {
+ if (response && response.data) {
+ response.data.forEach((embeddingBase64Obj) => {
+ const embeddingBase64Str = embeddingBase64Obj.embedding;
+ embeddingBase64Obj.embedding = (0, utils_1.toFloat32Array)(
+ embeddingBase64Str
+ );
+ });
+ }
+ return response;
+ });
+ }
}
exports.Embeddings = Embeddings;
//# sourceMappingURL=embeddings.js.map
diff --git a/resources/embeddings.mjs b/resources/embeddings.mjs
index 19dcaef578c194a89759c4360073cfd4f7dd2cbf..0284e9cc615c900eff508eb595f7360a74bd9200 100644
--- a/resources/embeddings.mjs
+++ b/resources/embeddings.mjs
@@ -2,51 +2,61 @@
import { APIResource } from "../core/resource.mjs";
import { loggerFor, toFloat32Array } from "../internal/utils.mjs";
export class Embeddings extends APIResource {
- /**
- * Creates an embedding vector representing the input text.
- *
- * @example
- * ```ts
- * const createEmbeddingResponse =
- * await client.embeddings.create({
- * input: 'The quick brown fox jumped over the lazy dog',
- * model: 'text-embedding-3-small',
- * });
- * ```
- */
- create(body, options) {
- const hasUserProvidedEncodingFormat = !!body.encoding_format;
- // No encoding_format specified, defaulting to base64 for performance reasons
- // See https://github.com/openai/openai-node/pull/1312
- let encoding_format = hasUserProvidedEncodingFormat ? body.encoding_format : 'base64';
- if (hasUserProvidedEncodingFormat) {
- loggerFor(this._client).debug('embeddings/user defined encoding_format:', body.encoding_format);
- }
- const response = this._client.post('/embeddings', {
- body: {
- ...body,
- encoding_format: encoding_format,
- },
- ...options,
- });
- // if the user specified an encoding_format, return the response as-is
- if (hasUserProvidedEncodingFormat) {
- return response;
- }
- // in this stage, we are sure the user did not specify an encoding_format
- // and we defaulted to base64 for performance reasons
- // we are sure then that the response is base64 encoded, let's decode it
- // the returned result will be a float32 array since this is OpenAI API's default encoding
- loggerFor(this._client).debug('embeddings/decoding base64 embeddings from base64');
- return response._thenUnwrap((response) => {
- if (response && response.data) {
- response.data.forEach((embeddingBase64Obj) => {
- const embeddingBase64Str = embeddingBase64Obj.embedding;
- embeddingBase64Obj.embedding = toFloat32Array(embeddingBase64Str);
- });
- }
- return response;
- });
- }
+ /**
+ * Creates an embedding vector representing the input text.
+ *
+ * @example
+ * ```ts
+ * const createEmbeddingResponse =
+ * await client.embeddings.create({
+ * input: 'The quick brown fox jumped over the lazy dog',
+ * model: 'text-embedding-3-small',
+ * });
+ * ```
+ */
+ create(body, options) {
+ const hasUserProvidedEncodingFormat = !!body.encoding_format;
+ // No encoding_format specified, defaulting to base64 for performance reasons
+ // See https://github.com/openai/openai-node/pull/1312
+ let encoding_format = hasUserProvidedEncodingFormat
+ ? body.encoding_format
+ : "base64";
+ if (body.model.includes("jina")) {
+ encoding_format = undefined;
+ }
+ if (hasUserProvidedEncodingFormat) {
+ loggerFor(this._client).debug(
+ "embeddings/user defined encoding_format:",
+ body.encoding_format
+ );
+ }
+ const response = this._client.post("/embeddings", {
+ body: {
+ ...body,
+ encoding_format: encoding_format,
+ },
+ ...options,
+ });
+ // if the user specified an encoding_format, return the response as-is
+ if (hasUserProvidedEncodingFormat || body.model.includes("jina")) {
+ return response;
+ }
+ // in this stage, we are sure the user did not specify an encoding_format
+ // and we defaulted to base64 for performance reasons
+ // we are sure then that the response is base64 encoded, let's decode it
+ // the returned result will be a float32 array since this is OpenAI API's default encoding
+ loggerFor(this._client).debug(
+ "embeddings/decoding base64 embeddings from base64"
+ );
+ return response._thenUnwrap((response) => {
+ if (response && response.data) {
+ response.data.forEach((embeddingBase64Obj) => {
+ const embeddingBase64Str = embeddingBase64Obj.embedding;
+ embeddingBase64Obj.embedding = toFloat32Array(embeddingBase64Str);
+ });
+ }
+ return response;
+ });
+ }
}
//# sourceMappingURL=embeddings.mjs.map

View File

@@ -3,10 +3,42 @@
<img src="https://github.com/CherryHQ/cherry-studio/blob/main/build/icon.png?raw=true" width="150" height="150" alt="banner" /><br>
</a>
</h1>
<p align="center">English | <a href="./docs/README.zh.md">中文</a> | <a href="./docs/README.ja.md">日本語</a><br></p>
<p align="center">English | <a href="./docs/README.zh.md">中文</a> | <a href="./docs/README.ja.md">日本語</a> | <a href="https://cherry-ai.com">Official Site</a> | <a href="https://docs.cherry-ai.com/cherry-studio-wen-dang/en-us">Documents</a> | <a href="./docs/dev.md">Development</a> | <a href="https://github.com/CherryHQ/cherry-studio/issues">Feedback</a><br></p>
<!-- 题头徽章组合 -->
<div align="center">
[![][deepwiki-shield]][deepwiki-link]
[![][twitter-shield]][twitter-link]
[![][discord-shield]][discord-link]
[![][telegram-shield]][telegram-link]
</div>
<!-- 项目统计徽章 -->
<div align="center">
[![][github-stars-shield]][github-stars-link]
[![][github-forks-shield]][github-forks-link]
[![][github-release-shield]][github-release-link]
[![][github-contributors-shield]][github-contributors-link]
</div>
<div align="center">
[![][license-shield]][license-link]
[![][commercial-shield]][commercial-link]
[![][sponsor-shield]][sponsor-link]
</div>
<div align="center">
<a href="https://hellogithub.com/repository/1605492e1e2a4df3be07abfa4578dd37" target="_blank"><img src="https://api.hellogithub.com/v1/widgets/recommend.svg?rid=1605492e1e2a4df3be07abfa4578dd37" alt="FeaturedHelloGitHub" style="width: 200px; height: 43px;" width="200" height="43" /></a>
<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>
<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: 200px; height: 43px;" width="200" height="43" /></a>
</div>
# 🍒 Cherry Studio
@@ -17,10 +49,6 @@ Cherry Studio is a desktop client that supports for multiple LLM providers, avai
❤️ Like Cherry Studio? Give it a star 🌟 or [Sponsor](docs/sponsor.md) to support the development!
# 📖 Guide
<https://docs.cherry-ai.com>
# 🌠 Screenshot
![](https://github.com/user-attachments/assets/36dddb2c-e0fb-4a5f-9411-91447bab6e18)
@@ -114,14 +142,6 @@ Want to influence our roadmap? Join our [GitHub Discussions](https://github.com/
Welcome PR for more themes
# 🖥️ Develop
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-en.md) for contribution guidelines
# 🤝 Contributing
We welcome contributions to Cherry Studio! Here are some ways you can contribute:
@@ -134,6 +154,8 @@ We welcome contributions to Cherry Studio! Here are some ways you can contribute
6. **Community Engagement**: Join discussions and help users.
7. **Promote Usage**: Spread the word about Cherry Studio.
Refer to the [Branching Strategy](docs/branching-strategy-en.md) for contribution guidelines
## Getting Started
1. **Fork the Repository**: Fork and clone it to your local machine.
@@ -158,22 +180,34 @@ Thank you for your support and contributions!
</a>
<br /><br />
# 🌐 Community
[Telegram](https://t.me/CherryStudioAI) | [Email](mailto:support@cherry-ai.com) | [Twitter](https://x.com/kangfenmao)
# ☕ Sponsor
[Buy Me a Coffee](docs/sponsor.md)
# 📃 License
[LICENSE](./LICENSE)
# ✉️ Contact
<yinsenho@cherry-ai.com>
# ⭐️ Star History
[![Star History Chart](https://api.star-history.com/svg?repos=kangfenmao/cherry-studio&type=Timeline)](https://star-history.com/#kangfenmao/cherry-studio&Timeline)
[![Star History Chart](https://api.star-history.com/svg?repos=CherryHQ/cherry-studio&type=Timeline)](https://star-history.com/#CherryHQ/cherry-studio&Timeline)
<!-- Links & Images -->
[deepwiki-shield]: https://img.shields.io/badge/Deepwiki-CherryHQ-0088CC?style=plastic
[deepwiki-link]: https://deepwiki.com/CherryHQ/cherry-studio
[twitter-shield]: https://img.shields.io/badge/Twitter-CherryStudioApp-0088CC?style=plastic&logo=x
[twitter-link]: https://twitter.com/CherryStudioApp
[discord-shield]: https://img.shields.io/badge/Discord-@CherryStudio-0088CC?style=plastic&logo=discord
[discord-link]: https://discord.gg/wez8HtpxqQ
[telegram-shield]: https://img.shields.io/badge/Telegram-@CherryStudioAI-0088CC?style=plastic&logo=telegram
[telegram-link]: https://t.me/CherryStudioAI
<!-- Links & Images -->
[github-stars-shield]: https://img.shields.io/github/stars/CherryHQ/cherry-studio?style=social
[github-stars-link]: https://github.com/CherryHQ/cherry-studio/stargazers
[github-forks-shield]: https://img.shields.io/github/forks/CherryHQ/cherry-studio?style=social
[github-forks-link]: https://github.com/CherryHQ/cherry-studio/network
[github-release-shield]: https://img.shields.io/github/v/release/CherryHQ/cherry-studio
[github-release-link]: https://github.com/CherryHQ/cherry-studio/releases
[github-contributors-shield]: https://img.shields.io/github/contributors/CherryHQ/cherry-studio
[github-contributors-link]: https://github.com/CherryHQ/cherry-studio/graphs/contributors
<!-- Links & Images -->
[license-shield]: https://img.shields.io/badge/License-AGPLv3-important.svg?style=plastic&logo=gnu
[license-link]: https://www.gnu.org/licenses/agpl-3.0
[commercial-shield]: https://img.shields.io/badge/License-Contact-white.svg?style=plastic&logoColor=white&logo=telegram&color=blue
[commercial-link]: mailto:license@cherry-ai.com?subject=Commercial%20License%20Inquiry
[sponsor-shield]: https://img.shields.io/badge/Sponsor-FF6699.svg?style=plastic&logo=githubsponsors&logoColor=white
[sponsor-link]: https://github.com/CherryHQ/cherry-studio/blob/main/docs/sponsor.md

21
components.json Normal file
View File

@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/renderer/src/assets/styles/tailwind.css",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@renderer/ui/third-party",
"utils": "@renderer/utils",
"ui": "@renderer/ui",
"lib": "@renderer/lib",
"hooks": "@renderer/hooks"
},
"iconLibrary": "lucide"
}

View File

@@ -1,15 +1,46 @@
<h1 align="center">
<a href="https://github.com/CherryHQ/cherry-studio/releases">
<img src="https://github.com/CherryHQ/cherry-studio/blob/main/build/icon.png?raw=true" width="150" height="150" alt="banner" />
<img src="https://github.com/CherryHQ/cherry-studio/blob/main/build/icon.png?raw=true" width="150" height="150" alt="banner" /><br>
</a>
</h1>
<p align="center">
<a href="https://github.com/CherryHQ/cherry-studio">English</a> | <a href="./README.zh.md">中文</a> | 日本語 <br>
<a href="https://github.com/CherryHQ/cherry-studio">English</a> | <a href="./README.zh.md">中文</a> | 日本語 | <a href="https://cherry-ai.com">公式サイト</a> | <a href="https://docs.cherry-ai.com/cherry-studio-wen-dang/ja">ドキュメント</a> | <a href="./dev.md">開発</a> | <a href="https://github.com/CherryHQ/cherry-studio/issues">フィードバック</a><br>
</p>
<!-- バッジコレクション -->
<div align="center">
[![][deepwiki-shield]][deepwiki-link]
[![][twitter-shield]][twitter-link]
[![][discord-shield]][discord-link]
[![][telegram-shield]][telegram-link]
</div>
<!-- プロジェクト統計 -->
<div align="center">
[![][github-stars-shield]][github-stars-link]
[![][github-forks-shield]][github-forks-link]
[![][github-release-shield]][github-release-link]
[![][github-contributors-shield]][github-contributors-link]
</div>
<div align="center">
[![][license-shield]][license-link]
[![][commercial-shield]][commercial-link]
[![][sponsor-shield]][sponsor-link]
</div>
<div align="center">
<a href="https://hellogithub.com/repository/1605492e1e2a4df3be07abfa4578dd37" target="_blank"><img src="https://api.hellogithub.com/v1/widgets/recommend.svg?rid=1605492e1e2a4df3be07abfa4578dd37" alt="FeaturedHelloGitHub" style="width: 200px; height: 43px;" width="200" height="43" /></a>
<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>
<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: 200px; height: 43px;" width="200" height="43" /></a>
</div>
# 🍒 Cherry Studio
@@ -20,10 +51,6 @@ Cherry Studio は、複数の LLM プロバイダーをサポートするデス
❤️ Cherry Studio をお気に入りにしましたか?小さな星をつけてください 🌟 または [スポンサー](sponsor.md) をして開発をサポートしてください!
# 📖 ガイド
https://docs.cherry-ai.com
# 🌠 スクリーンショット
![](https://github.com/user-attachments/assets/36dddb2c-e0fb-4a5f-9411-91447bab6e18)
@@ -117,14 +144,6 @@ https://docs.cherry-ai.com
より多くのテーマの PR を歓迎します
# 🖥️ 開発
[開発ドキュメント](dev.md)を参照してください
[アーキテクチャ概要ドキュメント](https://deepwiki.com/CherryHQ/cherry-studio)を参照してください
[ブランチ戦略](branching-strategy-en.md)を参照して貢献ガイドラインを確認してください
# 🤝 貢献
Cherry Studio への貢献を歓迎します!以下の方法で貢献できます:
@@ -137,6 +156,8 @@ Cherry Studio への貢献を歓迎します!以下の方法で貢献できま
6. **コミュニティの参加**:ディスカッションに参加し、ユーザーを支援します
7. **使用の促進**Cherry Studio を広めます
[ブランチ戦略](branching-strategy-en.md)を参照して貢献ガイドラインを確認してください
## 始め方
1. **リポジトリをフォーク**:フォークしてローカルマシンにクローンします
@@ -161,22 +182,34 @@ Cherry Studio への貢献を歓迎します!以下の方法で貢献できま
</a>
<br /><br />
# 🌐 コミュニティ
[Telegram](https://t.me/CherryStudioAI) | [Email](mailto:support@cherry-ai.com) | [Twitter](https://x.com/kangfenmao)
# ☕ スポンサー
[開発者を支援する](sponsor.md)
# 📃 ライセンス
[LICENSE](../LICENSE)
# ✉️ お問い合わせ
yinsenho@cherry-ai.com
# ⭐️ スター履歴
[![Star History Chart](https://api.star-history.com/svg?repos=kangfenmao/cherry-studio&type=Timeline)](https://star-history.com/#kangfenmao/cherry-studio&Timeline)
[![Star History Chart](https://api.star-history.com/svg?repos=CherryHQ/cherry-studio&type=Timeline)](https://star-history.com/#CherryHQ/cherry-studio&Timeline)
<!-- リンクと画像 -->
[deepwiki-shield]: https://img.shields.io/badge/Deepwiki-CherryHQ-0088CC?style=plastic
[deepwiki-link]: https://deepwiki.com/CherryHQ/cherry-studio
[twitter-shield]: https://img.shields.io/badge/Twitter-CherryStudioApp-0088CC?style=plastic&logo=x
[twitter-link]: https://twitter.com/CherryStudioApp
[discord-shield]: https://img.shields.io/badge/Discord-@CherryStudio-0088CC?style=plastic&logo=discord
[discord-link]: https://discord.gg/wez8HtpxqQ
[telegram-shield]: https://img.shields.io/badge/Telegram-@CherryStudioAI-0088CC?style=plastic&logo=telegram
[telegram-link]: https://t.me/CherryStudioAI
<!-- プロジェクト統計 -->
[github-stars-shield]: https://img.shields.io/github/stars/CherryHQ/cherry-studio?style=social
[github-stars-link]: https://github.com/CherryHQ/cherry-studio/stargazers
[github-forks-shield]: https://img.shields.io/github/forks/CherryHQ/cherry-studio?style=social
[github-forks-link]: https://github.com/CherryHQ/cherry-studio/network
[github-release-shield]: https://img.shields.io/github/v/release/CherryHQ/cherry-studio
[github-release-link]: https://github.com/CherryHQ/cherry-studio/releases
[github-contributors-shield]: https://img.shields.io/github/contributors/CherryHQ/cherry-studio
[github-contributors-link]: https://github.com/CherryHQ/cherry-studio/graphs/contributors
<!-- ライセンスとスポンサー -->
[license-shield]: https://img.shields.io/badge/License-AGPLv3-important.svg?style=plastic&logo=gnu
[license-link]: https://www.gnu.org/licenses/agpl-3.0
[commercial-shield]: https://img.shields.io/badge/商用ライセンス-お問い合わせ-white.svg?style=plastic&logoColor=white&logo=telegram&color=blue
[commercial-link]: mailto:license@cherry-ai.com?subject=商業ライセンスについて
[sponsor-shield]: https://img.shields.io/badge/スポンサー-FF6699.svg?style=plastic&logo=githubsponsors&logoColor=white
[sponsor-link]: https://github.com/CherryHQ/cherry-studio/blob/main/docs/sponsor.md

View File

@@ -1,14 +1,46 @@
<h1 align="center">
<a href="https://github.com/CherryHQ/cherry-studio/releases">
<img src="https://github.com/CherryHQ/cherry-studio/blob/main/build/icon.png?raw=true" width="150" height="150" alt="banner" />
<img src="https://github.com/CherryHQ/cherry-studio/blob/main/build/icon.png?raw=true" width="150" height="150" alt="banner" /><br>
</a>
</h1>
<p align="center">
<a href="https://github.com/CherryHQ/cherry-studio">English</a> | 中文 | <a href="./README.ja.md">日本語</a><br>
<a href="https://github.com/CherryHQ/cherry-studio">English</a> | 中文 | <a href="./README.ja.md">日本語</a> | <a href="https://cherry-ai.com">官方网站</a> | <a href="https://docs.cherry-ai.com/cherry-studio-wen-dang/zh-cn">文档</a> | <a href="./dev.md">开发</a> | <a href="https://github.com/CherryHQ/cherry-studio/issues">反馈</a><br>
</p>
<!-- 题头徽章组合 -->
<div align="center">
[![][deepwiki-shield]][deepwiki-link]
[![][twitter-shield]][twitter-link]
[![][discord-shield]][discord-link]
[![][telegram-shield]][telegram-link]
</div>
<!-- 项目统计徽章 -->
<div align="center">
[![][github-stars-shield]][github-stars-link]
[![][github-forks-shield]][github-forks-link]
[![][github-release-shield]][github-release-link]
[![][github-contributors-shield]][github-contributors-link]
</div>
<div align="center">
[![][license-shield]][license-link]
[![][commercial-shield]][commercial-link]
[![][sponsor-shield]][sponsor-link]
</div>
<div align="center">
<a href="https://hellogithub.com/repository/1605492e1e2a4df3be07abfa4578dd37" target="_blank"><img src="https://api.hellogithub.com/v1/widgets/recommend.svg?rid=1605492e1e2a4df3be07abfa4578dd37" alt="FeaturedHelloGitHub" style="width: 200px; height: 43px;" width="200" height="43" /></a>
<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>
<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: 200px; height: 43px;" width="200" height="43" /></a>
</div>
# 🍒 Cherry Studio
@@ -124,14 +156,6 @@ https://docs.cherry-ai.com
欢迎 PR 更多主题
# 🖥️ 开发
参考[开发文档](dev.md)
参考[架构概览文档](https://deepwiki.com/CherryHQ/cherry-studio)
参考[分支策略](branching-strategy-zh.md)了解贡献指南
# 🤝 贡献
我们欢迎对 Cherry Studio 的贡献!您可以通过以下方式贡献:
@@ -144,6 +168,8 @@ https://docs.cherry-ai.com
6. **社区参与**:加入讨论并帮助用户
7. **推广使用**:宣传 Cherry Studio
参考[分支策略](branching-strategy-zh.md)了解贡献指南
## 入门
1. **Fork 仓库**Fork 并克隆到您的本地机器
@@ -168,22 +194,34 @@ https://docs.cherry-ai.com
</a>
<br /><br />
# 🌐 社区
[Telegram](https://t.me/CherryStudioAI) | [Email](mailto:support@cherry-ai.com) | [Twitter](https://x.com/kangfenmao)
# ☕ 赞助
[赞助开发者](sponsor.md)
# 📃 许可证
[LICENSE](../LICENSE)
# ✉️ 联系我们
yinsenho@cherry-ai.com
# ⭐️ Star 记录
[![Star History Chart](https://api.star-history.com/svg?repos=kangfenmao/cherry-studio&type=Timeline)](https://star-history.com/#kangfenmao/cherry-studio&Timeline)
[![Star History Chart](https://api.star-history.com/svg?repos=CherryHQ/cherry-studio&type=Timeline)](https://star-history.com/#CherryHQ/cherry-studio&Timeline)
<!-- Links & Images -->
[deepwiki-shield]: https://img.shields.io/badge/Deepwiki-CherryHQ-0088CC?style=plastic
[deepwiki-link]: https://deepwiki.com/CherryHQ/cherry-studio
[twitter-shield]: https://img.shields.io/badge/Twitter-CherryStudioApp-0088CC?style=plastic&logo=x
[twitter-link]: https://twitter.com/CherryStudioApp
[discord-shield]: https://img.shields.io/badge/Discord-@CherryStudio-0088CC?style=plastic&logo=discord
[discord-link]: https://discord.gg/wez8HtpxqQ
[telegram-shield]: https://img.shields.io/badge/Telegram-@CherryStudioAI-0088CC?style=plastic&logo=telegram
[telegram-link]: https://t.me/CherryStudioAI
<!-- 项目统计徽章 -->
[github-stars-shield]: https://img.shields.io/github/stars/CherryHQ/cherry-studio?style=social
[github-stars-link]: https://github.com/CherryHQ/cherry-studio/stargazers
[github-forks-shield]: https://img.shields.io/github/forks/CherryHQ/cherry-studio?style=social
[github-forks-link]: https://github.com/CherryHQ/cherry-studio/network
[github-release-shield]: https://img.shields.io/github/v/release/CherryHQ/cherry-studio
[github-release-link]: https://github.com/CherryHQ/cherry-studio/releases
[github-contributors-shield]: https://img.shields.io/github/contributors/CherryHQ/cherry-studio
[github-contributors-link]: https://github.com/CherryHQ/cherry-studio/graphs/contributors
<!-- 许可和赞助徽章 -->
[license-shield]: https://img.shields.io/badge/License-AGPLv3-important.svg?style=plastic&logo=gnu
[license-link]: https://www.gnu.org/licenses/agpl-3.0
[commercial-shield]: https://img.shields.io/badge/商用授权-联系-white.svg?style=plastic&logoColor=white&logo=telegram&color=blue
[commercial-link]: mailto:license@cherry-ai.com?subject=商业授权咨询
[sponsor-shield]: https://img.shields.io/badge/赞助支持-FF6699.svg?style=plastic&logo=githubsponsors&logoColor=white
[sponsor-link]: https://github.com/CherryHQ/cherry-studio/blob/main/docs/sponsor.md

View File

@@ -12,30 +12,43 @@ electronLanguages:
directories:
buildResources: build
files:
- '!{.vscode,.yarn,.github}'
- '!electron.vite.config.{js,ts,mjs,cjs}'
- '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
- '**/*'
- '!**/{.vscode,.yarn,.yarn-lock,.github,.cursorrules,.prettierrc}'
- '!electron.vite.config.{js,ts,mjs,cjs}}'
- '!**/{.eslintignore,.eslintrc.js,.eslintrc.json,.eslintcache,root.eslint.config.js,eslint.config.js,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,eslint.config.mjs,dev-app-update.yml,CHANGELOG.md,README.md}'
- '!**/{.env,.env.*,.npmrc,pnpm-lock.yaml}'
- '!**/{tsconfig.json,tsconfig.tsbuildinfo,tsconfig.node.json,tsconfig.web.json}'
- '!**/{.editorconfig,.jekyll-metadata}'
- '!src'
- '!scripts'
- '!local'
- '!docs'
- '!packages'
- '!.swc'
- '!.bin'
- '!._*'
- '!*.log'
- '!stats.html'
- '!*.md'
- '!**/*.{iml,o,hprof,orig,pyc,pyo,rbc,swp,csproj,sln,xproj}'
- '!**/*.{map,ts,tsx,jsx,less,scss,sass,css.d.ts,d.cts,d.mts,md,markdown,yaml,yml}'
- '!**/{test,tests,__tests__,coverage}/**'
- '!**/{test,tests,__tests__,powered-test,coverage}/**'
- '!**/{example,examples}/**'
- '!**/*.{spec,test}.{js,jsx,ts,tsx}'
- '!**/*.min.*.map'
- '!**/*.d.ts'
- '!**/{.DS_Store,Thumbs.db}'
- '!**/{LICENSE,LICENSE.txt,LICENSE-MIT.txt,*.LICENSE.txt,NOTICE.txt,README.md,CHANGELOG.md}'
- '!**/dist/es6/**'
- '!**/dist/demo/**'
- '!**/amd/**'
- '!**/{.DS_Store,Thumbs.db,thumbs.db,__pycache__}'
- '!**/{LICENSE,license,LICENSE.*,*.LICENSE.txt,NOTICE.txt,README.md,readme.md,CHANGELOG.md}'
- '!node_modules/rollup-plugin-visualizer'
- '!node_modules/js-tiktoken'
- '!node_modules/@tavily/core/node_modules/js-tiktoken'
- '!node_modules/pdf-parse/lib/pdf.js/{v1.9.426,v1.10.88,v2.0.550}'
- '!node_modules/mammoth/{mammoth.browser.js,mammoth.browser.min.js}'
- '!node_modules/selection-hook/prebuilds/**/*' # we rebuild .node, don't use prebuilds
- '!**/*.{h,iobj,ipdb,tlog,recipe,vcxproj,vcxproj.filters}' # filter .node build files
asarUnpack:
- resources/**
- '**/*.{metal,exp,lib}'
@@ -94,10 +107,8 @@ afterSign: scripts/notarize.js
artifactBuildCompleted: scripts/artifact-build-completed.js
releaseInfo:
releaseNotes: |
⚠️ 注意:升级前请备份数据,否则将无法降级
文生图新增服务商 DMXAPI限时免费
输入框按钮支持拖拽排序
修复知识库搜索结果 100% 问题
修复拖拽多选消息相关问题
修复翻译回复内容导致内存异常问题
常规错误修复和优化
新增划词助手
助手支持分组
支持主题颜色切换
划词助手支持应用过滤
翻译模块功能改进

View File

@@ -9,25 +9,7 @@ const visualizerPlugin = (type: 'renderer' | 'main') => {
export default defineConfig({
main: {
plugins: [
externalizeDepsPlugin({
exclude: [
'@cherrystudio/embedjs',
'@cherrystudio/embedjs-openai',
'@cherrystudio/embedjs-loader-web',
'@cherrystudio/embedjs-loader-markdown',
'@cherrystudio/embedjs-loader-msoffice',
'@cherrystudio/embedjs-loader-xml',
'@cherrystudio/embedjs-loader-pdf',
'@cherrystudio/embedjs-loader-sitemap',
'@cherrystudio/embedjs-libsql',
'@cherrystudio/embedjs-loader-image',
'p-queue',
'webdav'
]
}),
...visualizerPlugin('main')
],
plugins: [externalizeDepsPlugin(), ...visualizerPlugin('main')],
resolve: {
alias: {
'@main': resolve('src/main'),
@@ -37,7 +19,7 @@ export default defineConfig({
},
build: {
rollupOptions: {
external: ['@libsql/client']
external: ['@libsql/client', 'bufferutil', 'utf-8-validate']
},
sourcemap: process.env.NODE_ENV === 'development'
},
@@ -58,6 +40,7 @@ export default defineConfig({
},
renderer: {
plugins: [
(async () => (await import('@tailwindcss/vite')).default())(),
react({
plugins: [
[
@@ -89,7 +72,9 @@ export default defineConfig({
rollupOptions: {
input: {
index: resolve(__dirname, 'src/renderer/index.html'),
miniWindow: resolve(__dirname, 'src/renderer/miniWindow.html')
miniWindow: resolve(__dirname, 'src/renderer/miniWindow.html'),
selectionToolbar: resolve(__dirname, 'src/renderer/selectionToolbar.html'),
selectionAction: resolve(__dirname, 'src/renderer/selectionAction.html')
}
}
}

View File

@@ -62,7 +62,8 @@ export default defineConfig([
'.yarn/**',
'.gitignore',
'scripts/cloudflare-worker.js',
'src/main/integration/nutstore/sso/lib/**'
'src/main/integration/nutstore/sso/lib/**',
'src/renderer/src/ui/**'
]
}
])

View File

@@ -1,6 +1,6 @@
{
"name": "CherryStudio",
"version": "1.3.12",
"version": "1.4.1",
"private": true,
"description": "A powerful AI assistant for producer.",
"main": "./out/main/index.js",
@@ -22,7 +22,7 @@
"dev": "electron-vite dev",
"debug": "electron-vite -- --inspect --sourcemap --remote-debugging-port=9222",
"build": "npm run typecheck && electron-vite build",
"build:check": "yarn test && yarn typecheck && yarn check:i18n",
"build:check": "yarn typecheck && yarn check:i18n && yarn test",
"build:unpack": "dotenv npm run build && electron-builder --dir",
"build:win": "dotenv npm run build && electron-builder --win --x64 --arm64",
"build:win:x64": "dotenv npm run build && electron-builder --win --x64",
@@ -38,19 +38,20 @@
"publish": "yarn build:check && yarn release patch push",
"pulish:artifacts": "cd packages/artifacts && npm publish && cd -",
"generate:agents": "yarn workspace @cherry-studio/database agents",
"generate:icons": "electron-icon-builder --input=./build/logo.png --output=build",
"analyze:renderer": "VISUALIZER_RENDERER=true yarn build",
"analyze:main": "VISUALIZER_MAIN=true yarn build",
"typecheck": "npm run typecheck:node && npm run typecheck:web",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
"check:i18n": "node scripts/check-i18n.js",
"test": "yarn test:renderer",
"test:coverage": "yarn test:renderer:coverage",
"test:node": "npx -y tsx --test src/**/*.test.ts",
"test:renderer": "vitest run",
"test:renderer:ui": "vitest --ui",
"test:renderer:coverage": "vitest run --coverage",
"test": "vitest run --silent",
"test:main": "vitest run --project main",
"test:renderer": "vitest run --project renderer",
"test:update": "yarn test:renderer --update",
"test:coverage": "vitest run --coverage --silent",
"test:ui": "vitest --ui",
"test:watch": "vitest",
"test:e2e": "yarn playwright test",
"test:lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts",
"format": "prettier --write .",
"lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
@@ -69,14 +70,12 @@
"@cherrystudio/embedjs-loader-xml": "^0.1.31",
"@cherrystudio/embedjs-openai": "^0.1.31",
"@electron-toolkit/utils": "^3.0.0",
"@electron/notarize": "^2.5.0",
"@langchain/community": "^0.3.36",
"@strongtz/win32-arm64-msvc": "^0.4.7",
"@tanstack/react-query": "^5.27.0",
"@types/react-infinite-scroll-component": "^5.0.0",
"archiver": "^7.0.1",
"async-mutex": "^0.5.0",
"color": "^5.0.0",
"diff": "^7.0.0",
"docx": "^9.0.2",
"electron-log": "^5.1.5",
@@ -84,22 +83,19 @@
"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",
"franc-min": "^6.2.0",
"fs-extra": "^11.2.0",
"got-scraping": "^4.1.1",
"jsdom": "^26.0.0",
"markdown-it": "^14.1.0",
"node-stream-zip": "^1.15.0",
"officeparser": "^4.1.1",
"os-proxy-config": "^1.1.2",
"proxy-agent": "^6.5.0",
"selection-hook": "^0.9.22",
"tar": "^7.4.3",
"turndown": "^7.2.0",
"turndown-plugin-gfm": "^1.0.2",
"webdav": "^5.8.0",
"ws": "^8.18.1",
"zipread": "^1.3.3"
},
"devDependencies": {
@@ -112,18 +108,32 @@
"@electron-toolkit/eslint-config-ts": "^3.0.0",
"@electron-toolkit/preload": "^3.0.0",
"@electron-toolkit/tsconfig": "^1.0.1",
"@electron/notarize": "^2.5.0",
"@emotion/is-prop-valid": "^1.3.1",
"@eslint-react/eslint-plugin": "^1.36.1",
"@eslint/js": "^9.22.0",
"@google/genai": "^0.13.0",
"@google/genai": "^1.0.1",
"@hello-pangea/dnd": "^16.6.0",
"@kangfenmao/keyv-storage": "^0.1.0",
"@modelcontextprotocol/sdk": "^1.11.4",
"@mozilla/readability": "^0.6.0",
"@notionhq/client": "^2.2.15",
"@playwright/test": "^1.52.0",
"@radix-ui/react-collapsible": "^1.1.10",
"@radix-ui/react-dialog": "^1.1.13",
"@radix-ui/react-dropdown-menu": "^2.1.14",
"@radix-ui/react-separator": "^1.1.6",
"@radix-ui/react-slot": "^1.2.2",
"@radix-ui/react-tabs": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.6",
"@reduxjs/toolkit": "^2.2.5",
"@shikijs/markdown-it": "^3.4.2",
"@swc/plugin-styled-components": "^7.1.5",
"@tailwindcss/vite": "^4.1.5",
"@tavily/core": "patch:@tavily/core@npm%3A0.3.1#~/.yarn/patches/@tavily-core-npm-0.3.1-fe69bf2bea.patch",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@tryfabric/martian": "^1.2.4",
"@types/diff": "^7",
"@types/fs-extra": "^11",
@@ -137,17 +147,21 @@
"@types/react-infinite-scroll-component": "^5.0.0",
"@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/ui": "^3.1.1",
"@vitest/web-worker": "^3.1.3",
"@vitest/browser": "^3.1.4",
"@vitest/coverage-v8": "^3.1.4",
"@vitest/ui": "^3.1.4",
"@vitest/web-worker": "^3.1.4",
"@xyflow/react": "^12.4.4",
"antd": "^5.22.5",
"axios": "^1.7.3",
"browser-image-compression": "^2.0.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"color": "^5.0.0",
"dayjs": "^1.11.11",
"dexie": "^4.0.8",
"dexie-react-hooks": "^1.1.7",
@@ -155,7 +169,6 @@
"electron": "35.4.0",
"electron-builder": "26.0.15",
"electron-devtools-installer": "^3.2.0",
"electron-icon-builder": "^2.0.1",
"electron-vite": "^3.1.0",
"emittery": "^1.0.3",
"emoji-picker-element": "^1.22.1",
@@ -163,20 +176,25 @@
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-unused-imports": "^4.1.4",
"fast-diff": "^1.3.0",
"html-to-image": "^1.11.13",
"husky": "^9.1.7",
"i18next": "^23.11.5",
"jest-styled-components": "^7.2.0",
"lint-staged": "^15.5.0",
"lodash": "^4.17.21",
"lru-cache": "^11.1.0",
"lucide-react": "^0.487.0",
"lucide-react": "^0.511.0",
"mermaid": "^11.6.0",
"mime": "^4.0.4",
"motion": "^12.10.5",
"motion": "^12.12.1",
"next-themes": "^0.4.6",
"npx-scope-finder": "^1.2.0",
"openai": "patch:openai@npm%3A4.96.0#~/.yarn/patches/openai-npm-4.96.0-0665b05cb9.patch",
"openai": "patch:openai@npm%3A5.1.0#~/.yarn/patches/openai-npm-5.1.0-0e7b3ccb07.patch",
"p-queue": "^8.1.0",
"playwright": "^1.52.0",
"prettier": "^3.5.3",
"prettier-plugin-tailwindcss": "^0.6.11",
"rc-virtual-list": "^3.18.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@@ -200,26 +218,31 @@
"rollup-plugin-visualizer": "^5.12.0",
"sass": "^1.88.0",
"shiki": "^3.4.2",
"sonner": "^2.0.3",
"string-width": "^7.2.0",
"styled-components": "^6.1.11",
"tailwind-merge": "^3.2.0",
"tailwindcss": "^4.1.5",
"tiny-pinyin": "^1.3.2",
"tokenx": "^0.4.1",
"tw-animate-css": "^1.2.9",
"typescript": "^5.6.2",
"usehooks-ts": "^3.1.1",
"uuid": "^10.0.0",
"vite": "6.2.6",
"vitest": "^3.1.1"
"vitest": "^3.1.4"
},
"resolutions": {
"pdf-parse@npm:1.1.1": "patch:pdf-parse@npm%3A1.1.1#~/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch",
"@langchain/openai@npm:^0.3.16": "patch:@langchain/openai@npm%3A0.3.16#~/.yarn/patches/@langchain-openai-npm-0.3.16-e525b59526.patch",
"@langchain/openai@npm:>=0.1.0 <0.4.0": "patch:@langchain/openai@npm%3A0.3.16#~/.yarn/patches/@langchain-openai-npm-0.3.16-e525b59526.patch",
"node-gyp": "^9.1.0",
"libsql@npm:^0.4.4": "patch:libsql@npm%3A0.4.7#~/.yarn/patches/libsql-npm-0.4.7-444e260fb1.patch",
"openai@npm:^4.77.0": "patch:openai@npm%3A4.96.0#~/.yarn/patches/openai-npm-4.96.0-0665b05cb9.patch",
"openai@npm:^4.77.0": "patch:openai@npm%3A5.1.0#~/.yarn/patches/openai-npm-5.1.0-0e7b3ccb07.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",
"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"
"openai@npm:^4.87.3": "patch:openai@npm%3A5.1.0#~/.yarn/patches/openai-npm-5.1.0-0e7b3ccb07.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",
"@langchain/core@npm:^0.3.26": "patch:@langchain/core@npm%3A0.3.44#~/.yarn/patches/@langchain-core-npm-0.3.44-41d5c3cb0a.patch"
},
"packageManager": "yarn@4.9.1",
"lint-staged": {

View File

@@ -11,9 +11,9 @@ export enum IpcChannel {
App_SetLaunchToTray = 'app:set-launch-to-tray',
App_SetTray = 'app:set-tray',
App_SetTrayOnClose = 'app:set-tray-on-close',
App_RestartTray = 'app:restart-tray',
App_SetTheme = 'app:set-theme',
App_SetAutoUpdate = 'app:set-auto-update',
App_SetFeedUrl = 'app:set-feed-url',
App_HandleZoomFactor = 'app:handle-zoom-factor',
App_IsBinaryExist = 'app:is-binary-exist',
@@ -21,6 +21,8 @@ export enum IpcChannel {
App_InstallUvBinary = 'app:install-uv-binary',
App_InstallBunBinary = 'app:install-bun-binary',
App_QuoteToMain = 'app:quote-to-main',
Notification_Send = 'notification:send',
Notification_OnClick = 'notification:on-click',
@@ -111,6 +113,7 @@ export enum IpcChannel {
File_WriteWithId = 'file:writeWithId',
File_SaveImage = 'file:saveImage',
File_Base64Image = 'file:base64Image',
File_SaveBase64Image = 'file:saveBase64Image',
File_Download = 'file:download',
File_Copy = 'file:copy',
File_BinaryImage = 'file:binaryImage',
@@ -144,7 +147,7 @@ export enum IpcChannel {
// events
BackupProgress = 'backup-progress',
ThemeChange = 'theme:change',
ThemeUpdated = 'theme:updated',
UpdateDownloadedCancelled = 'update-downloaded-cancelled',
RestoreProgress = 'restore-progress',
UpdateError = 'update-error',
@@ -176,5 +179,23 @@ export enum IpcChannel {
StoreSync_BroadcastSync = 'store-sync:broadcast-sync',
// Provider
Provider_AddKey = 'provider:add-key'
Provider_AddKey = 'provider:add-key',
//Selection Assistant
Selection_TextSelected = 'selection:text-selected',
Selection_ToolbarHide = 'selection:toolbar-hide',
Selection_ToolbarVisibilityChange = 'selection:toolbar-visibility-change',
Selection_ToolbarDetermineSize = 'selection:toolbar-determine-size',
Selection_WriteToClipboard = 'selection:write-to-clipboard',
Selection_SetEnabled = 'selection:set-enabled',
Selection_SetTriggerMode = 'selection:set-trigger-mode',
Selection_SetFilterMode = 'selection:set-filter-mode',
Selection_SetFilterList = 'selection:set-filter-list',
Selection_SetFollowToolbar = 'selection:set-follow-toolbar',
Selection_SetRemeberWinSize = 'selection:set-remeber-win-size',
Selection_ActionWindowClose = 'selection:action-window-close',
Selection_ActionWindowMinimize = 'selection:action-window-minimize',
Selection_ActionWindowPin = 'selection:action-window-pin',
Selection_ProcessAction = 'selection:process-action',
Selection_UpdateActionData = 'selection:update-action-data'
}

View File

@@ -4,135 +4,368 @@ export const audioExts = ['.mp3', '.wav', '.ogg', '.flac', '.aac']
export const documentExts = ['.pdf', '.docx', '.pptx', '.xlsx', '.odt', '.odp', '.ods']
export const thirdPartyApplicationExts = ['.draftsExport']
export const bookExts = ['.epub']
export const textExts = [
'.txt', // 普通文本文件
'.md', // Markdown 文件
'.mdx', // Markdown 文件
'.html', // HTML 文件
'.htm', // HTML 文件的另一种扩展名
'.xml', // XML 文件
'.json', // JSON 文件
'.yaml', // YAML 文件
'.yml', // YAML 文件的另一种扩展名
'.csv', // 逗号分隔值文件
'.tsv', // 制表符分隔值文件
'.ini', // 配置文件
'.log', // 日志文件
'.rtf', // 富文本格式文件
'.org', // org-mode 文件
'.wiki', // VimWiki 文件
'.tex', // LaTeX 文件
'.bib', // BibTeX 文件
'.srt', // 字幕文件
'.xhtml', // XHTML 文件
'.nfo', // 信息文件(主要用于场景发布)
'.conf', // 配置文件
'.config', // 配置文件
'.env', // 环境变量文件
'.rst', // reStructuredText 文件
'.php', // PHP 脚本文件,包含嵌入的 HTML
'.js', // JavaScript 文件(部分是文本,部分可能包含代码)
'.ts', // TypeScript 文件
'.jsp', // JavaServer Pages 文件
'.aspx', // ASP.NET 文件
'.bat', // Windows 批处理文件
'.sh', // Unix/Linux Shell 脚本文件
'.py', // Python 脚本文件
'.ipynb', // Jupyter 笔记本格式
'.rb', // Ruby 脚本文件
'.pl', // Perl 脚本文件
'.sql', // SQL 脚本文件
'.css', // Cascading Style Sheets 文件
'.less', // Less CSS 预处理器文件
'.scss', // Sass CSS 预处理器文件
'.sass', // Sass 文件
'.styl', // Stylus CSS 预处理器文件
'.coffee', // CoffeeScript 文件
'.ino', // Arduino 代码文件
'.asm', // Assembly 语言文件
'.go', // Go 语言文件
'.scala', // Scala 语言文件
'.swift', // Swift 语言文件
'.kt', // Kotlin 语言文件
'.rs', // Rust 语言文件
'.lua', // Lua 语言文件
'.groovy', // Groovy 语言文件
'.dart', // Dart 语言文件
'.hs', // Haskell 语言文件
'.clj', // Clojure 语言文件
'.cljs', // ClojureScript 语言文件
'.elm', // Elm 语言文件
'.erl', // Erlang 语言文件
'.ex', // Elixir 语言文件
'.exs', // Elixir 脚本文件
'.pug', // Pug (formerly Jade) 模板文件
'.haml', // Haml 模板文件
'.slim', // Slim 模板文件
'.tpl', // 模板文件(通用)
'.ejs', // Embedded JavaScript 模板文件
'.hbs', // Handlebars 模板文件
'.mustache', // Mustache 模板文件
'.jade', // Jade 模板文件 (已重命名为 Pug)
'.twig', // Twig 模板文件
'.blade', // Blade 模板文件 (Laravel)
'.vue', // Vue.js 单文件组件
'.jsx', // React JSX 文件
'.tsx', // React TSX 文件
'.graphql', // GraphQL 查询语言文件
'.gql', // GraphQL 查询语言文件
'.proto', // Protocol Buffers 文件
'.thrift', // Thrift 文件
'.toml', // TOML 配置文件
'.edn', // Clojure 数据表示文件
'.cake', // CakePHP 配置文件
'.ctp', // CakePHP 视图文件
'.cfm', // ColdFusion 标记语言文件
'.cfc', // ColdFusion 组件文件
'.m', // Objective-C 或 MATLAB 源文件
'.mm', // Objective-C++ 源文件
'.gradle', // Gradle 构建文件
'.groovy', // Gradle 构建文件
'.kts', // Kotlin Script 文件
'.java', // Java 代码文件
'.cs', // C# 代码文件
'.cpp', // C++ 代码文件
'.c', // C++ 代码文件
'.h', // C++ 头文件
'.hpp', // C++ 头文件
'.cc', // C++ 源文件
'.cxx', // C++ 源文件
'.cppm', // C++20 模块接口文件
'.ipp', // 模板实现文件
'.ixx', // C++20 模块实现文件
'.f90', // Fortran 90 源文件
'.f', // Fortran 固定格式源代码文件
'.f03', // Fortran 2003+ 源代码文件
'.ahk', // AutoHotKey 语言文件
'.tcl', // Tcl 脚本
'.do', // Questa 或 Modelsim Tcl 脚本
'.v', // Verilog 源文件
'.sv', // SystemVerilog 源文件
'.svh', // SystemVerilog 头文件
'.vhd', // VHDL 源文件
'.vhdl', // VHDL 源文件
'.lef', // Library Exchange Format
'.def', // Design Exchange Format
'.edif', // Electronic Design Interchange Format
'.sdf', // Standard Delay Format
'.sdc', // Synopsys Design Constraints
'.xdc', // Xilinx Design Constraints
'.rpt', // 报告文件
'.lisp', // Lisp 脚本
'.il', // Cadence SKILL 脚本
'.ils', // Cadence SKILL++ 脚本
'.sp', // SPICE netlist 文件
'.spi', // SPICE netlist 文件
'.cir', // SPICE netlist 文件
'.net', // SPICE netlist 文件
'.scs', // Spectre netlist 文件
'.asc', // LTspice netlist schematic 文件
'.tf' // Technology File
]
const textExtsByCategory = new Map([
[
'language',
[
'.js',
'.mjs',
'.cjs',
'.ts',
'.jsx',
'.tsx', // JavaScript/TypeScript
'.py', // Python
'.java', // Java
'.cs', // C#
'.cpp',
'.c',
'.h',
'.hpp',
'.cc',
'.cxx',
'.cppm',
'.ipp',
'.ixx', // C/C++
'.php', // PHP
'.rb', // Ruby
'.pl', // Perl
'.go', // Go
'.rs', // Rust
'.swift', // Swift
'.kt',
'.kts', // Kotlin
'.scala', // Scala
'.lua', // Lua
'.groovy', // Groovy
'.dart', // Dart
'.hs', // Haskell
'.clj',
'.cljs', // Clojure
'.elm', // Elm
'.erl', // Erlang
'.ex',
'.exs', // Elixir
'.ml',
'.mli', // OCaml
'.fs', // F#
'.r',
'.R', // R
'.sol', // Solidity
'.awk', // AWK
'.cob', // COBOL
'.asm',
'.s', // Assembly
'.lisp',
'.lsp', // Lisp
'.coffee', // CoffeeScript
'.ino', // Arduino
'.jl', // Julia
'.nim', // Nim
'.zig', // Zig
'.d', // D语言
'.pas', // Pascal
'.vb', // Visual Basic
'.rkt', // Racket
'.scm', // Scheme
'.hx', // Haxe
'.as', // ActionScript
'.pde', // Processing
'.f90',
'.f',
'.f03',
'.for',
'.f95', // Fortran
'.adb',
'.ads', // Ada
'.pro', // Prolog
'.m',
'.mm', // Objective-C/MATLAB
'.rpy', // Ren'Py
'.ets', // OpenHarmony,
'.uniswap', // DeFi
'.vy', // Vyper
'.shader',
'.glsl',
'.frag',
'.vert',
'.gd' // Godot
]
],
[
'script',
[
'.sh', // Shell
'.bat',
'.cmd', // Windows批处理
'.ps1', // PowerShell
'.tcl',
'.do', // Tcl
'.ahk', // AutoHotkey
'.zsh', // Zsh
'.fish', // Fish shell
'.csh', // C shell
'.vbs', // VBScript
'.applescript', // AppleScript
'.au3', // AutoIt
'.bash',
'.nu'
]
],
[
'style',
[
'.css', // CSS
'.less', // Less
'.scss',
'.sass', // Sass
'.styl', // Stylus
'.pcss', // PostCSS
'.postcss' // PostCSS
]
],
[
'template',
[
'.vue', // Vue.js
'.pug',
'.jade', // Pug/Jade
'.haml', // Haml
'.slim', // Slim
'.tpl', // 通用模板
'.ejs', // EJS
'.hbs', // Handlebars
'.mustache', // Mustache
'.twig', // Twig
'.blade', // Blade (Laravel)
'.liquid', // Liquid
'.jinja',
'.jinja2',
'.j2', // Jinja
'.erb', // ERB
'.vm', // Velocity
'.ftl', // FreeMarker
'.svelte', // Svelte
'.astro' // Astro
]
],
[
'config',
[
'.ini', // INI配置
'.conf',
'.config', // 通用配置
'.env', // 环境变量
'.toml', // TOML
'.cfg', // 通用配置
'.properties', // Java属性
'.desktop', // Linux桌面文件
'.service', // systemd服务
'.rc',
'.bashrc',
'.zshrc', // Shell配置
'.fishrc', // Fish shell配置
'.vimrc', // Vim配置
'.htaccess', // Apache配置
'.robots', // robots.txt
'.editorconfig', // EditorConfig
'.eslintrc', // ESLint
'.prettierrc', // Prettier
'.babelrc', // Babel
'.npmrc', // npm
'.dockerignore', // Docker ignore
'.npmignore',
'.yarnrc',
'.prettierignore',
'.eslintignore',
'.browserslistrc',
'.json5',
'.tfvars'
]
],
[
'document',
[
'.txt',
'.text', // 纯文本
'.md',
'.mdx', // Markdown
'.html',
'.htm',
'.xhtml', // HTML
'.xml', // XML
'.org', // Org-mode
'.wiki', // Wiki
'.tex',
'.bib', // LaTeX
'.rst', // reStructuredText
'.rtf', // 富文本
'.nfo', // 信息文件
'.adoc',
'.asciidoc', // AsciiDoc
'.pod', // Perl文档
'.1',
'.2',
'.3',
'.4',
'.5',
'.6',
'.7',
'.8',
'.9', // man页面
'.man', // man页面
'.texi',
'.texinfo', // Texinfo
'.readme',
'.me', // README
'.changelog', // 变更日志
'.license', // 许可证
'.authors', // 作者文件
'.po',
'.pot'
]
],
[
'data',
[
'.json', // JSON
'.jsonc', // JSON with comments
'.yaml',
'.yml', // YAML
'.csv',
'.tsv', // 分隔值文件
'.edn', // Clojure数据
'.jsonl',
'.ndjson', // 换行分隔JSON
'.geojson', // GeoJSON
'.gpx', // GPS Exchange
'.kml', // Keyhole Markup
'.rss',
'.atom', // Feed格式
'.vcf', // vCard
'.ics', // iCalendar
'.ldif', // LDAP数据交换
'.pbtxt',
'.map'
]
],
[
'build',
[
'.gradle', // Gradle
'.make',
'.mk', // Make
'.cmake', // CMake
'.sbt', // SBT
'.rake', // Rake
'.spec', // RPM spec
'.pom',
'.build', // Meson
'.bazel' // Bazel
]
],
[
'database',
[
'.sql', // SQL
'.ddl',
'.dml', // DDL/DML
'.plsql', // PL/SQL
'.psql', // PostgreSQL
'.cypher', // Cypher
'.sparql' // SPARQL
]
],
[
'web',
[
'.graphql',
'.gql', // GraphQL
'.proto', // Protocol Buffers
'.thrift', // Thrift
'.wsdl', // WSDL
'.raml', // RAML
'.swagger',
'.openapi' // API文档
]
],
[
'version',
[
'.gitignore', // Git ignore
'.gitattributes', // Git attributes
'.gitconfig', // Git config
'.hgignore', // Mercurial ignore
'.bzrignore', // Bazaar ignore
'.svnignore', // SVN ignore
'.githistory' // Git history
]
],
[
'subtitle',
[
'.srt',
'.sub',
'.ass' // 字幕格式
]
],
[
'log',
[
'.log',
'.rpt' // 日志和报告 (移除了.out因为通常是二进制可执行文件)
]
],
[
'eda',
[
'.v',
'.sv',
'.svh', // Verilog/SystemVerilog
'.vhd',
'.vhdl', // VHDL
'.lef',
'.def', // LEF/DEF
'.edif', // EDIF
'.sdf', // SDF
'.sdc',
'.xdc', // 约束文件
'.sp',
'.spi',
'.cir',
'.net', // SPICE
'.scs', // Spectre
'.asc', // LTspice
'.tf', // Technology File
'.il',
'.ils' // SKILL
]
],
[
'game',
[
'.mtl', // Material Template Library
'.x3d', // X3D文件
'.gltf', // glTF JSON
'.prefab', // Unity预制体 (YAML格式)
'.meta' // Unity元数据文件 (YAML格式)
]
],
[
'other',
[
'.mcfunction', // Minecraft函数
'.jsp', // JSP
'.aspx', // ASP.NET
'.ipynb', // Jupyter Notebook
'.cake',
'.ctp', // CakePHP
'.cfm',
'.cfc' // ColdFusion
]
]
])
export const textExts = Array.from(textExtsByCategory.values()).flat()
export const ZOOM_LEVELS = [0.25, 0.33, 0.5, 0.67, 0.75, 0.8, 0.9, 1, 1.1, 1.25, 1.5, 1.75, 2, 2.5, 3, 4, 5]
@@ -170,3 +403,8 @@ export const KB = 1024
export const MB = 1024 * KB
export const GB = 1024 * MB
export const defaultLanguage = 'en-US'
export enum FeedUrl {
PRODUCTION = 'https://releases.cherry-ai.com',
EARLY_ACCESS = 'https://github.com/CherryHQ/cherry-studio/releases/latest/download'
}

42
playwright.config.ts Normal file
View File

@@ -0,0 +1,42 @@
import { defineConfig, devices } from '@playwright/test'
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
// Look for test files, relative to this configuration file.
testDir: './tests/e2e',
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
// baseURL: 'http://localhost:3000',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry'
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] }
}
]
/* Run your local dev server before starting the tests */
// webServer: {
// command: 'npm run start',
// url: 'http://localhost:3000',
// reuseExistingServer: !process.env.CI,
// },
})

View File

@@ -11,13 +11,13 @@ if (isDev) {
export const DATA_PATH = getDataPath()
export const titleBarOverlayDark = {
height: 40,
height: 42,
color: 'rgba(255,255,255,0)',
symbolColor: '#fff'
}
export const titleBarOverlayLight = {
height: 40,
height: 42,
color: 'rgba(255,255,255,0)',
symbolColor: '#000'
}

View File

@@ -0,0 +1,58 @@
interface IFilterList {
WINDOWS: string[]
MAC?: string[]
}
interface IFinetunedList {
EXCLUDE_CLIPBOARD_CURSOR_DETECT: IFilterList
INCLUDE_CLIPBOARD_DELAY_READ: IFilterList
}
/*************************************************************************
* 注意:请不要修改此配置,除非你非常清楚其含义、影响和行为的目的
* Note: Do not modify this configuration unless you fully understand its meaning, implications, and intended behavior.
* -----------------------------------------------------------------------
* A predefined application filter list to include commonly used software
* that does not require text selection but may conflict with it, and disable them in advance.
* Only available in the selected mode.
*
* Specification: must be all lowercase, need to accurately find the actual running program name
*************************************************************************/
export const SELECTION_PREDEFINED_BLACKLIST: IFilterList = {
WINDOWS: [
'explorer.exe',
// Screenshot
'snipaste.exe',
'pixpin.exe',
'sharex.exe',
// Office
'excel.exe',
'powerpnt.exe',
// Image Editor
'photoshop.exe',
'illustrator.exe',
// Video Editor
'adobe premiere pro.exe',
'afterfx.exe',
// Audio Editor
'adobe audition.exe',
// 3D Editor
'blender.exe',
'3dsmax.exe',
'maya.exe',
// CAD
'acad.exe',
'sldworks.exe',
// Remote Desktop
'mstsc.exe'
]
}
export const SELECTION_FINETUNED_LIST: IFinetunedList = {
EXCLUDE_CLIPBOARD_CURSOR_DETECT: {
WINDOWS: ['acrobat.exe', 'wps.exe', 'cajviewer.exe']
},
INCLUDE_CLIPBOARD_DELAY_READ: {
WINDOWS: ['acrobat.exe', 'wps.exe', 'cajviewer.exe', 'foxitphantom.exe']
}
}

View File

@@ -6,7 +6,7 @@ import { app } from 'electron'
import installExtension, { REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS } from 'electron-devtools-installer'
import Logger from 'electron-log'
import { isDev } from './constant'
import { isDev, isWin } from './constant'
import { registerIpc } from './ipc'
import { configManager } from './services/ConfigManager'
import mcpService from './services/MCPService'
@@ -16,6 +16,7 @@ import {
registerProtocolClient,
setupAppImageDeepLink
} from './services/ProtocolClient'
import selectionService, { initSelectionService } from './services/SelectionService'
import { registerShortcuts } from './services/ShortcutService'
import { TrayService } from './services/TrayService'
import { windowService } from './services/WindowService'
@@ -23,6 +24,16 @@ import { setUserDataDir } from './utils/file'
Logger.initialize()
/**
* Disable chromium's window animations
* main purpose for this is to avoid the transparent window flashing when it is shown
* (especially on Windows for SelectionAssistant Toolbar)
* Know Issue: https://github.com/electron/electron/issues/12130#issuecomment-627198990
*/
if (isWin) {
app.commandLine.appendSwitch('wm-window-animations-disabled')
}
// in production mode, handle uncaught exception and unhandled rejection globally
if (!isDev) {
// handle uncaught exception
@@ -84,6 +95,9 @@ if (!app.requestSingleInstanceLock()) {
.then((name) => console.log(`Added Extension: ${name}`))
.catch((err) => console.log('An error occurred: ', err))
}
//start selection assistant service
initSelectionService()
})
registerProtocolClient(app)
@@ -110,6 +124,11 @@ if (!app.requestSingleInstanceLock()) {
app.on('before-quit', () => {
app.isQuitting = true
// quit selection service
if (selectionService) {
selectionService.quit()
}
})
app.on('will-quit', async () => {

View File

@@ -6,11 +6,10 @@ import { getBinaryPath, isBinaryExists, runInstallScript } from '@main/utils/pro
import { handleZoomFactor } from '@main/utils/zoom'
import { IpcChannel } from '@shared/IpcChannel'
import { Shortcut, ThemeMode } from '@types'
import { BrowserWindow, ipcMain, nativeTheme, session, shell } from 'electron'
import { BrowserWindow, ipcMain, 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'
import BackupManager from './services/BackupManager'
import { configManager } from './services/ConfigManager'
@@ -18,7 +17,6 @@ import CopilotService from './services/CopilotService'
import { ExportService } from './services/ExportService'
import FileService from './services/FileService'
import FileStorage from './services/FileStorage'
import { GeminiService } from './services/GeminiService'
import KnowledgeService from './services/KnowledgeService'
import mcpService from './services/MCPService'
import NotificationService from './services/NotificationService'
@@ -26,15 +24,17 @@ import * as NutstoreService from './services/NutstoreService'
import ObsidianVaultService from './services/ObsidianVaultService'
import { ProxyConfig, proxyManager } from './services/ProxyManager'
import { searchService } from './services/SearchService'
import { SelectionService } from './services/SelectionService'
import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService'
import storeSyncService from './services/StoreSyncService'
import { TrayService } from './services/TrayService'
import { themeService } from './services/ThemeService'
import { setOpenLinkExternal } from './services/WebviewService'
import { windowService } from './services/WindowService'
import { calculateDirectorySize, getResourcePath } from './utils'
import { decrypt, encrypt } from './utils/aes'
import { getCacheDir, getConfigDir, getFilesDir } from './utils/file'
import { compress, decompress } from './utils/zip'
import { FeedUrl } from '@shared/config/constant'
const fileManager = new FileStorage()
const backupManager = new BackupManager()
@@ -113,10 +113,12 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
configManager.setAutoUpdate(isActive)
})
ipcMain.handle(IpcChannel.App_RestartTray, () => TrayService.getInstance().restartTray())
ipcMain.handle(IpcChannel.App_SetFeedUrl, (_, feedUrl: FeedUrl) => {
appUpdater.setFeedUrl(feedUrl)
})
ipcMain.handle(IpcChannel.Config_Set, (_, key: string, value: any) => {
configManager.set(key, value)
ipcMain.handle(IpcChannel.Config_Set, (_, key: string, value: any, isNotify: boolean = false) => {
configManager.set(key, value, isNotify)
})
ipcMain.handle(IpcChannel.Config_Get, (_, key: string) => {
@@ -125,34 +127,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
// theme
ipcMain.handle(IpcChannel.App_SetTheme, (_, theme: ThemeMode) => {
const updateTitleBarOverlay = () => {
if (!mainWindow?.setTitleBarOverlay) return
const isDark = nativeTheme.shouldUseDarkColors
mainWindow.setTitleBarOverlay(isDark ? titleBarOverlayDark : titleBarOverlayLight)
}
const broadcastThemeChange = () => {
const isDark = nativeTheme.shouldUseDarkColors
const effectiveTheme = isDark ? ThemeMode.dark : ThemeMode.light
BrowserWindow.getAllWindows().forEach((win) => win.webContents.send(IpcChannel.ThemeChange, effectiveTheme))
}
const notifyThemeChange = () => {
updateTitleBarOverlay()
broadcastThemeChange()
}
if (theme === ThemeMode.auto) {
nativeTheme.themeSource = 'system'
nativeTheme.on('updated', notifyThemeChange)
} else {
nativeTheme.themeSource = theme
nativeTheme.off('updated', notifyThemeChange)
}
updateTitleBarOverlay()
configManager.setTheme(theme)
notifyThemeChange()
themeService.setTheme(theme)
})
ipcMain.handle(IpcChannel.App_HandleZoomFactor, (_, delta: number, reset: boolean = false) => {
@@ -249,6 +224,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.File_WriteWithId, fileManager.writeFileWithId)
ipcMain.handle(IpcChannel.File_SaveImage, fileManager.saveImage)
ipcMain.handle(IpcChannel.File_Base64Image, fileManager.base64Image)
ipcMain.handle(IpcChannel.File_SaveBase64Image, fileManager.saveBase64Image)
ipcMain.handle(IpcChannel.File_Base64File, fileManager.base64File)
ipcMain.handle(IpcChannel.File_Download, fileManager.downloadFile)
ipcMain.handle(IpcChannel.File_Copy, fileManager.copyFile)
@@ -297,13 +273,6 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
}
})
// gemini
ipcMain.handle(IpcChannel.Gemini_UploadFile, GeminiService.uploadFile)
ipcMain.handle(IpcChannel.Gemini_Base64File, GeminiService.base64File)
ipcMain.handle(IpcChannel.Gemini_RetrieveFile, GeminiService.retrieveFile)
ipcMain.handle(IpcChannel.Gemini_ListFiles, GeminiService.listFiles)
ipcMain.handle(IpcChannel.Gemini_DeleteFile, GeminiService.deleteFile)
// mini window
ipcMain.handle(IpcChannel.MiniWindow_Show, () => windowService.showMiniWindow())
ipcMain.handle(IpcChannel.MiniWindow_Hide, () => windowService.hideMiniWindow())
@@ -379,4 +348,9 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
// store sync
storeSyncService.registerIpcHandler()
// selection assistant
SelectionService.registerIpcHandler()
ipcMain.handle(IpcChannel.App_QuoteToMain, (_, text: string) => windowService.quoteToMainWindow(text))
}

View File

@@ -1,5 +1,7 @@
import { isWin } from '@main/constant'
import { locales } from '@main/utils/locales'
import { IpcChannel } from '@shared/IpcChannel'
import { FeedUrl } from '@shared/config/constant'
import { UpdateInfo } from 'builder-util-runtime'
import { app, BrowserWindow, dialog } from 'electron'
import logger from 'electron-log'
@@ -19,6 +21,7 @@ export default class AppUpdater {
autoUpdater.forceDevUpdateConfig = !app.isPackaged
autoUpdater.autoDownload = configManager.getAutoUpdate()
autoUpdater.autoInstallOnAppQuit = configManager.getAutoUpdate()
autoUpdater.setFeedURL(configManager.getFeedUrl())
// 检测下载错误
autoUpdater.on('error', (error) => {
@@ -61,6 +64,11 @@ export default class AppUpdater {
autoUpdater.autoInstallOnAppQuit = isActive
}
public setFeedUrl(feedUrl: FeedUrl) {
autoUpdater.setFeedURL(feedUrl)
configManager.setFeedUrl(feedUrl)
}
public async checkForUpdates() {
if (isWin && 'PORTABLE_EXECUTABLE_DIR' in process.env) {
return {
@@ -94,15 +102,22 @@ export default class AppUpdater {
if (!this.releaseInfo) {
return
}
const locale = locales[configManager.getLanguage()]
const { update: updateLocale } = locale.translation
let detail = this.formatReleaseNotes(this.releaseInfo.releaseNotes)
if (detail === '') {
detail = updateLocale.noReleaseNotes
}
dialog
.showMessageBox({
type: 'info',
title: '安装更新',
title: updateLocale.title,
icon,
message: `新版本 ${this.releaseInfo.version} 已准备就绪`,
detail: this.formatReleaseNotes(this.releaseInfo.releaseNotes),
buttons: ['稍后安装', '立即安装'],
message: updateLocale.message.replace('{{version}}', this.releaseInfo.version),
detail,
buttons: [updateLocale.later, updateLocale.install],
defaultId: 1,
cancelId: 0
})
@@ -118,7 +133,7 @@ export default class AppUpdater {
private formatReleaseNotes(releaseNotes: string | ReleaseNoteInfo[] | null | undefined): string {
if (!releaseNotes) {
return '暂无更新说明'
return ''
}
if (typeof releaseNotes === 'string') {

View File

@@ -7,7 +7,7 @@ import Logger from 'electron-log'
import * as fs from 'fs-extra'
import StreamZip from 'node-stream-zip'
import * as path from 'path'
import { createClient, CreateDirectoryOptions, FileStat } from 'webdav'
import { CreateDirectoryOptions, FileStat } from 'webdav'
import WebDav from './WebDav'
import { windowService } from './WindowService'
@@ -295,10 +295,12 @@ 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, undefined, webdavConfig.skipBackupFile)
const contentLength = (await fs.stat(backupedFilePath)).size
const webdavClient = new WebDav(webdavConfig)
try {
const result = await webdavClient.putFileContents(filename, fs.createReadStream(backupedFilePath), {
overwrite: true
overwrite: true,
contentLength
})
// 上传成功后删除本地备份文件
await fs.remove(backupedFilePath)
@@ -340,12 +342,8 @@ class BackupManager {
listWebdavFiles = async (_: Electron.IpcMainInvokeEvent, config: WebDavConfig) => {
try {
const client = createClient(config.webdavHost, {
username: config.webdavUser,
password: config.webdavPass
})
const response = await client.getDirectoryContents(config.webdavPath)
const client = new WebDav(config)
const response = await client.getDirectoryContents()
const files = Array.isArray(response) ? response : response.data
return files

View File

@@ -1,11 +1,11 @@
import { defaultLanguage, ZOOM_SHORTCUTS } from '@shared/config/constant'
import { defaultLanguage, FeedUrl, ZOOM_SHORTCUTS } from '@shared/config/constant'
import { LanguageVarious, Shortcut, ThemeMode } from '@types'
import { app } from 'electron'
import Store from 'electron-store'
import { locales } from '../utils/locales'
enum ConfigKeys {
export enum ConfigKeys {
Language = 'language',
Theme = 'theme',
LaunchToTray = 'launchToTray',
@@ -16,7 +16,14 @@ enum ConfigKeys {
ClickTrayToShowQuickAssistant = 'clickTrayToShowQuickAssistant',
EnableQuickAssistant = 'enableQuickAssistant',
AutoUpdate = 'autoUpdate',
EnableDataCollection = 'enableDataCollection'
FeedUrl = 'feedUrl',
EnableDataCollection = 'enableDataCollection',
SelectionAssistantEnabled = 'selectionAssistantEnabled',
SelectionAssistantTriggerMode = 'selectionAssistantTriggerMode',
SelectionAssistantFollowToolbar = 'selectionAssistantFollowToolbar',
SelectionAssistantRemeberWinSize = 'selectionAssistantRemeberWinSize',
SelectionAssistantFilterMode = 'selectionAssistantFilterMode',
SelectionAssistantFilterList = 'selectionAssistantFilterList'
}
export class ConfigManager {
@@ -32,12 +39,12 @@ export class ConfigManager {
return this.get(ConfigKeys.Language, locale) as LanguageVarious
}
setLanguage(theme: LanguageVarious) {
this.set(ConfigKeys.Language, theme)
setLanguage(lang: LanguageVarious) {
this.setAndNotify(ConfigKeys.Language, lang)
}
getTheme(): ThemeMode {
return this.get(ConfigKeys.Theme, ThemeMode.auto)
return this.get(ConfigKeys.Theme, ThemeMode.system)
}
setTheme(theme: ThemeMode) {
@@ -57,8 +64,7 @@ export class ConfigManager {
}
setTray(value: boolean) {
this.set(ConfigKeys.Tray, value)
this.notifySubscribers(ConfigKeys.Tray, value)
this.setAndNotify(ConfigKeys.Tray, value)
}
getTrayOnClose(): boolean {
@@ -74,8 +80,7 @@ export class ConfigManager {
}
setZoomFactor(factor: number) {
this.set(ConfigKeys.ZoomFactor, factor)
this.notifySubscribers(ConfigKeys.ZoomFactor, factor)
this.setAndNotify(ConfigKeys.ZoomFactor, factor)
}
subscribe<T>(key: string, callback: (newValue: T) => void) {
@@ -107,11 +112,10 @@ export class ConfigManager {
}
setShortcuts(shortcuts: Shortcut[]) {
this.set(
this.setAndNotify(
ConfigKeys.Shortcuts,
shortcuts.filter((shortcut) => shortcut.system)
)
this.notifySubscribers(ConfigKeys.Shortcuts, shortcuts)
}
getClickTrayToShowQuickAssistant(): boolean {
@@ -127,7 +131,7 @@ export class ConfigManager {
}
setEnableQuickAssistant(value: boolean) {
this.set(ConfigKeys.EnableQuickAssistant, value)
this.setAndNotify(ConfigKeys.EnableQuickAssistant, value)
}
getAutoUpdate(): boolean {
@@ -138,6 +142,14 @@ export class ConfigManager {
this.set(ConfigKeys.AutoUpdate, value)
}
getFeedUrl(): string {
return this.get<string>(ConfigKeys.FeedUrl, FeedUrl.PRODUCTION)
}
setFeedUrl(value: FeedUrl) {
this.set(ConfigKeys.FeedUrl, value)
}
getEnableDataCollection(): boolean {
return this.get<boolean>(ConfigKeys.EnableDataCollection, true)
}
@@ -146,8 +158,64 @@ export class ConfigManager {
this.set(ConfigKeys.EnableDataCollection, value)
}
set(key: string, value: unknown) {
// Selection Assistant: is enabled the selection assistant
getSelectionAssistantEnabled(): boolean {
return this.get<boolean>(ConfigKeys.SelectionAssistantEnabled, false)
}
setSelectionAssistantEnabled(value: boolean) {
this.setAndNotify(ConfigKeys.SelectionAssistantEnabled, value)
}
// Selection Assistant: trigger mode (selected, ctrlkey)
getSelectionAssistantTriggerMode(): string {
return this.get<string>(ConfigKeys.SelectionAssistantTriggerMode, 'selected')
}
setSelectionAssistantTriggerMode(value: string) {
this.setAndNotify(ConfigKeys.SelectionAssistantTriggerMode, value)
}
// Selection Assistant: if action window position follow toolbar
getSelectionAssistantFollowToolbar(): boolean {
return this.get<boolean>(ConfigKeys.SelectionAssistantFollowToolbar, true)
}
setSelectionAssistantFollowToolbar(value: boolean) {
this.setAndNotify(ConfigKeys.SelectionAssistantFollowToolbar, value)
}
getSelectionAssistantRemeberWinSize(): boolean {
return this.get<boolean>(ConfigKeys.SelectionAssistantRemeberWinSize, false)
}
setSelectionAssistantRemeberWinSize(value: boolean) {
this.setAndNotify(ConfigKeys.SelectionAssistantRemeberWinSize, value)
}
getSelectionAssistantFilterMode(): string {
return this.get<string>(ConfigKeys.SelectionAssistantFilterMode, 'default')
}
setSelectionAssistantFilterMode(value: string) {
this.setAndNotify(ConfigKeys.SelectionAssistantFilterMode, value)
}
getSelectionAssistantFilterList(): string[] {
return this.get<string[]>(ConfigKeys.SelectionAssistantFilterList, [])
}
setSelectionAssistantFilterList(value: string[]) {
this.setAndNotify(ConfigKeys.SelectionAssistantFilterList, value)
}
setAndNotify(key: string, value: unknown) {
this.set(key, value, true)
}
set(key: string, value: unknown, isNotify: boolean = false) {
this.store.set(key, value)
isNotify && this.notifySubscribers(key, value)
}
get<T>(key: string, defaultValue?: T) {

View File

@@ -1,7 +1,9 @@
import fs from 'node:fs'
import fs from 'fs/promises'
export default class FileService {
public static async readFile(_: Electron.IpcMainInvokeEvent, path: string) {
return fs.readFileSync(path, 'utf8')
public static async readFile(_: Electron.IpcMainInvokeEvent, pathOrUrl: string, encoding?: BufferEncoding) {
const path = pathOrUrl.startsWith('file://') ? new URL(pathOrUrl) : pathOrUrl
if (encoding) return fs.readFile(path, { encoding })
return fs.readFile(path)
}
}

View File

@@ -268,6 +268,51 @@ class FileStorage {
}
}
public saveBase64Image = async (_: Electron.IpcMainInvokeEvent, base64Data: string): Promise<FileType> => {
try {
if (!base64Data) {
throw new Error('Base64 data is required')
}
// 移除 base64 头部信息(如果存在)
const base64String = base64Data.replace(/^data:.*;base64,/, '')
const buffer = Buffer.from(base64String, 'base64')
const uuid = uuidv4()
const ext = '.png'
const destPath = path.join(this.storageDir, uuid + ext)
logger.info('[FileStorage] Saving base64 image:', {
storageDir: this.storageDir,
destPath,
bufferSize: buffer.length
})
// 确保目录存在
if (!fs.existsSync(this.storageDir)) {
fs.mkdirSync(this.storageDir, { recursive: true })
}
await fs.promises.writeFile(destPath, buffer)
const fileMetadata: FileType = {
id: uuid,
origin_name: uuid + ext,
name: uuid + ext,
path: destPath,
created_at: new Date().toISOString(),
size: buffer.length,
ext: ext.slice(1),
type: getFileType(ext),
count: 1
}
return fileMetadata
} catch (error) {
logger.error('[FileStorage] Failed to save base64 image:', error)
throw error
}
}
public base64File = async (_: Electron.IpcMainInvokeEvent, id: string): Promise<{ data: string; mime: string }> => {
const filePath = path.join(this.storageDir, id)
const buffer = await fs.promises.readFile(filePath)

View File

@@ -1,79 +0,0 @@
import { File, FileState, GoogleGenAI, Pager } from '@google/genai'
import { FileType } from '@types'
import fs from 'fs'
import { CacheService } from './CacheService'
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, 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: {
mimeType: 'application/pdf',
name: file.id,
displayName: file.origin_name
}
})
}
static async base64File(_: Electron.IpcMainInvokeEvent, file: FileType) {
return {
data: Buffer.from(fs.readFileSync(file.path)).toString('base64'),
mimeType: 'application/pdf'
}
}
static async retrieveFile(_: Electron.IpcMainInvokeEvent, file: FileType, apiKey: string): Promise<File | undefined> {
const sdk = new GoogleGenAI({ vertexai: false, apiKey })
const cachedResponse = CacheService.get<any>(GeminiService.FILE_LIST_CACHE_KEY)
if (cachedResponse) {
return GeminiService.processResponse(cachedResponse, file)
}
const response = await sdk.files.list()
CacheService.set(GeminiService.FILE_LIST_CACHE_KEY, response, GeminiService.CACHE_DURATION)
return GeminiService.processResponse(response, file)
}
private static async processResponse(response: Pager<File>, file: FileType) {
for await (const f of response) {
if (f.state === FileState.ACTIVE) {
if (f.displayName === file.origin_name && Number(f.sizeBytes) === file.size) {
return f
}
}
}
return undefined
}
static async listFiles(_: Electron.IpcMainInvokeEvent, apiKey: string): Promise<File[]> {
const sdk = new GoogleGenAI({ vertexai: false, apiKey })
const files: File[] = []
for await (const f of await sdk.files.list()) {
files.push(f)
}
return files
}
static async deleteFile(_: Electron.IpcMainInvokeEvent, fileId: string, apiKey: string) {
const sdk = new GoogleGenAI({ vertexai: false, apiKey })
await sdk.files.delete({ name: fileId })
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,48 @@
import { IpcChannel } from '@shared/IpcChannel'
import { ThemeMode } from '@types'
import { BrowserWindow, nativeTheme } from 'electron'
import { titleBarOverlayDark, titleBarOverlayLight } from '../config'
import { configManager } from './ConfigManager'
class ThemeService {
private theme: ThemeMode = ThemeMode.system
constructor() {
this.theme = configManager.getTheme()
if (this.theme === ThemeMode.dark || this.theme === ThemeMode.light || this.theme === ThemeMode.system) {
nativeTheme.themeSource = this.theme
} else {
// 兼容旧版本
configManager.setTheme(ThemeMode.system)
nativeTheme.themeSource = ThemeMode.system
}
nativeTheme.on('updated', this.themeUpdatadHandler.bind(this))
}
themeUpdatadHandler() {
BrowserWindow.getAllWindows().forEach((win) => {
if (win && !win.isDestroyed() && win.setTitleBarOverlay) {
try {
win.setTitleBarOverlay(nativeTheme.shouldUseDarkColors ? titleBarOverlayDark : titleBarOverlayLight)
} catch (error) {
// don't throw error if setTitleBarOverlay failed
// Because it may be called with some windows have some title bar
}
}
win.webContents.send(IpcChannel.ThemeUpdated, nativeTheme.shouldUseDarkColors ? ThemeMode.dark : ThemeMode.light)
})
}
setTheme(theme: ThemeMode) {
if (theme === this.theme) {
return
}
this.theme = theme
nativeTheme.themeSource = theme
configManager.setTheme(theme)
}
}
export const themeService = new ThemeService()

View File

@@ -5,16 +5,17 @@ import { app, Menu, MenuItemConstructorOptions, nativeImage, nativeTheme, Tray }
import icon from '../../../build/tray_icon.png?asset'
import iconDark from '../../../build/tray_icon_dark.png?asset'
import iconLight from '../../../build/tray_icon_light.png?asset'
import { configManager } from './ConfigManager'
import { ConfigKeys, configManager } from './ConfigManager'
import { windowService } from './WindowService'
export class TrayService {
private static instance: TrayService
private tray: Tray | null = null
private contextMenu: Menu | null = null
constructor() {
this.watchConfigChanges()
this.updateTray()
this.watchTrayChanges()
TrayService.instance = this
}
@@ -43,6 +44,30 @@ export class TrayService {
this.tray = tray
this.updateContextMenu()
if (process.platform === 'linux') {
this.tray.setContextMenu(this.contextMenu)
}
this.tray.setToolTip('Cherry Studio')
this.tray.on('right-click', () => {
if (this.contextMenu) {
this.tray?.popUpContextMenu(this.contextMenu)
}
})
this.tray.on('click', () => {
if (configManager.getEnableQuickAssistant() && configManager.getClickTrayToShowQuickAssistant()) {
windowService.showMiniWindow()
} else {
windowService.showMainWindow()
}
})
}
private updateContextMenu() {
const locale = locales[configManager.getLanguage()]
const { tray: trayLocale } = locale.translation
@@ -64,25 +89,7 @@ export class TrayService {
}
].filter(Boolean) as MenuItemConstructorOptions[]
const contextMenu = Menu.buildFromTemplate(template)
if (process.platform === 'linux') {
this.tray.setContextMenu(contextMenu)
}
this.tray.setToolTip('Cherry Studio')
this.tray.on('right-click', () => {
this.tray?.popUpContextMenu(contextMenu)
})
this.tray.on('click', () => {
if (enableQuickAssistant && configManager.getClickTrayToShowQuickAssistant()) {
windowService.showMiniWindow()
} else {
windowService.showMainWindow()
}
})
this.contextMenu = Menu.buildFromTemplate(template)
}
private updateTray() {
@@ -94,13 +101,6 @@ export class TrayService {
}
}
public restartTray() {
if (configManager.getTray()) {
this.destroyTray()
this.createTray()
}
}
private destroyTray() {
if (this.tray) {
this.tray.destroy()
@@ -108,8 +108,16 @@ export class TrayService {
}
}
private watchTrayChanges() {
configManager.subscribe<boolean>('tray', () => this.updateTray())
private watchConfigChanges() {
configManager.subscribe(ConfigKeys.Tray, () => this.updateTray())
configManager.subscribe(ConfigKeys.Language, () => {
this.updateContextMenu()
})
configManager.subscribe(ConfigKeys.EnableQuickAssistant, () => {
this.updateContextMenu()
})
}
private quit() {

View File

@@ -1,5 +1,7 @@
import { WebDavConfig } from '@types'
import Logger from 'electron-log'
import https from 'https'
import path from 'path'
import Stream from 'stream'
import {
BufferLike,
@@ -14,13 +16,14 @@ export default class WebDav {
private webdavPath: string
constructor(params: WebDavConfig) {
this.webdavPath = params.webdavPath
this.webdavPath = params.webdavPath || '/'
this.instance = createClient(params.webdavHost, {
username: params.webdavUser,
password: params.webdavPass,
maxBodyLength: Infinity,
maxContentLength: Infinity
maxContentLength: Infinity,
httpsAgent: new https.Agent({ rejectUnauthorized: false })
})
this.putFileContents = this.putFileContents.bind(this)
@@ -49,7 +52,7 @@ export default class WebDav {
throw error
}
const remoteFilePath = `${this.webdavPath}/${filename}`
const remoteFilePath = path.posix.join(this.webdavPath, filename)
try {
return await this.instance.putFileContents(remoteFilePath, data, options)
@@ -64,7 +67,7 @@ export default class WebDav {
throw new Error('WebDAV client not initialized')
}
const remoteFilePath = `${this.webdavPath}/${filename}`
const remoteFilePath = path.posix.join(this.webdavPath, filename)
try {
return await this.instance.getFileContents(remoteFilePath, options)
@@ -74,6 +77,19 @@ export default class WebDav {
}
}
public getDirectoryContents = async () => {
if (!this.instance) {
throw new Error('WebDAV client not initialized')
}
try {
return await this.instance.getDirectoryContents(this.webdavPath)
} catch (error) {
Logger.error('[WebDAV] Error getting directory contents on WebDAV:', error)
throw error
}
}
public checkConnection = async () => {
if (!this.instance) {
throw new Error('WebDAV client not initialized')
@@ -105,7 +121,7 @@ export default class WebDav {
throw new Error('WebDAV client not initialized')
}
const remoteFilePath = `${this.webdavPath}/${filename}`
const remoteFilePath = path.posix.join(this.webdavPath, filename)
try {
return await this.instance.deleteFile(remoteFilePath)

View File

@@ -1,8 +1,10 @@
// just import the themeService to ensure the theme is initialized
import './ThemeService'
import { is } from '@electron-toolkit/utils'
import { isDev, isLinux, isMac, isWin } from '@main/constant'
import { getFilesDir } from '@main/utils/file'
import { IpcChannel } from '@shared/IpcChannel'
import { ThemeMode } from '@types'
import { app, BrowserWindow, nativeTheme, shell } from 'electron'
import Logger from 'electron-log'
import windowStateKeeper from 'electron-window-state'
@@ -45,13 +47,6 @@ export class WindowService {
maximize: false
})
const theme = configManager.getTheme()
if (theme === ThemeMode.auto) {
nativeTheme.themeSource = 'system'
} else {
nativeTheme.themeSource = theme
}
this.mainWindow = new BrowserWindow({
x: mainWindowState.x,
y: mainWindowState.y,
@@ -61,14 +56,14 @@ export class WindowService {
minHeight: 600,
show: false,
autoHideMenuBar: true,
transparent: isMac,
transparent: false,
vibrancy: 'sidebar',
visualEffectState: 'active',
titleBarStyle: 'hidden',
titleBarOverlay: nativeTheme.shouldUseDarkColors ? titleBarOverlayDark : titleBarOverlayLight,
backgroundColor: isMac ? undefined : nativeTheme.shouldUseDarkColors ? '#181818' : '#FFFFFF',
darkTheme: nativeTheme.shouldUseDarkColors,
trafficLightPosition: { x: 8, y: 12 },
trafficLightPosition: { x: 12, y: 12 },
...(isLinux ? { icon } : {}),
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
@@ -549,6 +544,25 @@ export class WindowService {
public setPinMiniWindow(isPinned) {
this.isPinnedMiniWindow = isPinned
}
/**
* 引用文本到主窗口
* @param text 原始文本(未格式化)
*/
public quoteToMainWindow(text: string): void {
try {
this.showMainWindow()
const mainWindow = this.getMainWindow()
if (mainWindow && !mainWindow.isDestroyed()) {
setTimeout(() => {
mainWindow.webContents.send(IpcChannel.App_QuoteToMain, text)
}, 100)
}
} catch (error) {
Logger.error('Failed to quote to main window:', error as Error)
}
}
}
export const windowService = WindowService.getInstance()

View File

@@ -1,8 +1,12 @@
import path from 'node:path'
import { getConfigDir } from '@main/utils/file'
import { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth'
import { OAuthClientInformation, OAuthClientInformationFull, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth'
import { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'
import {
OAuthClientInformation,
OAuthClientInformationFull,
OAuthTokens
} from '@modelcontextprotocol/sdk/shared/auth.js'
import Logger from 'electron-log'
import open from 'open'

View File

@@ -65,7 +65,7 @@ export function handleMcpProtocolUrl(url: URL) {
const mainWindow = windowService.getMainWindow()
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.executeJavaScript("window.navigate('/settings/mcp')")
mainWindow.webContents.executeJavaScript("window.navigate('/mcp-servers')")
}
break
}

View File

@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest'
import { decrypt, encrypt } from '../aes'
const key = '12345678901234567890123456789012' // 32字节
const iv = '1234567890abcdef1234567890abcdef' // 32字节hex实际应16字节hex
function getIv16() {
// 取前16字节作为 hex
return iv.slice(0, 32)
}
describe('aes utils', () => {
it('should encrypt and decrypt normal string', () => {
const text = 'hello world'
const { iv: outIv, encryptedData } = encrypt(text, key, getIv16())
expect(typeof encryptedData).toBe('string')
expect(outIv).toBe(getIv16())
const decrypted = decrypt(encryptedData, getIv16(), key)
expect(decrypted).toBe(text)
})
it('should support unicode and special chars', () => {
const text = '你好,世界!🌟🚀'
const { encryptedData } = encrypt(text, key, getIv16())
const decrypted = decrypt(encryptedData, getIv16(), key)
expect(decrypted).toBe(text)
})
it('should handle empty string', () => {
const text = ''
const { encryptedData } = encrypt(text, key, getIv16())
const decrypted = decrypt(encryptedData, getIv16(), key)
expect(decrypted).toBe(text)
})
it('should encrypt and decrypt long string', () => {
const text = 'a'.repeat(100_000)
const { encryptedData } = encrypt(text, key, getIv16())
const decrypted = decrypt(encryptedData, getIv16(), key)
expect(decrypted).toBe(text)
})
it('should throw error for wrong key', () => {
const text = 'test'
const { encryptedData } = encrypt(text, key, getIv16())
expect(() => decrypt(encryptedData, getIv16(), 'wrongkeywrongkeywrongkeywrongkey')).toThrow()
})
it('should throw error for wrong iv', () => {
const text = 'test'
const { encryptedData } = encrypt(text, key, getIv16())
expect(() => decrypt(encryptedData, 'abcdefabcdefabcdefabcdefabcdefab', key)).toThrow()
})
it('should throw error for invalid key/iv length', () => {
expect(() => encrypt('test', 'shortkey', getIv16())).toThrow()
expect(() => encrypt('test', key, 'shortiv')).toThrow()
})
it('should throw error for invalid encrypted data', () => {
expect(() => decrypt('nothexdata', getIv16(), key)).toThrow()
})
it('should throw error for non-string input', () => {
// @ts-expect-error purposely pass wrong type to test error branch
expect(() => encrypt(null, key, getIv16())).toThrow()
// @ts-expect-error purposely pass wrong type to test error branch
expect(() => decrypt(null, getIv16(), key)).toThrow()
})
})

View File

@@ -0,0 +1,243 @@
import * as fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { FileTypes } from '@types'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getAllFiles, getAppConfigDir, getConfigDir, getFilesDir, getFileType, getTempDir } from '../file'
// Mock dependencies
vi.mock('node:fs')
vi.mock('node:os')
vi.mock('node:path')
vi.mock('uuid', () => ({
v4: () => 'mock-uuid'
}))
vi.mock('electron', () => ({
app: {
getPath: vi.fn((key) => {
if (key === 'temp') return '/mock/temp'
if (key === 'userData') return '/mock/userData'
return '/mock/unknown'
})
}
}))
describe('file', () => {
beforeEach(() => {
vi.clearAllMocks()
// Mock path.extname
vi.mocked(path.extname).mockImplementation((file) => {
const parts = file.split('.')
return parts.length > 1 ? `.${parts[parts.length - 1]}` : ''
})
// Mock path.basename
vi.mocked(path.basename).mockImplementation((file) => {
const parts = file.split('/')
return parts[parts.length - 1]
})
// Mock path.join
vi.mocked(path.join).mockImplementation((...args) => args.join('/'))
// Mock os.homedir
vi.mocked(os.homedir).mockReturnValue('/mock/home')
})
afterEach(() => {
vi.resetAllMocks()
})
describe('getFileType', () => {
it('should return IMAGE for image extensions', () => {
expect(getFileType('.jpg')).toBe(FileTypes.IMAGE)
expect(getFileType('.jpeg')).toBe(FileTypes.IMAGE)
expect(getFileType('.png')).toBe(FileTypes.IMAGE)
expect(getFileType('.gif')).toBe(FileTypes.IMAGE)
expect(getFileType('.webp')).toBe(FileTypes.IMAGE)
expect(getFileType('.bmp')).toBe(FileTypes.IMAGE)
})
it('should return VIDEO for video extensions', () => {
expect(getFileType('.mp4')).toBe(FileTypes.VIDEO)
expect(getFileType('.avi')).toBe(FileTypes.VIDEO)
expect(getFileType('.mov')).toBe(FileTypes.VIDEO)
expect(getFileType('.mkv')).toBe(FileTypes.VIDEO)
expect(getFileType('.flv')).toBe(FileTypes.VIDEO)
})
it('should return AUDIO for audio extensions', () => {
expect(getFileType('.mp3')).toBe(FileTypes.AUDIO)
expect(getFileType('.wav')).toBe(FileTypes.AUDIO)
expect(getFileType('.ogg')).toBe(FileTypes.AUDIO)
expect(getFileType('.flac')).toBe(FileTypes.AUDIO)
expect(getFileType('.aac')).toBe(FileTypes.AUDIO)
})
it('should return TEXT for text extensions', () => {
expect(getFileType('.txt')).toBe(FileTypes.TEXT)
expect(getFileType('.md')).toBe(FileTypes.TEXT)
expect(getFileType('.html')).toBe(FileTypes.TEXT)
expect(getFileType('.json')).toBe(FileTypes.TEXT)
expect(getFileType('.js')).toBe(FileTypes.TEXT)
expect(getFileType('.ts')).toBe(FileTypes.TEXT)
expect(getFileType('.css')).toBe(FileTypes.TEXT)
expect(getFileType('.java')).toBe(FileTypes.TEXT)
expect(getFileType('.py')).toBe(FileTypes.TEXT)
})
it('should return DOCUMENT for document extensions', () => {
expect(getFileType('.pdf')).toBe(FileTypes.DOCUMENT)
expect(getFileType('.pptx')).toBe(FileTypes.DOCUMENT)
expect(getFileType('.docx')).toBe(FileTypes.DOCUMENT)
expect(getFileType('.xlsx')).toBe(FileTypes.DOCUMENT)
expect(getFileType('.odt')).toBe(FileTypes.DOCUMENT)
})
it('should return OTHER for unknown extensions', () => {
expect(getFileType('.unknown')).toBe(FileTypes.OTHER)
expect(getFileType('')).toBe(FileTypes.OTHER)
expect(getFileType('.')).toBe(FileTypes.OTHER)
expect(getFileType('...')).toBe(FileTypes.OTHER)
expect(getFileType('.123')).toBe(FileTypes.OTHER)
})
it('should handle case-insensitive extensions', () => {
expect(getFileType('.JPG')).toBe(FileTypes.IMAGE)
expect(getFileType('.PDF')).toBe(FileTypes.DOCUMENT)
expect(getFileType('.Mp3')).toBe(FileTypes.AUDIO)
expect(getFileType('.HtMl')).toBe(FileTypes.TEXT)
expect(getFileType('.Xlsx')).toBe(FileTypes.DOCUMENT)
})
it('should handle extensions without leading dot', () => {
expect(getFileType('jpg')).toBe(FileTypes.OTHER)
expect(getFileType('pdf')).toBe(FileTypes.OTHER)
expect(getFileType('mp3')).toBe(FileTypes.OTHER)
})
it('should handle extreme cases', () => {
expect(getFileType('.averylongfileextensionname')).toBe(FileTypes.OTHER)
expect(getFileType('.tar.gz')).toBe(FileTypes.OTHER)
expect(getFileType('.文件')).toBe(FileTypes.OTHER)
expect(getFileType('.файл')).toBe(FileTypes.OTHER)
})
})
describe('getAllFiles', () => {
it('should return all valid files recursively', () => {
// Mock file system
// @ts-ignore - override type for testing
vi.spyOn(fs, 'readdirSync').mockImplementation((dirPath) => {
if (dirPath === '/test') {
return ['file1.txt', 'file2.pdf', 'subdir']
} else if (dirPath === '/test/subdir') {
return ['file3.md', 'file4.docx']
}
return []
})
vi.mocked(fs.statSync).mockImplementation((filePath) => {
const isDir = String(filePath).endsWith('subdir')
return {
isDirectory: () => isDir,
size: 1024
} as fs.Stats
})
const result = getAllFiles('/test')
expect(result).toHaveLength(4)
expect(result[0].id).toBe('mock-uuid')
expect(result[0].name).toBe('file1.txt')
expect(result[0].type).toBe(FileTypes.TEXT)
expect(result[1].name).toBe('file2.pdf')
expect(result[1].type).toBe(FileTypes.DOCUMENT)
})
it('should skip hidden files', () => {
// @ts-ignore - override type for testing
vi.spyOn(fs, 'readdirSync').mockReturnValue(['.hidden', 'visible.txt'])
vi.mocked(fs.statSync).mockReturnValue({
isDirectory: () => false,
size: 1024
} as fs.Stats)
const result = getAllFiles('/test')
expect(result).toHaveLength(1)
expect(result[0].name).toBe('visible.txt')
})
it('should skip unsupported file types', () => {
// @ts-ignore - override type for testing
vi.spyOn(fs, 'readdirSync').mockReturnValue(['image.jpg', 'video.mp4', 'audio.mp3', 'document.pdf'])
vi.mocked(fs.statSync).mockReturnValue({
isDirectory: () => false,
size: 1024
} as fs.Stats)
const result = getAllFiles('/test')
// Should only include document.pdf as the others are excluded types
expect(result).toHaveLength(1)
expect(result[0].name).toBe('document.pdf')
expect(result[0].type).toBe(FileTypes.DOCUMENT)
})
it('should return empty array for empty directory', () => {
// @ts-ignore - override type for testing
vi.spyOn(fs, 'readdirSync').mockReturnValue([])
const result = getAllFiles('/empty')
expect(result).toHaveLength(0)
})
it('should handle file system errors', () => {
// @ts-ignore - override type for testing
vi.spyOn(fs, 'readdirSync').mockImplementation(() => {
throw new Error('Directory not found')
})
// Since the function doesn't have error handling, we expect it to propagate
expect(() => getAllFiles('/nonexistent')).toThrow('Directory not found')
})
})
describe('getTempDir', () => {
it('should return correct temp directory path', () => {
const tempDir = getTempDir()
expect(tempDir).toBe('/mock/temp/CherryStudio')
})
})
describe('getFilesDir', () => {
it('should return correct files directory path', () => {
const filesDir = getFilesDir()
expect(filesDir).toBe('/mock/userData/Data/Files')
})
})
describe('getConfigDir', () => {
it('should return correct config directory path', () => {
const configDir = getConfigDir()
expect(configDir).toBe('/mock/home/.cherrystudio/config')
})
})
describe('getAppConfigDir', () => {
it('should return correct app config directory path', () => {
const appConfigDir = getAppConfigDir('test-app')
expect(appConfigDir).toBe('/mock/home/.cherrystudio/config/test-app')
})
it('should handle empty app name', () => {
const appConfigDir = getAppConfigDir('')
expect(appConfigDir).toBe('/mock/home/.cherrystudio/config/')
})
})
})

View File

@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest'
import { compress, decompress } from '../zip'
const jsonStr = JSON.stringify({ foo: 'bar', num: 42, arr: [1, 2, 3] })
// 辅助函数:生成大字符串
function makeLargeString(size: number) {
return 'a'.repeat(size)
}
describe('zip', () => {
describe('compress & decompress', () => {
it('should compress and decompress a normal JSON string', async () => {
const compressed = await compress(jsonStr)
expect(compressed).toBeInstanceOf(Buffer)
const decompressed = await decompress(compressed)
expect(decompressed).toBe(jsonStr)
})
it('should handle empty string', async () => {
const compressed = await compress('')
expect(compressed).toBeInstanceOf(Buffer)
const decompressed = await decompress(compressed)
expect(decompressed).toBe('')
})
it('should handle large string', async () => {
const largeStr = makeLargeString(100_000)
const compressed = await compress(largeStr)
expect(compressed).toBeInstanceOf(Buffer)
expect(compressed.length).toBeLessThan(largeStr.length)
const decompressed = await decompress(compressed)
expect(decompressed).toBe(largeStr)
})
it('should throw error when decompressing invalid buffer', async () => {
const invalidBuffer = Buffer.from('not a valid gzip', 'utf-8')
await expect(decompress(invalidBuffer)).rejects.toThrow()
})
it('should throw error when compress input is not string', async () => {
// @ts-expect-error purposely pass wrong type to test error branch
await expect(compress(null)).rejects.toThrow()
// @ts-expect-error purposely pass wrong type to test error branch
await expect(compress(undefined)).rejects.toThrow()
// @ts-expect-error purposely pass wrong type to test error branch
await expect(compress(123)).rejects.toThrow()
})
it('should throw error when decompress input is not buffer', async () => {
// @ts-expect-error purposely pass wrong type to test error branch
await expect(decompress(null)).rejects.toThrow()
// @ts-expect-error purposely pass wrong type to test error branch
await expect(decompress(undefined)).rejects.toThrow()
// @ts-expect-error purposely pass wrong type to test error branch
await expect(decompress('string')).rejects.toThrow()
})
})
})

View File

@@ -9,10 +9,10 @@ const gunzipPromise = util.promisify(zlib.gunzip)
/**
* 压缩字符串
* @param {string} str 要压缩的 JSON 字符串
* @returns {Promise<Buffer>} 压缩后的 Buffer
* @param str
*/
export async function compress(str) {
export async function compress(str: string): Promise<Buffer> {
try {
const buffer = Buffer.from(str, 'utf-8')
return await gzipPromise(buffer)
@@ -27,7 +27,7 @@ export async function compress(str) {
* @param {Buffer} compressedBuffer - 压缩的 Buffer
* @returns {Promise<string>} 解压缩后的 JSON 字符串
*/
export async function decompress(compressedBuffer) {
export async function decompress(compressedBuffer: Buffer): Promise<string> {
try {
const buffer = await gunzipPromise(compressedBuffer)
return buffer.toString('utf-8')

View File

@@ -1,11 +1,14 @@
import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
import { electronAPI } from '@electron-toolkit/preload'
import { FeedUrl } from '@shared/config/constant'
import { IpcChannel } from '@shared/IpcChannel'
import { FileType, KnowledgeBaseParams, KnowledgeItem, MCPServer, Shortcut, WebDavConfig } from '@types'
import { FileType, KnowledgeBaseParams, KnowledgeItem, MCPServer, Shortcut, ThemeMode, WebDavConfig } from '@types'
import { contextBridge, ipcRenderer, OpenDialogOptions, shell, webUtils } from 'electron'
import { Notification } from 'src/renderer/src/types/notification'
import { CreateDirectoryOptions } from 'webdav'
import type { ActionItem } from '../renderer/src/types/selectionTypes'
// Custom APIs for renderer
const api = {
getAppInfo: () => ipcRenderer.invoke(IpcChannel.App_Info),
@@ -18,8 +21,8 @@ const api = {
setLaunchToTray: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetLaunchToTray, isActive),
setTray: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetTray, isActive),
setTrayOnClose: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetTrayOnClose, isActive),
restartTray: () => ipcRenderer.invoke(IpcChannel.App_RestartTray),
setTheme: (theme: 'light' | 'dark' | 'auto') => ipcRenderer.invoke(IpcChannel.App_SetTheme, theme),
setFeedUrl: (feedUrl: FeedUrl) => ipcRenderer.invoke(IpcChannel.App_SetFeedUrl, feedUrl),
setTheme: (theme: ThemeMode) => ipcRenderer.invoke(IpcChannel.App_SetTheme, theme),
handleZoomFactor: (delta: number, reset: boolean = false) =>
ipcRenderer.invoke(IpcChannel.App_HandleZoomFactor, delta, reset),
setAutoUpdate: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetAutoUpdate, isActive),
@@ -74,14 +77,16 @@ const api = {
selectFolder: () => ipcRenderer.invoke(IpcChannel.File_SelectFolder),
saveImage: (name: string, data: string) => ipcRenderer.invoke(IpcChannel.File_SaveImage, name, data),
base64Image: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Base64Image, fileId),
download: (url: string, isUseContentType?: boolean) => ipcRenderer.invoke(IpcChannel.File_Download, url, isUseContentType),
saveBase64Image: (data: string) => ipcRenderer.invoke(IpcChannel.File_SaveBase64Image, data),
download: (url: string, isUseContentType?: boolean) =>
ipcRenderer.invoke(IpcChannel.File_Download, url, isUseContentType),
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),
getPathForFile: (file: File) => webUtils.getPathForFile(file)
},
fs: {
read: (path: string) => ipcRenderer.invoke(IpcChannel.Fs_Read, path)
read: (pathOrUrl: string, encoding?: BufferEncoding) => ipcRenderer.invoke(IpcChannel.Fs_Read, pathOrUrl, encoding)
},
export: {
toWord: (markdown: string, fileName: string) => ipcRenderer.invoke(IpcChannel.Export_Word, markdown, fileName)
@@ -124,7 +129,8 @@ const api = {
deleteFile: (fileId: string, apiKey: string) => ipcRenderer.invoke(IpcChannel.Gemini_DeleteFile, fileId, apiKey)
},
config: {
set: (key: string, value: any) => ipcRenderer.invoke(IpcChannel.Config_Set, key, value),
set: (key: string, value: any, isNotify: boolean = false) =>
ipcRenderer.invoke(IpcChannel.Config_Set, key, value, isNotify),
get: (key: string) => ipcRenderer.invoke(IpcChannel.Config_Get, key)
},
miniWindow: {
@@ -204,7 +210,26 @@ const api = {
subscribe: () => ipcRenderer.invoke(IpcChannel.StoreSync_Subscribe),
unsubscribe: () => ipcRenderer.invoke(IpcChannel.StoreSync_Unsubscribe),
onUpdate: (action: any) => ipcRenderer.invoke(IpcChannel.StoreSync_OnUpdate, action)
}
},
selection: {
hideToolbar: () => ipcRenderer.invoke(IpcChannel.Selection_ToolbarHide),
writeToClipboard: (text: string) => ipcRenderer.invoke(IpcChannel.Selection_WriteToClipboard, text),
determineToolbarSize: (width: number, height: number) =>
ipcRenderer.invoke(IpcChannel.Selection_ToolbarDetermineSize, width, height),
setEnabled: (enabled: boolean) => ipcRenderer.invoke(IpcChannel.Selection_SetEnabled, enabled),
setTriggerMode: (triggerMode: string) => ipcRenderer.invoke(IpcChannel.Selection_SetTriggerMode, triggerMode),
setFollowToolbar: (isFollowToolbar: boolean) =>
ipcRenderer.invoke(IpcChannel.Selection_SetFollowToolbar, isFollowToolbar),
setRemeberWinSize: (isRemeberWinSize: boolean) =>
ipcRenderer.invoke(IpcChannel.Selection_SetRemeberWinSize, isRemeberWinSize),
setFilterMode: (filterMode: string) => ipcRenderer.invoke(IpcChannel.Selection_SetFilterMode, filterMode),
setFilterList: (filterList: string[]) => ipcRenderer.invoke(IpcChannel.Selection_SetFilterList, filterList),
processAction: (actionItem: ActionItem) => ipcRenderer.invoke(IpcChannel.Selection_ProcessAction, actionItem),
closeActionWindow: () => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowClose),
minimizeActionWindow: () => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowMinimize),
pinActionWindow: (isPinned: boolean) => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowPin, isPinned)
},
quoteToMainWindow: (text: string) => ipcRenderer.invoke(IpcChannel.App_QuoteToMain, text)
}
// Use `contextBridge` APIs to expose Electron APIs to

View File

@@ -1,49 +0,0 @@
import { vi } from 'vitest'
vi.mock('electron-log/renderer', () => {
return {
default: {
info: console.log,
error: console.error,
warn: console.warn,
debug: console.debug,
verbose: console.log,
silly: console.log,
log: console.log,
transports: {
console: {
level: 'info'
}
}
}
}
})
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

@@ -0,0 +1,41 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="initial-scale=1, width=device-width" />
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; connect-src blob: *; script-src 'self' 'unsafe-eval' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: * blob:; frame-src * file:" />
<title>Cherry Studio Selection Assistant</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/windows/selection/action/entryPoint.tsx"></script>
<style>
html {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
width: 100vw;
height: 100vh;
margin: 0;
padding: 0;
box-sizing: border-box;
}
#root {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
box-sizing: border-box;
}
</style>
</body>
</html>

View File

@@ -0,0 +1,43 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="initial-scale=1, width=device-width" />
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; connect-src blob: *; script-src 'self' 'unsafe-eval' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: * blob:; frame-src * file:" />
<title>Cherry Studio Selection Toolbar</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/windows/selection/toolbar/entryPoint.tsx"></script>
<style>
html {
margin: 0;
}
body {
margin: 0;
padding: 0;
overflow: hidden;
width: 100vw;
height: 100vh;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
#root {
margin: 0;
padding: 0;
width: max-content !important;
height: fit-content !important;
}
</style>
</body>
</html>

View File

@@ -5,7 +5,7 @@ import { Provider } from 'react-redux'
import { HashRouter, Route, Routes } from 'react-router-dom'
import { PersistGate } from 'redux-persist/integration/react'
import Sidebar from './components/app/Sidebar'
import MainSidebar from './components/app/MainSidebar'
import TopViewContainer from './components/TopView'
import AntdProvider from './context/AntdProvider'
import { CodeStyleProvider } from './context/CodeStyleProvider'
@@ -13,14 +13,9 @@ import { NotificationProvider } from './context/NotificationProvider'
import StyleSheetManager from './context/StyleSheetManager'
import { ThemeProvider } from './context/ThemeProvider'
import NavigationHandler from './handler/NavigationHandler'
import AgentsPage from './pages/agents/AgentsPage'
import AppsPage from './pages/apps/AppsPage'
import FilesPage from './pages/files/FilesPage'
import DiscoverPage from './pages/discover'
import HomePage from './pages/home/HomePage'
import KnowledgePage from './pages/knowledge/KnowledgePage'
import PaintingsRoutePage from './pages/paintings/PaintingsRoutePage'
import SettingsPage from './pages/settings/SettingsPage'
import TranslatePage from './pages/translate/TranslatePage'
function App(): React.ReactElement {
return (
@@ -34,16 +29,18 @@ function App(): React.ReactElement {
<TopViewContainer>
<HashRouter>
<NavigationHandler />
<Sidebar />
<MainSidebar />
<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="/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="/mcp-servers/*" element={<McpServersPage />} /> */}
<Route path="/settings/*" element={<SettingsPage />} />
<Route path="/discover/*" element={<DiscoverPage />} />
</Routes>
</HashRouter>
</TopViewContainer>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

View File

@@ -25,7 +25,6 @@
}
.minapp-drawer {
max-width: calc(100vw - var(--sidebar-width));
.ant-drawer-content-wrapper {
box-shadow: none;
}
@@ -33,7 +32,7 @@
position: absolute;
-webkit-app-region: drag;
min-height: calc(var(--navbar-height) + 0.5px);
width: calc(100vw - var(--sidebar-width));
width: 100%;
margin-top: -0.5px;
border-bottom: none;
}
@@ -206,8 +205,14 @@
.ant-collapse {
border: 1px solid var(--color-border);
.ant-color-picker & {
border: none;
}
}
.ant-collapse-content {
border-top: 1px solid var(--color-border) !important;
.ant-color-picker & {
border-top: none !important;
}
}

View File

@@ -26,9 +26,10 @@
--color-primary-mute: #00b96b33;
--color-text: var(--color-text-1);
--color-text-secondary: rgba(235, 235, 245, 0.7);
--color-icon: #ffffff99;
--color-icon-white: #ffffff;
--color-border: #ffffff19;
--color-border: #383838;
--color-border-soft: #ffffff10;
--color-border-mute: #ffffff05;
--color-error: #f44336;
@@ -43,6 +44,9 @@
--color-reference-text: #ffffff;
--color-reference-background: #0b0e12;
--color-list-item: rgba(255, 255, 255, 0.1);
--color-list-item-hover: rgba(255, 255, 255, 0.05);
--modal-background: #1f1f1f;
--color-highlight: rgba(0, 0, 0, 1);
@@ -52,7 +56,7 @@
--navbar-background-mac: rgba(20, 20, 20, 0.55);
--navbar-background: #1f1f1f;
--navbar-height: 40px;
--navbar-height: 42px;
--sidebar-width: 50px;
--status-bar-height: 40px;
--input-bar-height: 100px;
@@ -67,7 +71,8 @@
--chat-background-assistant: #2c2c2c;
--chat-text-user: var(--color-black);
--list-item-border-radius: 16px;
--list-item-border-radius: 8px;
--border-width: 0.5px;
}
[theme-mode='light'] {
@@ -98,6 +103,7 @@
--color-primary-mute: #00b96b33;
--color-text: var(--color-text-1);
--color-text-secondary: rgba(0, 0, 0, 0.75);
--color-icon: #00000099;
--color-icon-white: #000000;
--color-border: #00000019;
@@ -115,6 +121,9 @@
--color-reference-text: #000000;
--color-reference-background: #f1f7ff;
--color-list-item: rgba(255, 255, 255, 0.9);
--color-list-item-hover: rgba(255, 255, 255, 0.5);
--modal-background: var(--color-white);
--color-highlight: initial;
@@ -128,4 +137,6 @@
--chat-background-user: #95ec69;
--chat-background-assistant: #ffffff;
--chat-text-user: var(--color-text);
--border-width: 0.5px;
}

View File

@@ -1,8 +1,6 @@
#content-container {
background-color: var(--color-background);
border-top: 0.5px solid var(--color-border);
border-top-left-radius: 10px;
border-left: 0.5px solid var(--color-border);
}
.group-container {
@@ -10,3 +8,7 @@
width: 100%;
}
}
.context-menu-container {
max-width: 100%;
}

View File

@@ -5,9 +5,8 @@
'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';
serif, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Ubuntu, Roboto, Oxygen, Cantarell, 'Open Sans',
'Helvetica Neue', 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

@@ -12,7 +12,7 @@
*::before,
*::after {
box-sizing: border-box;
margin: 0;
// margin: 0;
font-weight: normal;
}
@@ -147,11 +147,16 @@ ul {
background-color: var(--color-white-soft);
}
}
.group-grid-container.horizontal,
.group-grid-container.grid {
.message-content-container-assistant {
padding: 0;
}
}
.group-message-wrapper {
background-color: var(--color-background);
.message-content-container {
width: 100%;
border: 1px solid var(--color-background-mute);
}
}
.group-menu-bar {
@@ -170,6 +175,7 @@ span.highlight {
background-color: var(--color-background-highlight);
color: var(--color-highlight);
}
span.highlight.selected {
background-color: var(--color-background-highlight-accent);
}

View File

@@ -299,15 +299,21 @@ emoji-picker {
overflow-x: auto;
overflow-y: hidden;
}
mjx-container {
overflow-x: auto;
}
/* CodeMirror 相关样式 */
.cm-editor {
border-radius: 5px;
&.cm-focused {
outline: none;
}
.cm-scroller {
font-family: var(--code-font-family);
padding: 1px;
border-radius: 5px;
.cm-gutters {

View File

@@ -1,15 +1,11 @@
:root {
--color-scrollbar-thumb: rgba(255, 255, 255, 0.15);
--color-scrollbar-thumb-hover: rgba(255, 255, 255, 0.2);
--color-scrollbar-thumb-right: rgba(255, 255, 255, 0.18);
--color-scrollbar-thumb-right-hover: rgba(255, 255, 255, 0.25);
}
body[theme-mode='light'] {
--color-scrollbar-thumb: rgba(0, 0, 0, 0.15);
--color-scrollbar-thumb-hover: rgba(0, 0, 0, 0.2);
--color-scrollbar-thumb-right: rgba(0, 0, 0, 0.18);
--color-scrollbar-thumb-right-hover: rgba(0, 0, 0, 0.25);
}
/* 全局初始化滚动条样式 */

View File

@@ -0,0 +1,26 @@
@use './font.scss';
html {
font-family: var(--font-family);
}
:root {
--color-selection-toolbar-background: rgba(20, 20, 20, 0.95);
--color-selection-toolbar-border: rgba(55, 55, 55, 0.5);
--color-selection-toolbar-shadow: rgba(50, 50, 50, 0.3);
--color-selection-toolbar-text: rgba(255, 255, 245, 0.9);
--color-selection-toolbar-hover-bg: #222222;
--color-primary: #00b96b;
--color-error: #f44336;
}
[theme-mode='light'] {
--color-selection-toolbar-background: rgba(245, 245, 245, 0.95);
--color-selection-toolbar-border: rgba(200, 200, 200, 0.5);
--color-selection-toolbar-shadow: rgba(50, 50, 50, 0.3);
--color-selection-toolbar-text: rgba(0, 0, 0, 1);
--color-selection-toolbar-hover-bg: rgba(0, 0, 0, 0.04);
}

View File

@@ -0,0 +1,146 @@
@import 'tailwindcss' source('../../../src');
@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *));
/* 如需自定义:
1. 清晰地组织自定义 CSS 到相应的层中。
2. 基础样式(如全局重置、链接样式)放入 base 层;
3. 可复用的组件样式(如果仍使用 @apply 或原生 CSS 嵌套创建)放入 components 层;
4. 新的自定义工具类放入 utilities 层。
*/
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.21 0.006 285.885);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.21 0.006 285.885);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.015 286.067);
}
.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.92 0.004 286.32);
--primary-foreground: oklch(0.21 0.006 285.885);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.552 0.016 285.938);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--animate-marquee: marquee var(--duration) infinite linear;
--animate-marquee-vertical: marquee-vertical var(--duration) linear infinite;
@keyframes marquee {
from {
transform: translateX(0);
}
to {
transform: translateX(calc(-100% - var(--gap)));
}
}
@keyframes marquee-vertical {
from {
transform: translateY(0);
}
to {
transform: translateY(calc(-100% - var(--gap)));
}
}
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -134,26 +134,31 @@ const CodePreview = ({ children, language, setTools }: CodePreviewProps) => {
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()
if (prevCodeLengthRef.current > 0) {
setTimeout(highlightCode, 0)
return
}
const codeElement = codeContentRef.current
if (!codeElement) return
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].intersectionRatio > 0 && isMounted) {
setTimeout(highlightCode, 0)
observer.disconnect()
}
},
{
rootMargin: '50px 0px 50px 0px'
}
})
)
observer.observe(codeElement)
@@ -231,7 +236,6 @@ const ContentContainer = styled.div<{
$wrap: boolean
$fadeIn: boolean
}>`
display: block;
position: relative;
overflow: auto;
border: 0.5px solid transparent;
@@ -239,12 +243,11 @@ const ContentContainer = styled.div<{
margin-top: 0;
.shiki {
display: flex;
min-width: 100%;
padding: 1em;
code {
display: block;
display: flex;
flex-direction: column;
.line {
display: block;

View File

@@ -1,8 +1,10 @@
import { nanoid } from '@reduxjs/toolkit'
import { CodeTool, usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
import SvgSpinners180Ring from '@renderer/components/Icons/SvgSpinners180Ring'
import { useMermaid } from '@renderer/hooks/useMermaid'
import { Flex } from 'antd'
import React, { memo, startTransition, useCallback, useEffect, useRef, useState } from 'react'
import { Flex, Spin } from 'antd'
import { debounce } from 'lodash'
import React, { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import styled from 'styled-components'
interface Props {
@@ -10,12 +12,16 @@ interface Props {
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
}
/** 预览 Mermaid 图表
* 通过防抖渲染提供比较统一的体验,减少闪烁。
* FIXME: 等将来容易判断代码块结束位置时再重构。
*/
const MermaidPreview: React.FC<Props> = ({ children, setTools }) => {
const { mermaid, isLoading, error: mermaidError } = useMermaid()
const { mermaid, isLoading: isLoadingMermaid, 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 [error, setError] = useState<string | null>(null)
const [isRendering, setIsRendering] = useState(false)
// 使用通用图像工具
const { handleZoom, handleCopyImage, handleDownload } = usePreviewToolHandlers(mermaidRef, {
@@ -32,55 +38,69 @@ const MermaidPreview: React.FC<Props> = ({ children, setTools }) => {
handleDownload
})
const render = useCallback(async () => {
try {
if (!children) return
// 实际的渲染函数
const renderMermaid = useCallback(
async (content: string) => {
if (!content || !mermaidRef.current) return
// 验证语法,提前抛出异常
await mermaid.parse(children)
try {
setIsRendering(true)
if (!mermaidRef.current) return
const { svg } = await mermaid.render(diagramId, children, mermaidRef.current)
// 验证语法,提前抛出异常
await mermaid.parse(content)
// 避免不可见时产生 undefined 和 NaN
const fixedSvg = svg.replace(/translate\(undefined,\s*NaN\)/g, 'translate(0, 0)')
mermaidRef.current.innerHTML = fixedSvg
const { svg } = await mermaid.render(diagramId, content, mermaidRef.current)
// 没有语法错误时清除错误记录和定时器
setError(null)
if (errorTimeoutRef.current) {
clearTimeout(errorTimeoutRef.current)
errorTimeoutRef.current = null
}
} catch (error) {
// 延迟显示错误
if (errorTimeoutRef.current) clearTimeout(errorTimeoutRef.current)
errorTimeoutRef.current = setTimeout(() => {
// 避免不可见时产生 undefined 和 NaN
const fixedSvg = svg.replace(/translate\(undefined,\s*NaN\)/g, 'translate(0, 0)')
mermaidRef.current.innerHTML = fixedSvg
// 渲染成功,清除错误记录
setError(null)
} catch (error) {
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
} finally {
setIsRendering(false)
}
},
[diagramId, mermaid]
)
// debounce 渲染
const debouncedRender = useMemo(
() =>
debounce((content: string) => {
startTransition(() => renderMermaid(content))
}, 300),
[renderMermaid]
)
// 触发渲染
useEffect(() => {
if (isLoadingMermaid) return
if (children) {
setIsRendering(true)
debouncedRender(children)
} else {
debouncedRender.cancel()
setIsRendering(false)
}
}, [isLoading, render])
return () => {
debouncedRender.cancel()
}
}, [children, isLoadingMermaid, debouncedRender])
const isLoading = isLoadingMermaid || isRendering
return (
<Flex vertical>
{(mermaidError || error) && <StyledError>{mermaidError || error}</StyledError>}
<StyledMermaid ref={mermaidRef} className="mermaid" />
</Flex>
<Spin spinning={isLoading} indicator={<SvgSpinners180Ring color="var(--color-text-2)" />}>
<Flex vertical style={{ minHeight: isLoading ? '2rem' : 'auto' }}>
{(mermaidError || error) && <StyledError>{mermaidError || error}</StyledError>}
<StyledMermaid ref={mermaidRef} className="mermaid" />
</Flex>
</Spin>
)
}

View File

@@ -1,18 +1,53 @@
import { CodeTool, usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
import { memo, useRef } from 'react'
import styled from 'styled-components'
import { memo, useEffect, useRef } from 'react'
interface Props {
children: string
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
}
/**
* 使用 Shadow DOM 渲染 SVG
*/
const SvgPreview: React.FC<Props> = ({ children, setTools }) => {
const svgContainerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const container = svgContainerRef.current
if (!container) return
const shadowRoot = container.shadowRoot || container.attachShadow({ mode: 'open' })
// 添加基础样式
const style = document.createElement('style')
style.textContent = `
:host {
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;
display: block;
}
svg {
max-width: 100%;
height: auto;
}
`
// 清空并重新添加内容
shadowRoot.innerHTML = ''
shadowRoot.appendChild(style)
const svgContainer = document.createElement('div')
svgContainer.innerHTML = children
shadowRoot.appendChild(svgContainer)
}, [children])
// 使用通用图像工具
const { handleCopyImage, handleDownload } = usePreviewToolHandlers(svgContainerRef, {
imgSelector: '.svg-preview svg',
imgSelector: 'svg',
prefix: 'svg-image'
})
@@ -23,18 +58,7 @@ const SvgPreview: React.FC<Props> = ({ children, setTools }) => {
handleDownload
})
return (
<SvgPreviewContainer ref={svgContainerRef} className="svg-preview" dangerouslySetInnerHTML={{ __html: children }} />
)
return <div ref={svgContainerRef} className="svg-preview" />
}
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

@@ -249,8 +249,8 @@ const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
}
const CodeBlockWrapper = styled.div<{ $isInSpecialView: boolean }>`
/* FIXME: 在 bubble style 中撑开一些宽度*/
position: relative;
width: 100%;
.code-toolbar {
background-color: ${(props) => (props.$isInSpecialView ? 'transparent' : 'var(--color-background-mute)')};
@@ -285,13 +285,10 @@ const CodeHeader = styled.div<{ $isInSpecialView: boolean }>`
const SplitViewWrapper = styled.div`
display: flex;
width: 100%;
> * {
flex: 1 1 0;
width: 0;
min-width: 0;
max-width: 100%;
flex: 1 1 auto;
width: 100%;
}
`

View File

@@ -224,11 +224,10 @@ const CodeEditor = ({
...customBasicSetup // override basicSetup
}}
style={{
...style,
fontSize: `${fontSize - 1}px`,
border: '0.5px solid transparent',
borderRadius: '5px',
marginTop: 0,
...style
marginTop: 0
}}
/>
)

View File

@@ -5,12 +5,12 @@ export const TOOL_SPECS: Record<string, CodeToolSpec> = {
copy: {
id: 'copy',
type: 'core',
order: 10
order: 11
},
download: {
id: 'download',
type: 'core',
order: 11
order: 10
},
edit: {
id: 'edit',

View File

@@ -32,6 +32,14 @@ export const usePreviewToolHandlers = (
// 创建选择器函数
const getImgElement = useCallback(() => {
if (!containerRef.current) return null
// 优先尝试从 Shadow DOM 中查找
const shadowRoot = containerRef.current.shadowRoot
if (shadowRoot) {
return shadowRoot.querySelector(imgSelector) as SVGElement | null
}
// 降级到常规 DOM 查找
return containerRef.current.querySelector(imgSelector) as SVGElement | null
}, [containerRef, imgSelector])

View File

@@ -1,4 +1,3 @@
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { Dropdown } from 'antd'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -12,7 +11,6 @@ interface ContextMenuProps {
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(
@@ -20,12 +18,6 @@ const ContextMenu: React.FC<ContextMenuProps> = ({ children, onContextMenu }) =>
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)
}
@@ -45,7 +37,7 @@ const ContextMenu: React.FC<ContextMenuProps> = ({ children, onContextMenu }) =>
}, [])
// 获取右键菜单项
const getContextMenuItems = (t: (key: string) => string, selectedQuoteText: string, selectedText: string) => [
const getContextMenuItems = (t: (key: string) => string, selectedText: string) => [
{
key: 'copy',
label: t('common.copy'),
@@ -66,8 +58,8 @@ const ContextMenu: React.FC<ContextMenuProps> = ({ children, onContextMenu }) =>
key: 'quote',
label: t('chat.message.quote'),
onClick: () => {
if (selectedQuoteText) {
EventEmitter.emit(EVENT_NAMES.QUOTE_TEXT, selectedQuoteText)
if (selectedText) {
window.api?.quoteToMainWindow(selectedText)
}
}
}
@@ -78,7 +70,7 @@ const ContextMenu: React.FC<ContextMenuProps> = ({ children, onContextMenu }) =>
{contextMenuPosition && (
<Dropdown
overlayStyle={{ position: 'fixed', left: contextMenuPosition.x, top: contextMenuPosition.y, zIndex: 1000 }}
menu={{ items: getContextMenuItems(t, selectedQuoteText, selectedText) }}
menu={{ items: getContextMenuItems(t, selectedText) }}
open={true}
trigger={['contextMenu']}>
<div />

View File

@@ -0,0 +1,83 @@
import { Tooltip } from 'antd'
import { Copy } from 'lucide-react'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface CopyButtonProps {
tooltip?: string
textToCopy: string
label?: string
color?: string
hoverColor?: string
size?: number
}
interface ButtonContainerProps {
$color: string
$hoverColor: string
}
const CopyButton: FC<CopyButtonProps> = ({
tooltip,
textToCopy,
label,
color = 'var(--color-text-2)',
hoverColor = 'var(--color-primary)',
size = 14
}) => {
const { t } = useTranslation()
const handleCopy = () => {
navigator.clipboard
.writeText(textToCopy)
.then(() => {
window.message?.success(t('message.copy.success'))
})
.catch(() => {
window.message?.error(t('message.copy.failed'))
})
}
const button = (
<ButtonContainer $color={color} $hoverColor={hoverColor} onClick={handleCopy}>
<Copy size={size} className="copy-icon" />
{label && <RightText size={size}>{label}</RightText>}
</ButtonContainer>
)
if (tooltip) {
return <Tooltip title={tooltip}>{button}</Tooltip>
}
return button
}
const ButtonContainer = styled.div<ButtonContainerProps>`
display: flex;
flex-direction: row;
align-items: center;
gap: 4px;
cursor: pointer;
color: ${(props) => props.$color};
transition: color 0.2s;
.copy-icon {
color: ${(props) => props.$color};
transition: color 0.2s;
}
&:hover {
color: ${(props) => props.$hoverColor};
.copy-icon {
color: ${(props) => props.$hoverColor};
}
}
`
const RightText = styled.span<{ size: number }>`
font-size: ${(props) => props.size}px;
`
export default CopyButton

View File

@@ -66,6 +66,7 @@ const CustomCollapse: FC<CustomCollapseProps> = ({
const collapseStyle = merge({}, defaultCollapseStyle, style)
const collapseItemStyles = useMemo(() => {
return merge({}, defaultCollapseItemStyles, styles)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeKeys])
return (

View File

@@ -1,4 +1,3 @@
import { getLeadingEmoji } from '@renderer/utils'
import { FC } from 'react'
import styled from 'styled-components'
@@ -8,12 +7,10 @@ interface EmojiIconProps {
}
const EmojiIcon: FC<EmojiIconProps> = ({ emoji, className }) => {
const _emoji = getLeadingEmoji(emoji || '⭐️') || '⭐️'
return (
<Container className={className}>
<EmojiBackground>{_emoji}</EmojiBackground>
{_emoji}
<EmojiBackground>{emoji || '⭐️'}</EmojiBackground>
{emoji}
</Container>
)
}

View File

@@ -13,7 +13,7 @@ const EmojiPicker: FC<Props> = ({ onEmojiClick }) => {
if (ref.current) {
ref.current.addEventListener('emoji-click', (event: any) => {
event.stopPropagation()
onEmojiClick(event.detail.emoji.unicode)
onEmojiClick(event.detail.unicode || event.detail.emoji.unicode)
})
}
}, [onEmojiClick])

View File

@@ -0,0 +1,141 @@
import {
CopyOutlined,
DownloadOutlined,
FileImageOutlined,
RotateLeftOutlined,
RotateRightOutlined,
SwapOutlined,
UndoOutlined,
ZoomInOutlined,
ZoomOutOutlined
} from '@ant-design/icons'
import { download } from '@renderer/utils/download'
import { Dropdown, Image as AntImage, ImageProps as AntImageProps, Space } from 'antd'
import { Base64 } from 'js-base64'
import mime from 'mime'
import React from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface ImageViewerProps extends AntImageProps {
src: string
}
const ImageViewer: React.FC<ImageViewerProps> = ({ src, style, ...props }) => {
const { t } = useTranslation()
// 复制图片到剪贴板
const handleCopyImage = async (src: string) => {
try {
if (src.startsWith('data:')) {
// 处理 base64 格式的图片
const match = src.match(/^data:(image\/\w+);base64,(.+)$/)
if (!match) throw new Error('无效的 base64 图片格式')
const mimeType = match[1]
const byteArray = Base64.toUint8Array(match[2])
const blob = new Blob([byteArray], { type: mimeType })
await navigator.clipboard.write([new ClipboardItem({ [mimeType]: blob })])
} else if (src.startsWith('file://')) {
// 处理本地文件路径
const bytes = await window.api.fs.read(src)
const mimeType = mime.getType(src) || 'application/octet-stream'
const blob = new Blob([bytes], { type: mimeType })
await navigator.clipboard.write([
new ClipboardItem({
[mimeType]: blob
})
])
} else {
// 处理 URL 格式的图片
const response = await fetch(src)
const blob = await response.blob()
await navigator.clipboard.write([
new ClipboardItem({
[blob.type]: blob
})
])
}
window.message.success(t('message.copy.success'))
} catch (error) {
console.error('复制图片失败:', error)
window.message.error(t('message.copy.failed'))
}
}
const getContextMenuItems = (src: string) => {
return [
{
key: 'copy-url',
label: t('common.copy'),
icon: <CopyOutlined />,
onClick: () => {
navigator.clipboard.writeText(src)
window.message.success(t('message.copy.success'))
}
},
{
key: 'download',
label: t('common.download'),
icon: <DownloadOutlined />,
onClick: () => download(src)
},
{
key: 'copy-image',
label: t('code_block.preview.copy.image'),
icon: <FileImageOutlined />,
onClick: () => handleCopyImage(src)
}
]
}
return (
<Dropdown menu={{ items: getContextMenuItems(src) }} trigger={['contextMenu']}>
<AntImage
src={src}
style={style}
{...props}
preview={{
mask: typeof props.preview === 'object' ? props.preview.mask : false,
toolbarRender: (
_,
{
transform: { scale },
actions: { onFlipY, onFlipX, onRotateLeft, onRotateRight, onZoomOut, onZoomIn, onReset }
}
) => (
<ToolbarWrapper size={12} className="toolbar-wrapper">
<SwapOutlined rotate={90} onClick={onFlipY} />
<SwapOutlined onClick={onFlipX} />
<RotateLeftOutlined onClick={onRotateLeft} />
<RotateRightOutlined onClick={onRotateRight} />
<ZoomOutOutlined disabled={scale === 1} onClick={onZoomOut} />
<ZoomInOutlined disabled={scale === 50} onClick={onZoomIn} />
<UndoOutlined onClick={onReset} />
<CopyOutlined onClick={() => handleCopyImage(src)} />
<DownloadOutlined onClick={() => download(src)} />
</ToolbarWrapper>
)
}}
/>
</Dropdown>
)
}
const ToolbarWrapper = styled(Space)`
padding: 0px 24px;
color: #fff;
font-size: 20px;
background-color: rgba(0, 0, 0, 0.1);
border-radius: 100px;
.anticon {
padding: 12px;
cursor: pointer;
}
.anticon:hover {
opacity: 0.3;
}
`
export default ImageViewer

View File

@@ -395,10 +395,7 @@ const MinappPopupContainer: React.FC = () => {
height={'100%'}
maskClosable={false}
closeIcon={null}
style={{
marginLeft: 'var(--sidebar-width)',
backgroundColor: window.root.style.background
}}>
style={{ backgroundColor: window.root.style.background }}>
{!isReady && (
<EmptyView>
<Avatar
@@ -418,7 +415,7 @@ const TitleContainer = styled.div`
display: flex;
flex-direction: row;
align-items: center;
padding-left: ${isMac ? '20px' : '10px'};
padding-left: ${isMac ? '80px' : '10px'};
padding-right: 10px;
position: absolute;
top: 0;

View File

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

View File

@@ -1,26 +1,42 @@
import i18n from '@renderer/i18n'
import store from '@renderer/store'
import { exportMarkdownToObsidian } from '@renderer/utils/export'
import { Alert, Empty, Form, Input, Modal, Select, Spin, TreeSelect } from 'antd'
import type { Topic } from '@renderer/types'
import type { Message } from '@renderer/types/newMessage'
import {
exportMarkdownToObsidian,
messagesToMarkdown,
messageToMarkdown,
messageToMarkdownWithReasoning,
topicToMarkdown
} from '@renderer/utils/export'
import { Alert, Empty, Form, Input, Modal, Select, Spin, Switch, TreeSelect } from 'antd'
import React, { useEffect, useState } from 'react'
const { Option } = Select
interface ObsidianExportDialogProps {
title: string
markdown: string
open: boolean
onClose: (success: boolean) => void
obsidianTags: string | null
processingMethod: string | '3' //默认新增(存在就覆盖)
}
interface FileInfo {
path: string
type: 'folder' | 'markdown'
name: string
}
const ObsidianProcessingMethod = {
APPEND: '1',
PREPEND: '2',
NEW_OR_OVERWRITE: '3'
} as const
interface PopupContainerProps {
title: string
obsidianTags: string | null
processingMethod: (typeof ObsidianProcessingMethod)[keyof typeof ObsidianProcessingMethod]
open: boolean
resolve: (success: boolean) => void
message?: Message
messages?: Message[]
topic?: Topic
}
// 转换文件信息数组为树形结构
const convertToTreeData = (files: FileInfo[]) => {
const treeData: any[] = [
@@ -113,13 +129,15 @@ const convertToTreeData = (files: FileInfo[]) => {
return treeData
}
const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
const PopupContainer: React.FC<PopupContainerProps> = ({
title,
markdown,
open,
onClose,
obsidianTags,
processingMethod
processingMethod,
open,
resolve,
message,
messages,
topic
}) => {
const defaultObsidianVault = store.getState().settings.defaultObsidianVault
const [state, setState] = useState({
@@ -130,8 +148,6 @@ const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
processingMethod: processingMethod,
folder: ''
})
// 是否手动编辑过标题
const [hasTitleBeenManuallyEdited, setHasTitleBeenManuallyEdited] = useState(false)
const [vaults, setVaults] = useState<Array<{ path: string; name: string }>>([])
const [files, setFiles] = useState<FileInfo[]>([])
@@ -139,8 +155,8 @@ const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
const [selectedVault, setSelectedVault] = useState<string>('')
const [loading, setLoading] = useState<boolean>(false)
const [error, setError] = useState<string | null>(null)
const [exportReasoning, setExportReasoning] = useState(false)
// 处理文件数据转为树形结构
useEffect(() => {
if (files.length > 0) {
const treeData = convertToTreeData(files)
@@ -157,28 +173,21 @@ const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
}
}, [files])
// 组件加载时获取Vault列表
useEffect(() => {
const fetchVaults = async () => {
try {
setLoading(true)
setError(null)
const vaultsData = await window.obsidian.getVaults()
if (vaultsData.length === 0) {
setError(i18n.t('chat.topics.export.obsidian_no_vaults'))
setLoading(false)
return
}
setVaults(vaultsData)
// 如果没有选择的vault使用默认值或第一个
const vaultToUse = defaultObsidianVault || vaultsData[0]?.name
if (vaultToUse) {
setSelectedVault(vaultToUse)
// 获取选中vault的文件和文件夹
const filesData = await window.obsidian.getFiles(vaultToUse)
setFiles(filesData)
}
@@ -189,11 +198,9 @@ const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
setLoading(false)
}
}
fetchVaults()
}, [defaultObsidianVault])
// 当选择的vault变化时获取其文件和文件夹
useEffect(() => {
if (selectedVault) {
const fetchFiles = async () => {
@@ -209,7 +216,6 @@ const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
setLoading(false)
}
}
fetchFiles()
}
}, [selectedVault])
@@ -219,82 +225,71 @@ const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
setError(i18n.t('chat.topics.export.obsidian_no_vault_selected'))
return
}
//构建content 并复制到粘贴板
let markdown = ''
if (topic) {
markdown = await topicToMarkdown(topic, exportReasoning)
} else if (messages && messages.length > 0) {
markdown = messagesToMarkdown(messages, exportReasoning)
} else if (message) {
markdown = exportReasoning ? messageToMarkdownWithReasoning(message) : messageToMarkdown(message)
} else {
markdown = ''
}
let content = ''
if (state.processingMethod !== '3') {
if (state.processingMethod !== ObsidianProcessingMethod.NEW_OR_OVERWRITE) {
content = `\n---\n${markdown}`
} else {
content = `---
\ntitle: ${state.title}
\ncreated: ${state.createdAt}
\nsource: ${state.source}
\ntags: ${state.tags}
\n---\n${markdown}`
content = `---\ntitle: ${state.title}\ncreated: ${state.createdAt}\nsource: ${state.source}\ntags: ${state.tags}\n---\n${markdown}`
}
if (content === '') {
window.message.error(i18n.t('chat.topics.export.obsidian_export_failed'))
return
}
await navigator.clipboard.writeText(content)
// 导出到Obsidian
exportMarkdownToObsidian({
...state,
folder: state.folder,
vault: selectedVault
})
onClose(true)
setOpen(false)
resolve(true)
}
const [openState, setOpen] = useState(open)
useEffect(() => {
setOpen(open)
}, [open])
const handleCancel = () => {
onClose(false)
setOpen(false)
resolve(false)
}
const handleChange = (key: string, value: any) => {
setState((prevState) => ({ ...prevState, [key]: value }))
}
// 处理title输入变化
const handleTitleInputChange = (newTitle: string) => {
handleChange('title', newTitle)
setHasTitleBeenManuallyEdited(true)
}
const handleVaultChange = (value: string) => {
setSelectedVault(value)
// 文件夹会通过useEffect自动获取
setState((prevState) => ({
...prevState,
folder: ''
}))
setState((prevState) => ({ ...prevState, folder: '' }))
}
// 处理文件选择
const handleFileSelect = (value: string) => {
// 更新folder值
handleChange('folder', value)
// 检查是否选中md文件
if (value) {
const selectedFile = files.find((file) => file.path === value)
if (selectedFile) {
if (selectedFile.type === 'markdown') {
// 如果是md文件自动设置标题为文件名并设置处理方式为1(追加)
const fileName = selectedFile.name
const titleWithoutExt = fileName.endsWith('.md') ? fileName.substring(0, fileName.length - 3) : fileName
handleChange('title', titleWithoutExt)
// 重置手动编辑标记因为这是非用户设置的title
setHasTitleBeenManuallyEdited(false)
handleChange('processingMethod', '1')
handleChange('processingMethod', ObsidianProcessingMethod.APPEND)
} else {
// 如果是文件夹自动设置标题为话题名并设置处理方式为3(新建)
handleChange('processingMethod', '3')
// 仅当用户未手动编辑过 title 时,才将其重置为 props.title
handleChange('processingMethod', ObsidianProcessingMethod.NEW_OR_OVERWRITE)
if (!hasTitleBeenManuallyEdited) {
// title 是 props.title
handleChange('title', title)
}
}
@@ -305,7 +300,7 @@ const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
return (
<Modal
title={i18n.t('chat.topics.export.obsidian_atributes')}
open={open}
open={openState}
onOk={handleOk}
onCancel={handleCancel}
width={600}
@@ -317,9 +312,9 @@ const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
type: 'primary',
disabled: vaults.length === 0 || loading || !!error
}}
okText={i18n.t('chat.topics.export.obsidian_btn')}>
okText={i18n.t('chat.topics.export.obsidian_btn')}
afterClose={() => setOpen(open)}>
{error && <Alert message={error} type="error" showIcon style={{ marginBottom: 16 }} />}
<Form layout="horizontal" labelCol={{ span: 6 }} wrapperCol={{ span: 18 }} labelAlign="left">
<Form.Item label={i18n.t('chat.topics.export.obsidian_title')}>
<Input
@@ -328,7 +323,6 @@ const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
placeholder={i18n.t('chat.topics.export.obsidian_title_placeholder')}
/>
</Form.Item>
<Form.Item label={i18n.t('chat.topics.export.obsidian_vault')}>
{vaults.length > 0 ? (
<Select
@@ -354,7 +348,6 @@ const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
/>
)}
</Form.Item>
<Form.Item label={i18n.t('chat.topics.export.obsidian_path')}>
<Spin spinning={loading}>
{selectedVault ? (
@@ -376,7 +369,6 @@ const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
)}
</Spin>
</Form.Item>
<Form.Item label={i18n.t('chat.topics.export.obsidian_tags')}>
<Input
value={state.tags}
@@ -398,21 +390,29 @@ const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
placeholder={i18n.t('chat.topics.export.obsidian_source_placeholder')}
/>
</Form.Item>
<Form.Item label={i18n.t('chat.topics.export.obsidian_operate')}>
<Select
value={state.processingMethod}
onChange={(value) => handleChange('processingMethod', value)}
placeholder={i18n.t('chat.topics.export.obsidian_operate_placeholder')}
allowClear>
<Option value="1">{i18n.t('chat.topics.export.obsidian_operate_append')}</Option>
<Option value="2">{i18n.t('chat.topics.export.obsidian_operate_prepend')}</Option>
<Option value="3">{i18n.t('chat.topics.export.obsidian_operate_new_or_overwrite')}</Option>
<Option value={ObsidianProcessingMethod.APPEND}>
{i18n.t('chat.topics.export.obsidian_operate_append')}
</Option>
<Option value={ObsidianProcessingMethod.PREPEND}>
{i18n.t('chat.topics.export.obsidian_operate_prepend')}
</Option>
<Option value={ObsidianProcessingMethod.NEW_OR_OVERWRITE}>
{i18n.t('chat.topics.export.obsidian_operate_new_or_overwrite')}
</Option>
</Select>
</Form.Item>
<Form.Item label={i18n.t('chat.topics.export.obsidian_reasoning')}>
<Switch checked={exportReasoning} onChange={setExportReasoning} />
</Form.Item>
</Form>
</Modal>
)
}
export default ObsidianExportDialog
export { ObsidianProcessingMethod, PopupContainer }

View File

@@ -1,44 +1,38 @@
import ObsidianExportDialog from '@renderer/components/ObsidianExportDialog'
import { createRoot } from 'react-dom/client'
import { ObsidianProcessingMethod, PopupContainer } from '@renderer/components/ObsidianExportDialog'
import { TopView } from '@renderer/components/TopView'
import type { Topic } from '@renderer/types'
import type { Message } from '@renderer/types/newMessage'
interface ObsidianExportOptions {
title: string
markdown: string
processingMethod: string | '3' // 默认新增(存在就覆盖)
processingMethod: (typeof ObsidianProcessingMethod)[keyof typeof ObsidianProcessingMethod]
topic?: Topic
message?: Message
messages?: Message[]
}
/**
* 配置Obsidian 笔记属性弹窗
* @param options.title 标题
* @param options.markdown markdown内容
* @param options.processingMethod 处理方式
* @returns
*/
const showObsidianExportDialog = async (options: ObsidianExportOptions): Promise<boolean> => {
return new Promise<boolean>((resolve) => {
const div = document.createElement('div')
document.body.appendChild(div)
const root = createRoot(div)
const handleClose = (success: boolean) => {
root.unmount()
document.body.removeChild(div)
resolve(success)
}
// 不再从store中获取tag配置
root.render(
<ObsidianExportDialog
title={options.title}
markdown={options.markdown}
obsidianTags=""
processingMethod={options.processingMethod}
open={true}
onClose={handleClose}
/>
)
})
}
export default {
show: showObsidianExportDialog
export default class ObsidianExportPopup {
static hide() {
TopView.hide('ObsidianExportPopup')
}
static show(options: ObsidianExportOptions): Promise<boolean> {
return new Promise((resolve) => {
TopView.show(
<PopupContainer
title={options.title}
processingMethod={options.processingMethod}
topic={options.topic}
message={options.message}
messages={options.messages}
obsidianTags={''}
open={true}
resolve={(v) => {
resolve(v)
ObsidianExportPopup.hide()
}}
/>,
'ObsidianExportPopup'
)
})
}
}

View File

@@ -38,8 +38,9 @@ const PromptPopupContainer: React.FC<Props> = ({
setOpen(false)
}
const onClose = () => {
const onAfterClose = () => {
resolve(null)
TopView.hide(TopViewKey)
}
const handleAfterOpenChange = (visible: boolean) => {
@@ -61,7 +62,7 @@ const PromptPopupContainer: React.FC<Props> = ({
open={open}
onOk={onOk}
onCancel={onCancel}
afterClose={onClose}
afterClose={onAfterClose}
afterOpenChange={handleAfterOpenChange}
transitionName="animation-move-down"
centered>
@@ -95,16 +96,7 @@ export default class PromptPopup {
}
static show(props: PromptPopupShowParams) {
return new Promise<string>((resolve) => {
TopView.show(
<PromptPopupContainer
{...props}
resolve={(v) => {
resolve(v)
TopView.hide(TopViewKey)
}}
/>,
'PromptPopup'
)
TopView.show(<PromptPopupContainer {...props} resolve={resolve} />, 'PromptPopup')
})
}
}

View File

@@ -18,6 +18,7 @@ interface ShowParams {
text: string
textareaProps?: TextAreaProps
modalProps?: ModalProps
showTranslate?: boolean
children?: (props: { onOk?: () => void; onCancel?: () => void }) => React.ReactNode
}
@@ -25,7 +26,14 @@ interface Props extends ShowParams {
resolve: (data: any) => void
}
const PopupContainer: React.FC<Props> = ({ text, textareaProps, modalProps, resolve, children }) => {
const PopupContainer: React.FC<Props> = ({
text,
textareaProps,
modalProps,
resolve,
children,
showTranslate = true
}) => {
const [open, setOpen] = useState(true)
const { t } = useTranslation()
const [textValue, setTextValue] = useState(text)
@@ -148,12 +156,14 @@ const PopupContainer: React.FC<Props> = ({ text, textareaProps, modalProps, reso
onInput={resizeTextArea}
onChange={(e) => setTextValue(e.target.value)}
/>
<TranslateButton
onClick={handleTranslate}
aria-label="Translate text"
disabled={isTranslating || !textValue.trim()}>
{isTranslating ? <LoadingOutlined spin /> : <Languages size={16} />}
</TranslateButton>
{showTranslate && (
<TranslateButton
onClick={handleTranslate}
aria-label="Translate text"
disabled={isTranslating || !textValue.trim()}>
{isTranslating ? <LoadingOutlined spin /> : <Languages size={16} />}
</TranslateButton>
)}
</TextAreaContainer>
<ChildrenContainer>{children && children({ onOk, onCancel })}</ChildrenContainer>
</Modal>

View File

@@ -1,9 +1,8 @@
import { RightOutlined } from '@ant-design/icons'
import { isMac } from '@renderer/config/constant'
import useUserTheme from '@renderer/hooks/useUserTheme'
import { classNames } from '@renderer/utils'
import { Flex } from 'antd'
import { theme } from 'antd'
import Color from 'color'
import { t } from 'i18next'
import { Check } from 'lucide-react'
import React, { use, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
@@ -40,8 +39,7 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
throw new Error('QuickPanel must be used within a QuickPanelProvider')
}
const { token } = theme.useToken()
const colorPrimary = Color(token.colorPrimary || '#008000')
const { colorPrimary } = useUserTheme()
const selectedColor = colorPrimary.alpha(0.15).toString()
const selectedColorHover = colorPrimary.alpha(0.2).toString()
@@ -434,7 +432,8 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
$pageSize={ctx.pageSize}
$selectedColor={selectedColor}
$selectedColorHover={selectedColorHover}
className={ctx.isVisible ? 'visible' : ''}>
className={ctx.isVisible ? 'visible' : ''}
data-testid="quick-panel">
<QuickPanelBody
ref={bodyRef}
onMouseMove={() =>

View File

@@ -3,7 +3,6 @@ import { FC, useCallback, useEffect, useRef, useState } from 'react'
import styled from 'styled-components'
interface Props extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onScroll'> {
right?: boolean
ref?: React.RefObject<HTMLDivElement | null>
onScroll?: () => void // Custom onScroll prop for useScrollPosition's handleScroll
}
@@ -12,38 +11,46 @@ const Scrollbar: FC<Props> = ({ ref: passedRef, children, onScroll: externalOnSc
const [isScrolling, setIsScrolling] = useState(false)
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
const handleScroll = useCallback(() => {
setIsScrolling(true)
const clearScrollingTimeout = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
timeoutRef.current = setTimeout(() => setIsScrolling(false), 1500)
}, [])
const throttledInternalScrollHandler = throttle(handleScroll, 200)
const handleScroll = useCallback(() => {
setIsScrolling(true)
clearScrollingTimeout()
timeoutRef.current = setTimeout(() => {
setIsScrolling(false)
timeoutRef.current = null
}, 1500)
}, [clearScrollingTimeout])
// eslint-disable-next-line react-hooks/exhaustive-deps
const throttledInternalScrollHandler = useCallback(throttle(handleScroll, 100, { leading: true, trailing: true }), [
handleScroll
])
// Combined scroll handler
const combinedOnScroll = useCallback(() => {
// Event is available if needed by internal handler
throttledInternalScrollHandler() // Call internal logic
throttledInternalScrollHandler()
if (externalOnScroll) {
externalOnScroll() // Call external logic (from useScrollPosition)
externalOnScroll()
}
}, [throttledInternalScrollHandler, externalOnScroll])
useEffect(() => {
return () => {
timeoutRef.current && clearTimeout(timeoutRef.current)
clearScrollingTimeout()
throttledInternalScrollHandler.cancel()
}
}, [throttledInternalScrollHandler])
}, [throttledInternalScrollHandler, clearScrollingTimeout])
return (
<Container
{...htmlProps} // Pass other HTML attributes
isScrolling={isScrolling}
$isScrolling={isScrolling}
onScroll={combinedOnScroll} // Use the combined handler
ref={passedRef}>
{children}
@@ -51,15 +58,13 @@ const Scrollbar: FC<Props> = ({ ref: passedRef, children, onScroll: externalOnSc
)
}
const Container = styled.div<{ isScrolling: boolean; right?: boolean }>`
const Container = styled.div<{ $isScrolling: boolean }>`
overflow-y: auto;
&::-webkit-scrollbar-thumb {
transition: background 2s ease;
background: ${(props) =>
props.isScrolling ? `var(--color-scrollbar-thumb${props.right ? '-right' : ''})` : 'transparent'};
background: ${(props) => (props.$isScrolling ? 'var(--color-scrollbar-thumb)' : 'transparent')};
&:hover {
background: ${(props) =>
props.isScrolling ? `var(--color-scrollbar-thumb${props.right ? '-right' : ''}-hover)` : 'transparent'};
background: var(--color-scrollbar-thumb-hover);
}
}
`

View File

@@ -65,7 +65,7 @@ const TopViewContainer: React.FC<Props> = ({ children }) => {
const FullScreenContainer: React.FC<PropsWithChildren> = useCallback(({ children }) => {
return (
<Box flex={1} position="absolute" w="100%" h="100%">
<Box flex={1} position="absolute" w="100%" h="100%" className="topview-fullscreen-container">
<Box position="absolute" w="100%" h="100%" onClick={onPop} />
{children}
</Box>

View File

@@ -14,9 +14,9 @@ interface BackupFile {
interface WebdavConfig {
webdavHost: string
webdavUser: string
webdavPass: string
webdavPath: string
webdavUser?: string
webdavPass?: string
webdavPath?: string
}
interface WebdavBackupManagerProps {
@@ -47,7 +47,7 @@ export function WebdavBackupManager({ visible, onClose, webdavConfig, restoreMet
const { webdavHost, webdavUser, webdavPass, webdavPath } = webdavConfig
const fetchBackupFiles = useCallback(async () => {
if (!webdavHost || !webdavUser || !webdavPass || !webdavPath) {
if (!webdavHost) {
message.error(t('message.error.invalid.webdav'))
return
}
@@ -93,7 +93,7 @@ export function WebdavBackupManager({ visible, onClose, webdavConfig, restoreMet
return
}
if (!webdavHost || !webdavUser || !webdavPass || !webdavPath) {
if (!webdavHost) {
message.error(t('message.error.invalid.webdav'))
return
}
@@ -132,7 +132,7 @@ export function WebdavBackupManager({ visible, onClose, webdavConfig, restoreMet
}
const handleDeleteSingle = async (fileName: string) => {
if (!webdavHost || !webdavUser || !webdavPass || !webdavPath) {
if (!webdavHost) {
message.error(t('message.error.invalid.webdav'))
return
}
@@ -165,7 +165,7 @@ export function WebdavBackupManager({ visible, onClose, webdavConfig, restoreMet
}
const handleRestore = async (fileName: string) => {
if (!webdavHost || !webdavUser || !webdavPass || !webdavPath) {
if (!webdavHost) {
message.error(t('message.error.invalid.webdav'))
return
}

View File

@@ -123,7 +123,7 @@ export function useWebdavRestoreModal({
const [backupFiles, setBackupFiles] = useState<BackupFile[]>([])
const showRestoreModal = useCallback(async () => {
if (!webdavHost || !webdavUser || !webdavPass || !webdavPath) {
if (!webdavHost) {
window.message.error({ content: t('message.error.invalid.webdav'), key: 'webdav-error' })
return
}
@@ -146,7 +146,7 @@ export function useWebdavRestoreModal({
}, [webdavHost, webdavUser, webdavPass, webdavPath, t])
const handleRestore = useCallback(async () => {
if (!selectedFile || !webdavHost || !webdavUser || !webdavPass || !webdavPath) {
if (!selectedFile || !webdavHost) {
window.message.error({
content: !selectedFile ? t('message.error.no.file.selected') : t('message.error.invalid.webdav'),
key: 'restore-error'
@@ -170,7 +170,7 @@ export function useWebdavRestoreModal({
}
}
})
}, [selectedFile, webdavHost, webdavUser, webdavPass, webdavPath, t, restoreMethod])
}, [selectedFile, webdavHost, t, restoreMethod])
const handleCancel = () => {
setIsRestoreModalVisible(false)

View File

@@ -0,0 +1,44 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it } from 'vitest'
import CustomTag from '../CustomTag'
const COLOR = '#ff0000'
describe('CustomTag', () => {
it('should render children text', () => {
render(<CustomTag color={COLOR}>content</CustomTag>)
expect(screen.getByText('content')).toBeInTheDocument()
})
it('should render icon if provided', () => {
render(
<CustomTag color={COLOR} icon={<span data-testid="icon">cherry</span>}>
content
</CustomTag>
)
expect(screen.getByTestId('icon')).toBeInTheDocument()
expect(screen.getByText('content')).toBeInTheDocument()
})
it('should show tooltip if tooltip prop is set', async () => {
render(
<CustomTag color={COLOR} tooltip="reasoning model">
reasoning
</CustomTag>
)
// 鼠标悬停触发 Tooltip
await userEvent.hover(screen.getByText('reasoning'))
expect(await screen.findByText('reasoning model')).toBeInTheDocument()
})
it('should not render Tooltip when tooltip is not set', () => {
render(<CustomTag color="#ff0000">no tooltip</CustomTag>)
expect(screen.getByText('no tooltip')).toBeInTheDocument()
// 不应有 tooltip 相关内容
expect(document.querySelector('.ant-tooltip')).toBeNull()
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,282 @@
/// <reference types="@vitest/browser/context" />
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import DragableList from '../DragableList'
// mock @hello-pangea/dnd 组件
vi.mock('@hello-pangea/dnd', () => {
return {
__esModule: true,
DragDropContext: ({ children, onDragEnd }: any) => {
// 挂载到 window 以便测试用例直接调用
window.triggerOnDragEnd = (result = { source: { index: 0 }, destination: { index: 1 } }, provided = {}) => {
onDragEnd && onDragEnd(result, provided)
}
return <div data-testid="drag-drop-context">{children}</div>
},
Droppable: ({ children }: any) => (
<div data-testid="droppable">
{children({ droppableProps: {}, innerRef: () => {}, placeholder: <div data-testid="placeholder" /> })}
</div>
),
Draggable: ({ children, draggableId, index }: any) => (
<div data-testid={`draggable-${draggableId}-${index}`}>
{children({ draggableProps: {}, dragHandleProps: {}, innerRef: () => {} })}
</div>
)
}
})
// mock VirtualList 只做简单渲染
vi.mock('rc-virtual-list', () => ({
__esModule: true,
default: ({ data, itemKey, children }: any) => (
<div data-testid="virtual-list">
{data.map((item: any, idx: number) => (
<div key={item[itemKey] || item} data-testid="virtual-list-item">
{children(item, idx)}
</div>
))}
</div>
)
}))
declare global {
interface Window {
triggerOnDragEnd: (result?: any, provided?: any) => void
}
}
describe('DragableList', () => {
describe('rendering', () => {
it('should render all list items', () => {
const list = [
{ id: 'a', name: 'A' },
{ id: 'b', name: 'B' },
{ id: 'c', name: 'C' }
]
render(
<DragableList list={list} onUpdate={() => {}}>
{(item) => <div data-testid="item">{item.name}</div>}
</DragableList>
)
const items = screen.getAllByTestId('item')
expect(items.length).toBe(3)
expect(items[0].textContent).toBe('A')
expect(items[1].textContent).toBe('B')
expect(items[2].textContent).toBe('C')
})
it('should render with custom style and listStyle', () => {
const list = [{ id: 'a', name: 'A' }]
const style = { background: 'red' }
const listStyle = { color: 'blue' }
render(
<DragableList list={list} style={style} listStyle={listStyle} onUpdate={() => {}}>
{(item) => <div data-testid="item">{item.name}</div>}
</DragableList>
)
// 检查 style 是否传递到外层容器
const virtualList = screen.getByTestId('virtual-list')
expect(virtualList.parentElement).toHaveStyle({ background: 'red' })
})
it('should render nothing when list is empty', () => {
render(
<DragableList list={[]} onUpdate={() => {}}>
{(item) => <div data-testid="item">{item.name}</div>}
</DragableList>
)
// 虚拟列表存在但无内容
const items = screen.queryAllByTestId('item')
expect(items.length).toBe(0)
})
})
describe('drag and drop', () => {
it('should call onUpdate with new order after drag end', () => {
const list = [
{ id: 'a', name: 'A' },
{ id: 'b', name: 'B' },
{ id: 'c', name: 'C' }
]
const newOrder = [list[1], list[2], list[0]]
const onUpdate = vi.fn()
render(
<DragableList list={list} onUpdate={onUpdate}>
{(item) => <div data-testid="item">{item.name}</div>}
</DragableList>
)
// 直接调用 window.triggerOnDragEnd 模拟拖拽结束
window.triggerOnDragEnd({ source: { index: 0 }, destination: { index: 2 } }, {})
expect(onUpdate).toHaveBeenCalledWith(newOrder)
expect(onUpdate).toHaveBeenCalledTimes(1)
})
it('should call onDragStart and onDragEnd', () => {
const list = [
{ id: 'a', name: 'A' },
{ id: 'b', name: 'B' },
{ id: 'c', name: 'C' }
]
const onDragStart = vi.fn()
const onDragEnd = vi.fn()
render(
<DragableList list={list} onUpdate={() => {}} onDragStart={onDragStart} onDragEnd={onDragEnd}>
{(item) => <div data-testid="item">{item.name}</div>}
</DragableList>
)
// 先手动调用 onDragStart
onDragStart()
// 再模拟拖拽结束
window.triggerOnDragEnd({ source: { index: 0 }, destination: { index: 1 } }, {})
expect(onDragStart).toHaveBeenCalledTimes(1)
expect(onDragEnd).toHaveBeenCalledTimes(1)
})
it('should not call onUpdate if dropped at same position', () => {
const list = [
{ id: 'a', name: 'A' },
{ id: 'b', name: 'B' },
{ id: 'c', name: 'C' }
]
const onUpdate = vi.fn()
render(
<DragableList list={list} onUpdate={onUpdate}>
{(item) => <div data-testid="item">{item.name}</div>}
</DragableList>
)
// 模拟拖拽到自身
window.triggerOnDragEnd({ source: { index: 1 }, destination: { index: 1 } }, {})
expect(onUpdate).toHaveBeenCalledTimes(1)
expect(onUpdate.mock.calls[0][0]).toEqual(list)
})
})
describe('edge cases', () => {
it('should work with single item', () => {
const list = [{ id: 'a', name: 'A' }]
const onUpdate = vi.fn()
render(
<DragableList list={list} onUpdate={onUpdate}>
{(item) => <div data-testid="item">{item.name}</div>}
</DragableList>
)
// 拖拽自身
window.triggerOnDragEnd({ source: { index: 0 }, destination: { index: 0 } }, {})
expect(onUpdate).toHaveBeenCalledTimes(1)
expect(onUpdate.mock.calls[0][0]).toEqual(list)
})
it('should not crash if callbacks are undefined', () => {
const list = [
{ id: 'a', name: 'A' },
{ id: 'b', name: 'B' }
]
// 不传 onDragStart/onDragEnd
expect(() => {
render(
<DragableList list={list} onUpdate={() => {}}>
{(item) => <div data-testid="item">{item.name}</div>}
</DragableList>
)
window.triggerOnDragEnd({ source: { index: 0 }, destination: { index: 1 } }, {})
}).not.toThrow()
})
it('should handle items without id', () => {
const list = ['A', 'B', 'C']
const onUpdate = vi.fn()
render(
<DragableList list={list} onUpdate={onUpdate}>
{(item) => <div data-testid="item">{item}</div>}
</DragableList>
)
// 拖拽第0项到第2项
window.triggerOnDragEnd({ source: { index: 0 }, destination: { index: 2 } }, {})
expect(onUpdate).toHaveBeenCalledTimes(1)
expect(onUpdate.mock.calls[0][0]).toEqual(['B', 'C', 'A'])
})
})
describe('interaction', () => {
it('should show placeholder during drag', () => {
const list = [
{ id: 'a', name: 'A' },
{ id: 'b', name: 'B' },
{ id: 'c', name: 'C' }
]
render(
<DragableList list={list} onUpdate={() => {}}>
{(item) => <div data-testid="item">{item.name}</div>}
</DragableList>
)
// placeholder 应该在初始渲染时就存在
const placeholder = screen.getByTestId('placeholder')
expect(placeholder).toBeInTheDocument()
})
it('should reorder correctly when dragged to first/last', () => {
const list = [
{ id: 'a', name: 'A' },
{ id: 'b', name: 'B' },
{ id: 'c', name: 'C' }
]
const onUpdate = vi.fn()
render(
<DragableList list={list} onUpdate={onUpdate}>
{(item) => <div data-testid="item">{item.name}</div>}
</DragableList>
)
// 拖拽第2项到第0项
window.triggerOnDragEnd({ source: { index: 2 }, destination: { index: 0 } }, {})
expect(onUpdate).toHaveBeenCalledWith([
{ id: 'c', name: 'C' },
{ id: 'a', name: 'A' },
{ id: 'b', name: 'B' }
])
// 拖拽第0项到第2项
onUpdate.mockClear()
window.triggerOnDragEnd({ source: { index: 0 }, destination: { index: 2 } }, {})
expect(onUpdate).toHaveBeenCalledWith([
{ id: 'b', name: 'B' },
{ id: 'c', name: 'C' },
{ id: 'a', name: 'A' }
])
})
})
describe('snapshot', () => {
it('should match snapshot', () => {
const list = [
{ id: 'a', name: 'A' },
{ id: 'b', name: 'B' },
{ id: 'c', name: 'C' }
]
const { container } = render(
<DragableList list={list} onUpdate={() => {}}>
{(item) => <div data-testid="item">{item.name}</div>}
</DragableList>
)
expect(container).toMatchSnapshot()
})
})
})

View File

@@ -0,0 +1,33 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import ExpandableText from '../ExpandableText'
// mock i18n
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (k: string) => k })
}))
describe('ExpandableText', () => {
const TEXT = 'This is a long text for testing.'
it('should render text and expand button', () => {
render(<ExpandableText text={TEXT} />)
expect(screen.getByText(TEXT)).toBeInTheDocument()
expect(screen.getByRole('button')).toHaveTextContent('common.expand')
})
it('should toggle expand/collapse when button is clicked', async () => {
render(<ExpandableText text={TEXT} />)
const button = screen.getByRole('button')
// 初始为收起状态
expect(button).toHaveTextContent('common.expand')
// 点击展开
await userEvent.click(button)
expect(button).toHaveTextContent('common.collapse')
// 再次点击收起
await userEvent.click(button)
expect(button).toHaveTextContent('common.expand')
})
})

View File

@@ -0,0 +1,211 @@
import { configureStore } from '@reduxjs/toolkit'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useEffect } from 'react'
import { Provider } from 'react-redux'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { QuickPanelListItem, QuickPanelProvider, QuickPanelView, useQuickPanel } from '../QuickPanel'
// Mock Redux store
const mockStore = configureStore({
reducer: {
settings: (state = { userTheme: { colorPrimary: '#1677ff' } }) => state
}
})
function createList(length: number, prefix = 'Item', extra: Partial<QuickPanelListItem> = {}) {
return Array.from({ length }, (_, i) => ({
label: `${prefix} ${i + 1}`,
description: `${prefix} Description ${i + 1}`,
icon: `${prefix} Icon ${i + 1}`,
action: () => {},
...extra
}))
}
type KeyStep = {
key: string
ctrlKey?: boolean
expected: string | ((text: string) => boolean)
}
const PAGE_SIZE = 7
// 用于测试 open 行为的组件
function OpenPanelOnMount({ list }: { list: QuickPanelListItem[] }) {
const quickPanel = useQuickPanel()
useEffect(() => {
quickPanel.open({
title: 'Test Panel',
list,
symbol: 'test',
pageSize: PAGE_SIZE
})
}, [list, quickPanel])
return null
}
function wrapWithProviders(children: React.ReactNode) {
return (
<Provider store={mockStore}>
<QuickPanelProvider>{children}</QuickPanelProvider>
</Provider>
)
}
describe('QuickPanelView', () => {
beforeEach(() => {
// 添加一个假的 .inputbar textarea 到 document.body
const inputbar = document.createElement('div')
inputbar.className = 'inputbar'
const textarea = document.createElement('textarea')
inputbar.appendChild(textarea)
document.body.appendChild(inputbar)
})
afterEach(() => {
const inputbar = document.querySelector('.inputbar')
if (inputbar) inputbar.remove()
})
describe('rendering', () => {
it('should render without crashing when wrapped in QuickPanelProvider', () => {
render(wrapWithProviders(<QuickPanelView setInputText={vi.fn()} />))
// 检查面板容器是否存在且初始不可见
const panel = screen.getByTestId('quick-panel')
expect(panel.classList.contains('visible')).toBe(false)
})
it('should render list after open', async () => {
const list = createList(100)
render(
wrapWithProviders(
<>
<QuickPanelView setInputText={vi.fn()} />
<OpenPanelOnMount list={list} />
</>
)
)
// 检查面板可见
const panel = screen.getByTestId('quick-panel')
expect(panel.classList.contains('visible')).toBe(true)
// 检查第一个 item 是否渲染
expect(screen.getByText('Item 1')).toBeInTheDocument()
})
})
describe('focusing', () => {
// 执行一系列按键,检查 focused item 是否正确
async function runKeySequenceAndCheck(panel: HTMLElement, sequence: KeyStep[]) {
const user = userEvent.setup()
for (const { key, ctrlKey, expected } of sequence) {
let keyString = ''
if (ctrlKey) keyString += '{Control>}'
keyString += key.length === 1 ? key : `{${key}}`
if (ctrlKey) keyString += '{/Control}'
await user.keyboard(keyString)
// 检查是否只有一个 focused item
const focused = panel.querySelectorAll('.focused')
expect(focused.length).toBe(1)
// 检查 focused item 是否包含预期文本
const text = focused[0].textContent || ''
if (typeof expected === 'string') {
expect(text).toContain(expected)
} else {
expect(expected(text)).toBe(true)
}
}
}
it('should focus on the first item after panel open', () => {
const list = createList(100)
render(
wrapWithProviders(
<>
<QuickPanelView setInputText={vi.fn()} />
<OpenPanelOnMount list={list} />
</>
)
)
// 检查第一个 item 是否有 focused
const item1 = screen.getByText('Item 1')
const focused = item1.closest('.focused')
expect(focused).not.toBeNull()
expect(item1).toBeInTheDocument()
})
it('should focus on the right item using ArrowUp, ArrowDown', async () => {
const list = createList(100, 'Item')
render(
wrapWithProviders(
<>
<QuickPanelView setInputText={vi.fn()} />
<OpenPanelOnMount list={list} />
</>
)
)
const keySequence = [
{ key: 'ArrowUp', expected: 'Item 100' },
{ key: 'ArrowUp', expected: 'Item 99' },
{ key: 'ArrowDown', expected: 'Item 100' },
{ key: 'ArrowDown', expected: 'Item 1' }
]
await runKeySequenceAndCheck(screen.getByTestId('quick-panel'), keySequence)
})
it('should focus on the right item using PageUp, PageDown', async () => {
const list = createList(100, 'Item')
render(
wrapWithProviders(
<>
<QuickPanelView setInputText={vi.fn()} />
<OpenPanelOnMount list={list} />
</>
)
)
const keySequence = [
{ key: 'PageUp', expected: 'Item 1' }, // 停留在顶部
{ key: 'ArrowUp', expected: 'Item 100' },
{ key: 'PageDown', expected: 'Item 100' }, // 停留在底部
{ key: 'PageUp', expected: `Item ${100 - PAGE_SIZE}` },
{ key: 'PageDown', expected: 'Item 100' }
]
await runKeySequenceAndCheck(screen.getByTestId('quick-panel'), keySequence)
})
it('should focus on the right item using Ctrl+ArrowUp, Ctrl+ArrowDown', async () => {
const list = createList(100, 'Item')
render(
wrapWithProviders(
<>
<QuickPanelView setInputText={vi.fn()} />
<OpenPanelOnMount list={list} />
</>
)
)
const keySequence = [
{ key: 'ArrowDown', ctrlKey: true, expected: `Item ${PAGE_SIZE + 1}` },
{ key: 'ArrowUp', ctrlKey: true, expected: 'Item 1' },
{ key: 'ArrowUp', ctrlKey: true, expected: 'Item 100' },
{ key: 'ArrowDown', ctrlKey: true, expected: 'Item 1' }
]
await runKeySequenceAndCheck(screen.getByTestId('quick-panel'), keySequence)
})
})
})

View File

@@ -0,0 +1,176 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { act } from 'react'
import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
import Scrollbar from '../Scrollbar'
// Mock lodash throttle
vi.mock('lodash', async () => {
const actual = await import('lodash')
return {
...actual,
throttle: vi.fn((fn) => {
// 简单地直接返回函数,不实际执行节流
const throttled = (...args: any[]) => fn(...args)
throttled.cancel = vi.fn()
return throttled
})
}
})
describe('Scrollbar', () => {
beforeEach(() => {
// 使用 fake timers
vi.useFakeTimers()
})
afterEach(() => {
// 恢复真实的 timers
vi.restoreAllMocks()
vi.useRealTimers()
})
describe('rendering', () => {
it('should render children correctly', () => {
render(
<Scrollbar data-testid="scrollbar">
<div data-testid="child"></div>
</Scrollbar>
)
const child = screen.getByTestId('child')
expect(child).toBeDefined()
expect(child.textContent).toBe('测试内容')
})
it('should pass custom props to container', () => {
render(
<Scrollbar data-testid="scrollbar" className="custom-class">
</Scrollbar>
)
const scrollbar = screen.getByTestId('scrollbar')
expect(scrollbar.className).toContain('custom-class')
})
it('should match default styled snapshot', () => {
const { container } = render(<Scrollbar data-testid="scrollbar"></Scrollbar>)
expect(container.firstChild).toMatchSnapshot()
})
})
describe('scrolling behavior', () => {
it('should update isScrolling state when scrolled', () => {
render(<Scrollbar data-testid="scrollbar"></Scrollbar>)
const scrollbar = screen.getByTestId('scrollbar')
// 初始状态下应该不是滚动状态
expect(scrollbar.getAttribute('isScrolling')).toBeFalsy()
// 触发滚动
fireEvent.scroll(scrollbar)
// 由于 isScrolling 是组件内部状态,不直接反映在 DOM 属性上
// 但可以检查模拟的事件处理是否被调用
expect(scrollbar).toBeDefined()
})
it('should reset isScrolling after timeout', () => {
render(<Scrollbar data-testid="scrollbar"></Scrollbar>)
const scrollbar = screen.getByTestId('scrollbar')
// 触发滚动
fireEvent.scroll(scrollbar)
// 前进时间但不超过timeout
act(() => {
vi.advanceTimersByTime(1000)
})
// 前进超过timeout
act(() => {
vi.advanceTimersByTime(600)
})
// 不测试样式,这里只检查组件是否存在
expect(scrollbar).toBeDefined()
})
it('should reset timeout on continuous scrolling', () => {
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout')
render(<Scrollbar data-testid="scrollbar"></Scrollbar>)
const scrollbar = screen.getByTestId('scrollbar')
// 第一次滚动
fireEvent.scroll(scrollbar)
// 前进一部分时间
act(() => {
vi.advanceTimersByTime(800)
})
// 再次滚动
fireEvent.scroll(scrollbar)
// clearTimeout 应该被调用,因为在第二次滚动时会清除之前的定时器
expect(clearTimeoutSpy).toHaveBeenCalled()
})
})
describe('throttling', () => {
it('should use throttled scroll handler', async () => {
const { throttle } = await import('lodash')
render(<Scrollbar data-testid="scrollbar"></Scrollbar>)
// 验证 throttle 被调用
expect(throttle).toHaveBeenCalled()
// 验证 throttle 调用时使用了 100ms 延迟和正确的选项
expect(throttle).toHaveBeenCalledWith(expect.any(Function), 100, { leading: true, trailing: true })
})
})
describe('cleanup', () => {
it('should clear timeout and cancel throttle on unmount', async () => {
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout')
const { unmount } = render(<Scrollbar data-testid="scrollbar"></Scrollbar>)
const scrollbar = screen.getByTestId('scrollbar')
// 触发滚动设置定时器
fireEvent.scroll(scrollbar)
// 卸载组件
unmount()
// 验证 clearTimeout 被调用
expect(clearTimeoutSpy).toHaveBeenCalled()
// 验证 throttle.cancel 被调用
const { throttle } = await import('lodash')
const throttledFunction = (throttle as unknown as Mock).mock.results[0].value
expect(throttledFunction.cancel).toHaveBeenCalled()
})
})
describe('props handling', () => {
it('should handle ref forwarding', () => {
const ref = { current: null }
render(
<Scrollbar data-testid="scrollbar" ref={ref}>
</Scrollbar>
)
// 验证 ref 被正确设置
expect(ref.current).not.toBeNull()
})
})
})

View File

@@ -0,0 +1,74 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`DragableList > snapshot > should match snapshot 1`] = `
<div>
<div
data-testid="drag-drop-context"
>
<div
data-testid="droppable"
>
<div>
<div
data-testid="virtual-list"
>
<div
data-testid="virtual-list-item"
>
<div
data-testid="draggable-a-0"
>
<div
style="margin-bottom: 8px;"
>
<div
data-testid="item"
>
A
</div>
</div>
</div>
</div>
<div
data-testid="virtual-list-item"
>
<div
data-testid="draggable-b-1"
>
<div
style="margin-bottom: 8px;"
>
<div
data-testid="item"
>
B
</div>
</div>
</div>
</div>
<div
data-testid="virtual-list-item"
>
<div
data-testid="draggable-c-2"
>
<div
style="margin-bottom: 8px;"
>
<div
data-testid="item"
>
C
</div>
</div>
</div>
</div>
</div>
<div
data-testid="placeholder"
/>
</div>
</div>
</div>
</div>
`;

View File

@@ -0,0 +1,23 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Scrollbar > rendering > should match default styled snapshot 1`] = `
.c0 {
overflow-y: auto;
}
.c0::-webkit-scrollbar-thumb {
transition: background 2s ease;
background: transparent;
}
.c0::-webkit-scrollbar-thumb:hover {
background: var(--color-scrollbar-thumb-hover);
}
<div
class="c0"
data-testid="scrollbar"
>
内容
</div>
`;

View File

@@ -0,0 +1,90 @@
import { isMac } from '@renderer/config/constant'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { Tooltip } from 'antd'
import { t } from 'i18next'
import { MessageSquareDiff, Search } from 'lucide-react'
import { FC } from 'react'
import styled from 'styled-components'
import SearchPopup from '../Popups/SearchPopup'
interface Props {}
const HeaderNavbar: FC<Props> = () => {
return (
<Container>
<div>
{!isMac && (
<Tooltip title={t('chat.assistant.search.placeholder')} mouseEnterDelay={0.8}>
<NarrowIcon onClick={() => SearchPopup.show()}>
<Search size={18} />
</NarrowIcon>
</Tooltip>
)}
</div>
<Tooltip title={t('settings.shortcuts.new_topic')} mouseEnterDelay={0.8}>
<NavbarIcon onClick={() => EventEmitter.emit(EVENT_NAMES.ADD_NEW_TOPIC)} style={{ marginRight: 5 }}>
<MessageSquareDiff size={18} />
</NavbarIcon>
</Tooltip>
</Container>
)
}
const Container = styled.div`
display: flex;
width: var(--assistant-width);
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 0;
padding-left: var(--sidebar-width);
height: var(--navbar-height);
min-height: var(--navbar-height);
background-color: transparent;
-webkit-app-region: drag;
padding-left: ${isMac ? '75px' : '0'};
`
export const NavbarIcon = styled.div`
-webkit-app-region: none;
border-radius: 8px;
height: 30px;
padding: 0 7px;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
transition: all 0.2s ease-in-out;
-webkit-app-region: no-drag;
cursor: pointer;
.iconfont {
font-size: 18px;
color: var(--color-icon);
&.icon-a-addchat {
font-size: 20px;
}
&.icon-a-darkmode {
font-size: 20px;
}
&.icon-appstore {
font-size: 20px;
}
}
.anticon {
color: var(--color-icon);
font-size: 16px;
}
&:hover {
background-color: var(--color-background-mute);
color: var(--color-icon-white);
}
`
const NarrowIcon = styled(NavbarIcon)`
@media (max-width: 1000px) {
display: none;
}
`
export default HeaderNavbar

View File

@@ -0,0 +1,379 @@
import EmojiAvatar from '@renderer/components/Avatar/EmojiAvatar'
import { UserAvatar } from '@renderer/config/env'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useAssistants } from '@renderer/hooks/useAssistant'
import useAvatar from '@renderer/hooks/useAvatar'
import { useChat } from '@renderer/hooks/useChat'
import { useSettings } from '@renderer/hooks/useSettings'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import NavigationService from '@renderer/services/NavigationService'
import { ThemeMode } from '@renderer/types'
import { isEmoji } from '@renderer/utils'
import { Avatar, Tooltip } from 'antd'
import {
Blocks,
Bot,
ChevronDown,
ChevronRight,
Compass,
FileSearch,
Folder,
Languages,
MessageSquare,
Moon,
Palette,
SquareTerminal,
Sun,
SunMoon
} from 'lucide-react'
import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useLocation, useNavigate } from 'react-router-dom'
import styled from 'styled-components'
import Tabs from '../../pages/home/Tabs'
import MainNavbar from './MainNavbar'
type Tab = 'assistants' | 'topic' | 'settings'
const MainSidebar: FC = () => {
const { assistants } = useAssistants()
const navigate = useNavigate()
const [tab, setTab] = useState<Tab>('assistants')
const avatar = useAvatar()
const { userName, defaultPaintingProvider } = useSettings()
const { t } = useTranslation()
const { theme, settedTheme, toggleTheme } = useTheme()
const [isAppMenuExpanded, setIsAppMenuExpanded] = useState(false)
const location = useLocation()
const { pathname } = location
const { activeAssistant, activeTopic, setActiveAssistant, setActiveTopic } = useChat()
const { showAssistants, showTopics, topicPosition } = useSettings()
useEffect(() => {
NavigationService.setNavigate(navigate)
}, [navigate])
useEffect(() => {
const unsubscribe = EventEmitter.on(EVENT_NAMES.SHOW_TOPIC_SIDEBAR, () => setTab('topic'))
return () => unsubscribe()
}, [])
useEffect(() => {
const unsubscribe = EventEmitter.on(EVENT_NAMES.SWITCH_ASSISTANT, (assistantId: string) => {
const newAssistant = assistants.find((a) => a.id === assistantId)
if (newAssistant) {
setActiveAssistant(newAssistant)
}
})
return () => {
unsubscribe()
}
}, [assistants, setActiveAssistant])
useEffect(() => {
const canMinimize = !showAssistants && !showTopics
window.api.window.setMinimumSize(canMinimize ? 520 : 1080, 600)
return () => {
window.api.window.resetMinimumSize()
}
}, [showAssistants, showTopics, topicPosition])
useEffect(() => {
setIsAppMenuExpanded(false)
}, [activeAssistant.id, activeTopic.id])
const onAvatarClick = () => {
navigate('/settings/provider')
}
const onChageTheme = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation()
toggleTheme()
}
const appMenuItems = [
// { icon: <Sparkle size={18} className="icon" />, text: t('agents.title'), path: '/agents' },
{ icon: <Compass size={18} className="icon" />, text: t('discover.title'), path: '/discover' },
{ icon: <Languages size={18} className="icon" />, text: t('translate.title'), path: '/translate' },
{
icon: <Palette size={18} className="icon" />,
text: t('paintings.title'),
path: `/paintings/${defaultPaintingProvider}`
},
// { icon: <LayoutGrid size={18} className="icon" />, text: t('minapp.title'), path: '/apps' },
{ icon: <FileSearch size={18} className="icon" />, text: t('knowledge.title'), path: '/knowledge' },
{ icon: <SquareTerminal size={18} className="icon" />, text: t('common.mcp'), path: '/mcp-servers' },
{ icon: <Folder size={18} className="icon" />, text: t('files.title'), path: '/files' }
]
const isRoutes = (path: string): boolean => pathname.startsWith(path)
const onChageTab = (tab: Tab) => {
setTab(tab)
setIsAppMenuExpanded(false)
}
if (!showAssistants) {
return null
}
if (location.pathname !== '/') {
return null
}
return (
<Container id="main-sidebar">
<MainNavbar />
<MainMenu>
<MainMenuItem
active={tab === 'assistants' && location.pathname === '/'}
onClick={() => onChageTab('assistants')}>
<MainMenuItemLeft>
<MainMenuItemIcon>
<Bot size={18} />
</MainMenuItemIcon>
<MainMenuItemText>{t('assistants.title')}</MainMenuItemText>
</MainMenuItemLeft>
{tab === 'topic' && (
<MainMenuItemRight>
<MainMenuItemRightText>{activeAssistant.name}</MainMenuItemRightText>
</MainMenuItemRight>
)}
</MainMenuItem>
<MainMenuItem active={tab === 'topic' && location.pathname === '/'} onClick={() => onChageTab('topic')}>
<MainMenuItemLeft>
<MainMenuItemIcon>
<MessageSquare size={18} />
</MainMenuItemIcon>
<MainMenuItemText>{t('common.topics')}</MainMenuItemText>
</MainMenuItemLeft>
</MainMenuItem>
<MainMenuItem
style={{ opacity: isAppMenuExpanded ? 0.5 : 1 }}
onClick={() => setIsAppMenuExpanded(!isAppMenuExpanded)}>
<MainMenuItemLeft>
<MainMenuItemIcon>
<Blocks size={19} className="icon" />
</MainMenuItemIcon>
<MainMenuItemText>{t('common.apps')}</MainMenuItemText>
</MainMenuItemLeft>
<MainMenuItemRight>
{isAppMenuExpanded ? (
<ChevronDown size={18} color="var(--color-text-3)" />
) : (
<ChevronRight size={18} color="var(--color-text-3)" />
)}
</MainMenuItemRight>
</MainMenuItem>
{isAppMenuExpanded && (
<SubMenu>
{appMenuItems.map((item) => (
<MainMenuItem key={item.path} active={isRoutes(item.path)} onClick={() => navigate(item.path)}>
<MainMenuItemLeft>
<MainMenuItemIcon>{item.icon}</MainMenuItemIcon>
<MainMenuItemText>{item.text}</MainMenuItemText>
</MainMenuItemLeft>
</MainMenuItem>
))}
</SubMenu>
)}
</MainMenu>
<Tabs
tab={tab}
activeAssistant={activeAssistant}
activeTopic={activeTopic}
setActiveAssistant={setActiveAssistant}
setActiveTopic={setActiveTopic}
/>
<UserMenu onClick={onAvatarClick}>
<UserMenuLeft>
{isEmoji(avatar) ? (
<EmojiAvatar className="sidebar-avatar" size={31} fontSize={18}>
{avatar}
</EmojiAvatar>
) : (
<AvatarImg src={avatar || UserAvatar} draggable={false} className="nodrag" />
)}
<UserMenuText>{userName}</UserMenuText>
</UserMenuLeft>
<Tooltip
title={t('settings.theme.title') + ': ' + t(`settings.theme.${settedTheme}`)}
mouseEnterDelay={0.8}
placement="right">
<Icon theme={theme} onClick={onChageTheme}>
{settedTheme === ThemeMode.dark ? (
<Moon size={18} className="icon" />
) : settedTheme === ThemeMode.light ? (
<Sun size={18} className="icon" />
) : (
<SunMoon size={18} className="icon" />
)}
</Icon>
</Tooltip>
</UserMenu>
</Container>
)
}
const Container = styled.div`
display: flex;
flex-direction: column;
width: var(--assistant-width);
max-width: var(--assistant-width);
border-right: 0.5px solid var(--color-border);
`
const MainMenu = styled.div`
display: flex;
flex-direction: column;
padding: 10px;
padding-top: 0;
gap: 8px;
`
const MainMenuItem = styled.div<{ active?: boolean }>`
display: flex;
justify-content: space-between;
align-items: center;
gap: 5px;
background-color: ${({ active }) => (active ? 'var(--color-list-item)' : 'transparent')};
padding: 5px 10px;
border-radius: 5px;
border-radius: 8px;
&:hover {
background-color: ${({ active }) => (active ? 'var(--color-list-item)' : 'var(--color-list-item-hover)')};
}
`
const MainMenuItemLeft = styled.div`
display: flex;
align-items: center;
gap: 5px;
`
const MainMenuItemRight = styled.div`
display: flex;
align-items: center;
gap: 5px;
`
const MainMenuItemRightText = styled.div`
font-size: 12px;
color: var(--color-text-3);
`
const MainMenuItemIcon = styled.div`
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
`
const MainMenuItemText = styled.div`
font-size: 14px;
font-weight: 500;
`
const UserMenu = styled.div`
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
margin: 10px;
margin-bottom: 10px;
padding: 4px 8px;
gap: 5px;
border-radius: 8px;
&:hover {
background-color: var(--color-list-item);
}
`
const UserMenuLeft = styled.div`
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
`
const AvatarImg = styled(Avatar)`
width: 28px;
height: 28px;
background-color: var(--color-background-soft);
border: none;
cursor: pointer;
`
const UserMenuText = styled.div`
font-size: 14px;
font-weight: 500;
`
const Icon = styled.div<{ theme: string }>`
width: 30px;
height: 30px;
display: flex;
justify-content: center;
align-items: center;
border-radius: 50%;
box-sizing: border-box;
-webkit-app-region: none;
border: 0.5px solid transparent;
&:hover {
background-color: ${({ theme }) => (theme === 'dark' ? 'var(--color-black)' : 'var(--color-white)')};
opacity: 0.8;
cursor: pointer;
.icon {
color: var(--color-icon-white);
}
}
&.active {
background-color: ${({ theme }) => (theme === 'dark' ? 'var(--color-black)' : 'var(--color-white)')};
border: 0.5px solid var(--color-border);
.icon {
color: var(--color-primary);
}
}
@keyframes borderBreath {
0% {
opacity: 0.1;
}
50% {
opacity: 1;
}
100% {
opacity: 0.1;
}
}
&.opened-minapp {
position: relative;
}
&.opened-minapp::after {
content: '';
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
border-radius: inherit;
opacity: 0.3;
border: 0.5px solid var(--color-primary);
}
`
const SubMenu = styled.div`
display: flex;
flex-direction: column;
gap: 5px;
`
export default MainSidebar

View File

@@ -1,24 +1,16 @@
import { isLinux, isMac, isWindows } from '@renderer/config/constant'
import { useFullscreen } from '@renderer/hooks/useFullscreen'
import useNavBackgroundColor from '@renderer/hooks/useNavBackgroundColor'
import { Button } from 'antd'
import { ChevronDown, X } from 'lucide-react'
import type { FC, PropsWithChildren } from 'react'
import type { HTMLAttributes } from 'react'
import { useNavigate } from 'react-router-dom'
import styled from 'styled-components'
type Props = PropsWithChildren & HTMLAttributes<HTMLDivElement>
export const Navbar: FC<Props> = ({ children, ...props }) => {
const backgroundColor = useNavBackgroundColor()
return (
<NavbarContainer {...props} style={{ backgroundColor }}>
{children}
</NavbarContainer>
)
}
export const NavbarLeft: FC<Props> = ({ children, ...props }) => {
return <NavbarLeftContainer {...props}>{children}</NavbarLeftContainer>
return <NavbarContainer {...props}>{children}</NavbarContainer>
}
export const NavbarCenter: FC<Props> = ({ children, ...props }) => {
@@ -34,34 +26,56 @@ export const NavbarRight: FC<Props> = ({ children, ...props }) => {
)
}
export const NavbarMain: FC<Props> = ({ children, ...props }) => {
const isFullscreen = useFullscreen()
return (
<NavbarMainContainer {...props} $isFullscreen={isFullscreen}>
<CloseIconWindows />
{children}
<CloseIconMac />
</NavbarMainContainer>
)
}
const CloseIconMac = () => {
const navigate = useNavigate()
if (!isMac) {
return null
}
return <Button type="text" icon={<X size={18} />} onClick={() => navigate('/')} className="nodrag" />
}
const CloseIconWindows = () => {
const navigate = useNavigate()
if (isMac) {
return null
}
return (
<Button
size="small"
type="default"
shape="circle"
icon={<ChevronDown size={16} />}
onClick={() => navigate('/')}
className="nodrag"
style={{ marginRight: 5 }}
/>
)
}
const NavbarContainer = styled.div`
min-width: 100%;
display: flex;
flex-direction: row;
min-height: var(--navbar-height);
max-height: var(--navbar-height);
margin-left: ${isMac ? 'calc(var(--sidebar-width) * -1)' : 0};
padding-left: ${isMac ? 'var(--sidebar-width)' : 0};
-webkit-app-region: drag;
`
const NavbarLeftContainer = styled.div`
min-width: var(--assistants-width);
padding: 0 10px;
display: flex;
flex-direction: row;
align-items: center;
font-weight: bold;
color: var(--color-text-1);
`
const NavbarCenterContainer = styled.div`
flex: 1;
display: flex;
align-items: center;
padding: 0 ${isMac ? '20px' : 0};
font-weight: bold;
color: var(--color-text-1);
background-color: var(--color-background);
`
const NavbarRightContainer = styled.div<{ $isFullscreen: boolean }>`
@@ -72,3 +86,32 @@ const NavbarRightContainer = styled.div<{ $isFullscreen: boolean }>`
padding-right: ${({ $isFullscreen }) => ($isFullscreen ? '12px' : isWindows ? '140px' : isLinux ? '120px' : '12px')};
justify-content: flex-end;
`
const NavbarMainContainer = styled.div<{ $isFullscreen: boolean }>`
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
height: var(--navbar-height);
max-height: var(--navbar-height);
min-height: var(--navbar-height);
justify-content: space-between;
padding-left: ${isMac ? '70px' : '10px'};
font-weight: bold;
color: var(--color-text-1);
padding-right: ${({ $isFullscreen }) => ($isFullscreen ? '12px' : isWindows ? '140px' : isLinux ? '120px' : '12px')};
-webkit-app-region: drag;
`
const NavbarCenterContainer = styled.div`
flex: 1;
display: flex;
align-items: center;
height: var(--navbar-height);
max-height: var(--navbar-height);
min-height: var(--navbar-height);
padding: 0 8px;
font-weight: bold;
justify-content: space-between;
color: var(--color-text-1);
`

View File

@@ -9,16 +9,19 @@ import { useMinapps } from '@renderer/hooks/useMinapps'
import useNavBackgroundColor from '@renderer/hooks/useNavBackgroundColor'
import { modelGenerating, useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings'
import i18n from '@renderer/i18n'
import { ThemeMode } from '@renderer/types'
import { isEmoji } from '@renderer/utils'
import type { MenuProps } from 'antd'
import { Avatar, Dropdown, Tooltip } from 'antd'
import {
CircleHelp,
Compass,
FileSearch,
Folder,
Languages,
LayoutGrid,
MessageSquareQuote,
MessageSquare,
Moon,
Palette,
Settings,
@@ -44,7 +47,7 @@ const Sidebar: FC = () => {
const { pathname } = useLocation()
const navigate = useNavigate()
const { theme, settingTheme, toggleTheme } = useTheme()
const { theme, settedTheme, toggleTheme } = useTheme()
const avatar = useAvatar()
const { t } = useTranslation()
@@ -61,10 +64,11 @@ const Sidebar: FC = () => {
const docsId = 'cherrystudio-docs'
const onOpenDocs = () => {
const isChinese = i18n.language.startsWith('zh')
openMinapp({
id: docsId,
name: t('docs.title'),
url: 'https://docs.cherry-ai.com/',
url: isChinese ? 'https://docs.cherry-ai.com/' : 'https://docs.cherry-ai.com/cherry-studio-wen-dang/en-us',
logo: AppLogo
})
}
@@ -104,13 +108,13 @@ const Sidebar: FC = () => {
</Icon>
</Tooltip>
<Tooltip
title={t('settings.theme.title') + ': ' + t(`settings.theme.${settingTheme}`)}
title={t('settings.theme.title') + ': ' + t(`settings.theme.${settedTheme}`)}
mouseEnterDelay={0.8}
placement="right">
<Icon theme={theme} onClick={() => toggleTheme()}>
{settingTheme === 'dark' ? (
{settedTheme === ThemeMode.dark ? (
<Moon size={20} className="icon" />
) : settingTheme === 'light' ? (
) : settedTheme === ThemeMode.light ? (
<Sun size={20} className="icon" />
) : (
<SunMoon size={20} className="icon" />
@@ -137,7 +141,7 @@ const MainMenus: FC = () => {
const { hideMinappPopup } = useMinappPopup()
const { t } = useTranslation()
const { pathname } = useLocation()
const { sidebarIcons } = useSettings()
const { sidebarIcons, defaultPaintingProvider } = useSettings()
const { minappShow } = useRuntime()
const navigate = useNavigate()
const { theme } = useTheme()
@@ -146,23 +150,25 @@ const MainMenus: FC = () => {
const isRoutes = (path: string): string => (pathname.startsWith(path) && !minappShow ? 'active' : '')
const iconMap = {
assistants: <MessageSquareQuote size={18} className="icon" />,
assistants: <MessageSquare size={18} className="icon" />,
agents: <Sparkle size={18} className="icon" />,
paintings: <Palette size={18} className="icon" />,
translate: <Languages size={18} className="icon" />,
minapp: <LayoutGrid size={18} className="icon" />,
knowledge: <FileSearch size={18} className="icon" />,
files: <Folder size={17} className="icon" />
files: <Folder size={17} className="icon" />,
discover: <Compass size={18} className="icon" />
}
const pathMap = {
assistants: '/',
agents: '/agents',
paintings: '/paintings',
paintings: `/paintings/${defaultPaintingProvider}`,
translate: '/translate',
minapp: '/apps',
knowledge: '/knowledge',
files: '/files'
files: '/files',
discover: '/discover'
}
return sidebarIcons.visible.map((icon) => {

View File

@@ -15,3 +15,18 @@ export const TOKENFLUX_HOST = 'https://tokenflux.ai'
// Messages loading configuration
export const INITIAL_MESSAGES_COUNT = 20
export const LOAD_MORE_COUNT = 20
export const DEFAULT_COLOR_PRIMARY = '#00b96b'
export const THEME_COLOR_PRESETS = [
DEFAULT_COLOR_PRIMARY,
'#FF5470', // Coral Pink
'#14B8A6', // Teal
'#6366F1', // Indigo
'#8B5CF6', // Purple
'#EC4899', // Pink
'#3B82F6', // Blue
'#F59E0B', // Amber
'#6D28D9', // Violet
'#0EA5E9', // Sky Blue
'#0284C7' // Light Blue
]

View File

@@ -395,6 +395,37 @@ export function getModelLogo(modelId: string) {
}
export const SYSTEM_MODELS: Record<string, Model[]> = {
defaultModel: [
{
// 默认助手模型
id: 'deepseek-ai/DeepSeek-V3',
name: 'deepseek-ai/DeepSeek-V3',
provider: 'silicon',
group: 'deepseek-ai'
},
{
// 默认话题命名模型
id: 'Qwen/Qwen3-8B',
name: 'Qwen/Qwen3-8B',
provider: 'silicon',
group: 'Qwen'
},
{
// 默认翻译模型
id: 'deepseek-ai/DeepSeek-V3',
name: 'deepseek-ai/DeepSeek-V3',
provider: 'silicon',
group: 'deepseek-ai'
},
{
// 默认快捷助手模型
id: 'deepseek-ai/DeepSeek-V3',
name: 'deepseek-ai/DeepSeek-V3',
provider: 'silicon',
group: 'deepseek-ai'
}
],
aihubmix: [
{
id: 'gpt-4o',
@@ -600,17 +631,17 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
name: 'Qwen2.5-7B-Instruct',
group: 'Qwen'
},
{
id: 'meta-llama/Llama-3.3-70B-Instruct',
name: 'meta-llama/Llama-3.3-70B-Instruct',
provider: 'silicon',
group: 'meta-llama'
},
{
id: 'BAAI/bge-m3',
name: 'BAAI/bge-m3',
provider: 'silicon',
group: 'BAAI'
},
{
id: 'Qwen/Qwen3-8B',
name: 'Qwen/Qwen3-8B',
provider: 'silicon',
group: 'Qwen'
}
],
ppio: [
@@ -1348,15 +1379,39 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
],
grok: [
{
id: 'grok-beta',
id: 'grok-3',
provider: 'grok',
name: 'Grok Beta',
name: 'Grok 3',
group: 'Grok'
},
{
id: 'grok-vision-beta',
id: 'grok-3-fast',
provider: 'grok',
name: 'Grok Vision Beta',
name: 'Grok 3 Fast',
group: 'Grok'
},
{
id: 'grok-3-mini',
provider: 'grok',
name: 'Grok 3 Mini',
group: 'Grok'
},
{
id: 'grok-3-mini-fast',
provider: 'grok',
name: 'Grok 3 Mini Fast',
group: 'Grok'
},
{
id: 'grok-2-vision-1212',
provider: 'grok',
name: 'Grok 2 Vision 1212',
group: 'Grok'
},
{
id: 'grok-2-1212',
provider: 'grok',
name: 'Grok 2 1212',
group: 'Grok'
}
],
@@ -1685,24 +1740,6 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
name: 'ERNIE-Speed-128K',
group: '免费模型'
},
{
id: 'THUDM/glm-4-9b-chat',
provider: 'dmxapi',
name: 'THUDM/glm-4-9b-chat',
group: '免费模型'
},
{
id: 'glm-4-flash',
provider: 'dmxapi',
name: 'glm-4-flash',
group: '免费模型'
},
{
id: 'hunyuan-lite',
provider: 'dmxapi',
name: 'hunyuan-lite',
group: '免费模型'
},
{
id: 'gpt-4o',
provider: 'dmxapi',
@@ -2315,7 +2352,8 @@ export function isSupportedThinkingTokenQwenModel(model?: Model): boolean {
}
return (
model.id.toLowerCase().includes('qwen3') ||
model.id.toLowerCase().startsWith('qwen3') ||
model.id.toLowerCase().startsWith('qwen/qwen3') ||
[
'qwen-plus-latest',
'qwen-plus-0428',
@@ -2606,7 +2644,8 @@ export function groupQwenModels(models: Model[]): Record<string, Model[]> {
export const THINKING_TOKEN_MAP: Record<string, { min: number; max: number }> = {
// Gemini models
'gemini-.*$': { min: 0, max: 24576 },
'gemini-.*-flash.*$': { min: 0, max: 24576 },
'gemini-.*-pro.*$': { min: 128, max: 32768 },
// Qwen models
'qwen-plus-.*$': { min: 0, max: 38912 },
@@ -2617,7 +2656,7 @@ export const THINKING_TOKEN_MAP: Record<string, { min: number; max: number }> =
// Claude models
'claude-3[.-]7.*sonnet.*$': { min: 1024, max: 64000 },
'claude-(:?sonnet|opus)-4.*$': { min: 1024, max: 64000 }
'claude-(:?sonnet|opus)-4.*$': { min: 1024, max: 32000 }
}
export const findTokenLimit = (modelId: string): { min: number; max: number } | undefined => {

View File

@@ -47,7 +47,7 @@ As [role name], with [list skills], strictly adhering to [list constraints], usi
`
export const SUMMARIZE_PROMPT =
"You are an assistant skilled in conversation. You need to summarize the user's conversation into a title within 10 words. The language of the title should be consistent with the user's primary language. Do not use punctuation marks or other special symbols"
"You are an assistant skilled in conversation. You need to summarize the user's conversation into a title within 10 words. The language of the title should be consistent with the user's primary language. Do not use punctuation marks, markdown language markers, or other special symbols"
// https://github.com/ItzCrazyKns/Perplexica/blob/master/src/lib/prompts/webSearch.ts
export const SEARCH_SUMMARY_PROMPT = `

View File

@@ -124,8 +124,8 @@ export const PROVIDER_CONFIG = {
websites: {
official: 'https://o3.fan',
apiKey: 'https://o3.fan/token',
docs: 'https://docs.o3.fan',
models: 'https://docs.o3.fan/models'
docs: '',
models: 'https://o3.fan/info/models/'
}
},
burncloud: {
@@ -144,11 +144,10 @@ export const PROVIDER_CONFIG = {
url: 'https://api.ppinfra.com/v3/openai'
},
websites: {
official: 'https://ppinfra.com/user/register?invited_by=JYT9GD&utm_source=github_cherry-studio',
apiKey: 'https://ppinfra.com/user/register?invited_by=JYT9GD&utm_source=github_cherry-studio',
official: 'https://ppio.cn/user/register?invited_by=JYT9GD&utm_source=github_cherry-studio',
apiKey: 'https://ppio.cn/user/register?invited_by=JYT9GD&utm_source=github_cherry-studio',
docs: 'https://docs.cherry-ai.com/pre-basic/providers/ppio?invited_by=JYT9GD&utm_source=github_cherry-studio',
models:
'https://ppinfra.com/model-api/product/llm-api?utm_source=github_cherry-studio&utm_medium=github_readme&utm_campaign=link'
models: 'https://ppio.cn/model-api/product/llm-api?invited_by=JYT9GD&utm_source=github_cherry-studio'
}
},
gemini: {
@@ -170,7 +169,7 @@ export const PROVIDER_CONFIG = {
official: 'https://www.siliconflow.cn',
apiKey: 'https://cloud.siliconflow.cn/i/d1nTBKXU',
docs: 'https://docs.siliconflow.cn/',
models: 'https://docs.siliconflow.cn/docs/model-names'
models: 'https://cloud.siliconflow.cn/models'
}
},
'gitee-ai': {
@@ -395,7 +394,7 @@ export const PROVIDER_CONFIG = {
official: 'https://openrouter.ai/',
apiKey: 'https://openrouter.ai/settings/keys',
docs: 'https://openrouter.ai/docs/quick-start',
models: 'https://openrouter.ai/docs/models'
models: 'https://openrouter.ai/models'
}
},
groq: {
@@ -447,7 +446,7 @@ export const PROVIDER_CONFIG = {
websites: {
official: 'https://x.ai/',
docs: 'https://docs.x.ai/',
models: 'https://docs.x.ai/docs#getting-started'
models: 'https://docs.x.ai/docs/models'
}
},
hyperbolic: {

View File

@@ -1,65 +1,127 @@
import i18n from '@renderer/i18n'
export const TranslateLanguageOptions = [
export interface TranslateLanguageOption {
value: string
langCode?: string
label: string
emoji: string
}
export const TranslateLanguageOptions: TranslateLanguageOption[] = [
{
value: 'english',
langCode: 'en-us',
label: i18n.t('languages.english'),
emoji: '🇬🇧'
},
{
value: 'chinese',
langCode: 'zh-cn',
label: i18n.t('languages.chinese'),
emoji: '🇨🇳'
},
{
value: 'chinese-traditional',
langCode: 'zh-tw',
label: i18n.t('languages.chinese-traditional'),
emoji: '🇭🇰'
},
{
value: 'japanese',
langCode: 'ja-jp',
label: i18n.t('languages.japanese'),
emoji: '🇯🇵'
},
{
value: 'korean',
langCode: 'ko-kr',
label: i18n.t('languages.korean'),
emoji: '🇰🇷'
},
{
value: 'russian',
label: i18n.t('languages.russian'),
emoji: '🇷🇺'
},
{
value: 'spanish',
label: i18n.t('languages.spanish'),
emoji: '🇪🇸'
},
{
value: 'french',
langCode: 'fr-fr',
label: i18n.t('languages.french'),
emoji: '🇫🇷'
},
{
value: 'german',
langCode: 'de-de',
label: i18n.t('languages.german'),
emoji: '🇩🇪'
},
{
value: 'italian',
langCode: 'it-it',
label: i18n.t('languages.italian'),
emoji: '🇮🇹'
},
{
value: 'spanish',
langCode: 'es-es',
label: i18n.t('languages.spanish'),
emoji: '🇪🇸'
},
{
value: 'portuguese',
langCode: 'pt-pt',
label: i18n.t('languages.portuguese'),
emoji: '🇵🇹'
},
{
value: 'russian',
langCode: 'ru-ru',
label: i18n.t('languages.russian'),
emoji: '🇷🇺'
},
{
value: 'polish',
langCode: 'pl-pl',
label: i18n.t('languages.polish'),
emoji: '🇵🇱'
},
{
value: 'arabic',
langCode: 'ar-ar',
label: i18n.t('languages.arabic'),
emoji: '🇸🇦'
},
{
value: 'german',
label: i18n.t('languages.german'),
emoji: '🇩🇪'
value: 'turkish',
langCode: 'tr-tr',
label: i18n.t('languages.turkish'),
emoji: '🇹🇷'
},
{
value: 'thai',
langCode: 'th-th',
label: i18n.t('languages.thai'),
emoji: '🇹🇭'
},
{
value: 'vietnamese',
langCode: 'vi-vn',
label: i18n.t('languages.vietnamese'),
emoji: '🇻🇳'
},
{
value: 'indonesian',
langCode: 'id-id',
label: i18n.t('languages.indonesian'),
emoji: '🇮🇩'
},
{
value: 'urdu',
langCode: 'ur-pk',
label: i18n.t('languages.urdu'),
emoji: '🇵🇰'
},
{
value: 'malay',
langCode: 'ms-my',
label: i18n.t('languages.malay'),
emoji: '🇲🇾'
}
]

View File

@@ -15,13 +15,18 @@ import { FC, PropsWithChildren } from 'react'
import { useTheme } from './ThemeProvider'
const AntdProvider: FC<PropsWithChildren> = ({ children }) => {
const { language } = useSettings()
const {
language,
userTheme: { colorPrimary }
} = useSettings()
const { theme: _theme } = useTheme()
return (
<ConfigProvider
locale={getAntdLocale(language)}
theme={{
cssVar: true,
hashed: false,
algorithm: [_theme === 'dark' ? theme.darkAlgorithm : theme.defaultAlgorithm],
components: {
Menu: {
@@ -40,10 +45,17 @@ const AntdProvider: FC<PropsWithChildren> = ({ children }) => {
},
Tooltip: {
fontSize: 13
},
ColorPicker: {
fontFamily: 'var(--code-font-family)'
},
Segmented: {
itemActiveBg: 'var(--color-background-mute)',
itemHoverBg: 'var(--color-background-mute)'
}
},
token: {
colorPrimary: '#00b96b',
colorPrimary: colorPrimary,
fontFamily: 'var(--font-family)'
}
}}>

View File

@@ -1,18 +1,19 @@
import { isMac } from '@renderer/config/constant'
import { useSettings } from '@renderer/hooks/useSettings'
import useUserTheme from '@renderer/hooks/useUserTheme'
import { ThemeMode } from '@renderer/types'
import { IpcChannel } from '@shared/IpcChannel'
import React, { createContext, PropsWithChildren, use, useEffect, useState } from 'react'
interface ThemeContextType {
theme: ThemeMode
settingTheme: ThemeMode
settedTheme: ThemeMode
toggleTheme: () => void
}
const ThemeContext = createContext<ThemeContextType>({
theme: ThemeMode.auto,
settingTheme: ThemeMode.auto,
theme: ThemeMode.system,
settedTheme: ThemeMode.dark,
toggleTheme: () => {}
})
@@ -20,47 +21,62 @@ interface ThemeProviderProps extends PropsWithChildren {
defaultTheme?: ThemeMode
}
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children, defaultTheme }) => {
const { theme, setTheme } = useSettings()
const [effectiveTheme, setEffectiveTheme] = useState(theme)
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
// 用户设置的主题
const { theme: settedTheme, setTheme: setSettedTheme } = useSettings()
const [actualTheme, setActualTheme] = useState<ThemeMode>(
window.matchMedia('(prefers-color-scheme: dark)').matches ? ThemeMode.dark : ThemeMode.light
)
const { initUserTheme } = useUserTheme()
const toggleTheme = () => {
// 主题顺序是light, dark, auto, 所以需要先判断当前主题,然后取下一个主题
switch (theme) {
case ThemeMode.light:
setTheme(ThemeMode.dark)
break
case ThemeMode.dark:
setTheme(ThemeMode.auto)
break
case ThemeMode.auto:
setTheme(ThemeMode.light)
break
}
const nextTheme = {
[ThemeMode.light]: ThemeMode.dark,
[ThemeMode.dark]: ThemeMode.system,
[ThemeMode.system]: ThemeMode.light
}[settedTheme]
setSettedTheme(nextTheme || ThemeMode.system)
}
const tailwindThemeChange = (theme) => {
const root = window.document.documentElement
root.classList.remove('light', 'dark')
root.classList.add(theme)
}
useEffect(() => {
window.api?.setTheme(defaultTheme || theme)
}, [defaultTheme, theme])
window.api?.setTheme(settedTheme || actualTheme)
}, [settedTheme, actualTheme])
useEffect(() => {
document.body.setAttribute('theme-mode', effectiveTheme)
}, [effectiveTheme])
document.body.setAttribute('theme-mode', settedTheme)
tailwindThemeChange(settedTheme)
}, [settedTheme])
useEffect(() => {
document.body.setAttribute('os', isMac ? 'mac' : 'windows')
const themeChangeListenerRemover = window.electron.ipcRenderer.on(
IpcChannel.ThemeChange,
(_, realTheam: ThemeMode) => {
setEffectiveTheme(realTheam)
}
)
return () => {
themeChangeListenerRemover()
}
})
document.body.setAttribute('theme-mode', actualTheme)
return <ThemeContext value={{ theme: effectiveTheme, settingTheme: theme, toggleTheme }}>{children}</ThemeContext>
// if theme is old auto, then set theme to system
// we can delete this after next big release
if (settedTheme !== ThemeMode.dark && settedTheme !== ThemeMode.light && settedTheme !== ThemeMode.system) {
setSettedTheme(ThemeMode.system)
}
initUserTheme()
// listen for theme updates from main process
return window.electron.ipcRenderer.on(IpcChannel.ThemeUpdated, (_, actualTheme: ThemeMode) => {
document.body.setAttribute('theme-mode', actualTheme)
setActualTheme(actualTheme)
})
}, [actualTheme, initUserTheme, setSettedTheme, settedTheme])
useEffect(() => {
window.api.setTheme(settedTheme)
}, [settedTheme])
return <ThemeContext value={{ theme: actualTheme, settedTheme, toggleTheme }}>{children}</ThemeContext>
}
export const useTheme = () => use(ThemeContext)

View File

@@ -281,7 +281,6 @@ export async function upgradeToV7(tx: Transaction): Promise<void> {
modelId: oldMessage.modelId,
model: oldMessage.model,
type: oldMessage.type === 'clear' ? 'clear' : undefined,
isPreset: oldMessage.isPreset,
useful: oldMessage.useful,
askId: oldMessage.askId,
mentions: oldMessage.mentions,

View File

@@ -1,3 +1,4 @@
import './assets/styles/tailwind.css'
import './assets/styles/index.scss'
import '@ant-design/v5-patch-for-react-19'

View File

@@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}

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