Compare commits

..

120 Commits

Author SHA1 Message Date
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
227 changed files with 16961 additions and 5370 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,5 @@ 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 }}

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

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

@@ -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'
},
@@ -89,7 +71,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

@@ -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.21",
"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,23 @@
"@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",
"@reduxjs/toolkit": "^2.2.5",
"@shikijs/markdown-it": "^3.4.2",
"@swc/plugin-styled-components": "^7.1.5",
"@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 +138,19 @@
"@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",
"color": "^5.0.0",
"dayjs": "^1.11.11",
"dexie": "^4.0.8",
"dexie-react-hooks": "^1.1.7",
@@ -155,7 +158,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,9 +165,11 @@
"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",
@@ -174,8 +178,9 @@
"mime": "^4.0.4",
"motion": "^12.10.5",
"npx-scope-finder": "^1.2.0",
"openai": "patch:openai@npm%3A4.96.0#~/.yarn/patches/openai-npm-4.96.0-0665b05cb9.patch",
"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",
"rc-virtual-list": "^3.18.6",
"react": "^19.0.0",
@@ -207,19 +212,19 @@
"typescript": "^5.6.2",
"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,7 +11,6 @@ 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_HandleZoomFactor = 'app:handle-zoom-factor',
@@ -111,6 +110,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 +144,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 +176,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]

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

@@ -0,0 +1,57 @@
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: [
// 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,9 +24,10 @@ 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'
@@ -113,10 +112,8 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
configManager.setAutoUpdate(isActive)
})
ipcMain.handle(IpcChannel.App_RestartTray, () => TrayService.getInstance().restartTray())
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 +122,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 +219,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 +268,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 +343,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
// store sync
storeSyncService.registerIpcHandler()
// selection assistant
SelectionService.registerIpcHandler()
}

View File

@@ -1,4 +1,5 @@
import { isWin } from '@main/constant'
import { locales } from '@main/utils/locales'
import { IpcChannel } from '@shared/IpcChannel'
import { UpdateInfo } from 'builder-util-runtime'
import { app, BrowserWindow, dialog } from 'electron'
@@ -94,15 +95,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 +126,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'
@@ -340,12 +340,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

@@ -5,7 +5,7 @@ import Store from 'electron-store'
import { locales } from '../utils/locales'
enum ConfigKeys {
export enum ConfigKeys {
Language = 'language',
Theme = 'theme',
LaunchToTray = 'launchToTray',
@@ -16,7 +16,13 @@ enum ConfigKeys {
ClickTrayToShowQuickAssistant = 'clickTrayToShowQuickAssistant',
EnableQuickAssistant = 'enableQuickAssistant',
AutoUpdate = 'autoUpdate',
EnableDataCollection = 'enableDataCollection'
EnableDataCollection = 'enableDataCollection',
SelectionAssistantEnabled = 'selectionAssistantEnabled',
SelectionAssistantTriggerMode = 'selectionAssistantTriggerMode',
SelectionAssistantFollowToolbar = 'selectionAssistantFollowToolbar',
SelectionAssistantRemeberWinSize = 'selectionAssistantRemeberWinSize',
SelectionAssistantFilterMode = 'selectionAssistantFilterMode',
SelectionAssistantFilterList = 'selectionAssistantFilterList'
}
export class ConfigManager {
@@ -32,12 +38,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 +63,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 +79,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 +111,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 +130,7 @@ export class ConfigManager {
}
setEnableQuickAssistant(value: boolean) {
this.set(ConfigKeys.EnableQuickAssistant, value)
this.setAndNotify(ConfigKeys.EnableQuickAssistant, value)
}
getAutoUpdate(): boolean {
@@ -146,8 +149,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, true)
}
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

@@ -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,6 +1,7 @@
import { WebDavConfig } from '@types'
import Logger from 'electron-log'
import Stream from 'stream'
import https from 'https'
import {
BufferLike,
createClient,
@@ -20,7 +21,8 @@ export default class WebDav {
username: params.webdavUser,
password: params.webdavPass,
maxBodyLength: Infinity,
maxContentLength: Infinity
maxContentLength: Infinity,
httpsAgent: new https.Agent({ rejectUnauthorized: false })
})
this.putFileContents = this.putFileContents.bind(this)
@@ -74,6 +76,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')

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,

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,13 @@
import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
import { electronAPI } from '@electron-toolkit/preload'
import { IpcChannel } from '@shared/IpcChannel'
import { FileType, KnowledgeBaseParams, KnowledgeItem, MCPServer, Shortcut, WebDavConfig } from '@types'
import { 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 +20,7 @@ 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),
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,7 +75,9 @@ 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),
@@ -124,7 +127,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,6 +208,24 @@ 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)
}
}

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>

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

@@ -206,8 +206,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,6 +26,7 @@
--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;
@@ -43,6 +44,9 @@
--color-reference-text: #ffffff;
--color-reference-background: #0b0e12;
--color-list-item: #222;
--color-list-item-hover: #1e1e1e;
--modal-background: #1f1f1f;
--color-highlight: rgba(0, 0, 0, 1);
@@ -67,7 +71,7 @@
--chat-background-assistant: #2c2c2c;
--chat-text-user: var(--color-black);
--list-item-border-radius: 16px;
--list-item-border-radius: 20px;
}
[theme-mode='light'] {
@@ -98,6 +102,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 +120,9 @@
--color-reference-text: #000000;
--color-reference-background: #f1f7ff;
--color-list-item: #eee;
--color-list-item-hover: #f5f5f5;
--modal-background: var(--color-white);
--color-highlight: initial;

View File

@@ -10,3 +10,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

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

@@ -231,7 +231,6 @@ const ContentContainer = styled.div<{
$wrap: boolean
$fadeIn: boolean
}>`
display: block;
position: relative;
overflow: auto;
border: 0.5px solid transparent;
@@ -239,12 +238,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

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

@@ -1,26 +1,36 @@
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
}
interface PopupContainerProps {
title: string
obsidianTags: string | null
processingMethod: string | '3'
open: boolean
resolve: (success: boolean) => void
message?: Message
messages?: Message[]
topic?: Topic
}
// 转换文件信息数组为树形结构
const convertToTreeData = (files: FileInfo[]) => {
const treeData: any[] = [
@@ -113,13 +123,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 +142,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 +149,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 +167,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 +192,9 @@ const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
setLoading(false)
}
}
fetchVaults()
}, [defaultObsidianVault])
// 当选择的vault变化时获取其文件和文件夹
useEffect(() => {
if (selectedVault) {
const fetchFiles = async () => {
@@ -209,7 +210,6 @@ const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
setLoading(false)
}
}
fetchFiles()
}
}, [selectedVault])
@@ -219,82 +219,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') {
content = `\n---\n${markdown}`
} else {
content = `---
\ntitle: ${state.title}
\ncreated: ${state.createdAt}
\nsource: ${state.source}
\ntags: ${state.tags}
\n---\n${markdown}`
content = `---\n\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')
} else {
// 如果是文件夹自动设置标题为话题名并设置处理方式为3(新建)
handleChange('processingMethod', '3')
// 仅当用户未手动编辑过 title 时,才将其重置为 props.title
if (!hasTitleBeenManuallyEdited) {
// title 是 props.title
handleChange('title', title)
}
}
@@ -305,7 +294,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 +306,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 +317,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 +342,6 @@ const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
/>
)}
</Form.Item>
<Form.Item label={i18n.t('chat.topics.export.obsidian_path')}>
<Spin spinning={loading}>
{selectedVault ? (
@@ -376,7 +363,6 @@ const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
)}
</Spin>
</Form.Item>
<Form.Item label={i18n.t('chat.topics.export.obsidian_tags')}>
<Input
value={state.tags}
@@ -398,7 +384,6 @@ 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}
@@ -410,9 +395,12 @@ const ObsidianExportDialog: React.FC<ObsidianExportDialogProps> = ({
<Option value="3">{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 { PopupContainer }

View File

@@ -1,44 +1,38 @@
import ObsidianExportDialog from '@renderer/components/ObsidianExportDialog'
import { createRoot } from 'react-dom/client'
import { 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: string | '3'
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

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

@@ -9,6 +9,8 @@ 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'
@@ -18,7 +20,7 @@ import {
Folder,
Languages,
LayoutGrid,
MessageSquareQuote,
MessageSquare,
Moon,
Palette,
Settings,
@@ -44,7 +46,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 +63,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 +107,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 +140,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,7 +149,7 @@ 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" />,
@@ -158,7 +161,7 @@ const MainMenus: FC = () => {
const pathMap = {
assistants: '/',
agents: '/agents',
paintings: '/paintings',
paintings: `/paintings/${defaultPaintingProvider}`,
translate: '/translate',
minapp: '/apps',
knowledge: '/knowledge',

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

@@ -1348,15 +1348,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'
}
],
@@ -2315,7 +2339,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',
@@ -2617,7 +2642,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

@@ -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: {
@@ -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,48 @@ 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)
}
useEffect(() => {
window.api?.setTheme(defaultTheme || theme)
}, [defaultTheme, theme])
useEffect(() => {
document.body.setAttribute('theme-mode', effectiveTheme)
}, [effectiveTheme])
useEffect(() => {
// Set initial theme and OS attributes on body
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

@@ -332,11 +332,11 @@ export function useMessageOperations(topic: Topic) {
}
// 6. Log operations for debugging
console.log('[editMessageBlocks] Operations:', {
blocksToRemove: blockIdsToRemove.length,
blocksToUpdate: blocksToUpdate.length,
blocksToAdd: blocksToAdd.length
})
// console.log('[editMessageBlocks] Operations:', {
// blocksToRemove: blockIdsToRemove.length,
// blocksToUpdate: blocksToUpdate.length,
// blocksToAdd: blocksToAdd.length
// })
// 7. Update Redux state and database
// First update message and add/update blocks

View File

@@ -10,16 +10,19 @@ export function usePaintings() {
const edit = useAppSelector((state) => state.paintings.edit)
const upscale = useAppSelector((state) => state.paintings.upscale)
const DMXAPIPaintings = useAppSelector((state) => state.paintings.DMXAPIPaintings)
const tokenFluxPaintings = useAppSelector((state) => state.paintings.tokenFluxPaintings)
const dispatch = useAppDispatch()
return {
paintings,
DMXAPIPaintings,
tokenFluxPaintings,
persistentData: {
generate,
remix,
edit,
upscale
upscale,
tokenFluxPaintings
},
addPainting: (namespace: keyof PaintingsState, painting: PaintingAction) => {
dispatch(addPainting({ namespace, painting }))

View File

@@ -0,0 +1,63 @@
import { useAppDispatch, useAppSelector } from '@renderer/store'
import {
setActionItems,
setActionWindowOpacity,
setFilterList,
setFilterMode,
setIsAutoClose,
setIsAutoPin,
setIsCompact,
setIsFollowToolbar,
setIsRemeberWinSize,
setSelectionEnabled,
setTriggerMode
} from '@renderer/store/selectionStore'
import { ActionItem, FilterMode, TriggerMode } from '@renderer/types/selectionTypes'
export function useSelectionAssistant() {
const dispatch = useAppDispatch()
const selectionStore = useAppSelector((state) => state.selectionStore)
return {
...selectionStore,
setSelectionEnabled: (enabled: boolean) => {
dispatch(setSelectionEnabled(enabled))
window.api.selection.setEnabled(enabled)
},
setTriggerMode: (mode: TriggerMode) => {
dispatch(setTriggerMode(mode))
window.api.selection.setTriggerMode(mode)
},
setIsCompact: (isCompact: boolean) => {
dispatch(setIsCompact(isCompact))
},
setIsAutoClose: (isAutoClose: boolean) => {
dispatch(setIsAutoClose(isAutoClose))
},
setIsAutoPin: (isAutoPin: boolean) => {
dispatch(setIsAutoPin(isAutoPin))
},
setIsFollowToolbar: (isFollowToolbar: boolean) => {
dispatch(setIsFollowToolbar(isFollowToolbar))
window.api.selection.setFollowToolbar(isFollowToolbar)
},
setIsRemeberWinSize: (isRemeberWinSize: boolean) => {
dispatch(setIsRemeberWinSize(isRemeberWinSize))
window.api.selection.setRemeberWinSize(isRemeberWinSize)
},
setFilterMode: (mode: FilterMode) => {
dispatch(setFilterMode(mode))
window.api.selection.setFilterMode(mode)
},
setFilterList: (list: string[]) => {
dispatch(setFilterList(list))
window.api.selection.setFilterList(list)
},
setActionWindowOpacity: (opacity: number) => {
dispatch(setActionWindowOpacity(opacity))
},
setActionItems: (items: ActionItem[]) => {
dispatch(setActionItems(items))
}
}
}

View File

@@ -6,13 +6,14 @@ import {
setAutoCheckUpdate as _setAutoCheckUpdate,
setLaunchOnBoot,
setLaunchToTray,
setPinTopicsToTop,
setSendMessageShortcut as _setSendMessageShortcut,
setShowTokens,
setSidebarIcons,
setTargetLanguage,
setTheme,
SettingsState,
setTopicPosition,
setPinTopicsToTop,
setTray as _setTray,
setTrayOnClose,
setWindowStyle
@@ -83,6 +84,9 @@ export function useSettings() {
},
setAssistantIconType(assistantIconType: AssistantIconType) {
dispatch(setAssistantIconType(assistantIconType))
},
setShowTokens(showTokens: boolean) {
dispatch(setShowTokens(showTokens))
}
}
}

View File

@@ -1,5 +1,12 @@
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { setShowAssistants, setShowTopics, toggleShowAssistants, toggleShowTopics } from '@renderer/store/settings'
import {
setAssistantsTabSortType,
setShowAssistants,
setShowTopics,
toggleShowAssistants,
toggleShowTopics
} from '@renderer/store/settings'
import { AssistantsSortType } from '@renderer/types'
export function useShowAssistants() {
const showAssistants = useAppSelector((state) => state.settings.showAssistants)
@@ -22,3 +29,13 @@ export function useShowTopics() {
toggleShowTopics: () => dispatch(toggleShowTopics())
}
}
export function useAssistantsTabSortType() {
const assistantsTabSortType = useAppSelector((state) => state.settings.assistantsTabSortType)
const dispatch = useAppDispatch()
return {
assistantsTabSortType,
setAssistantsTabSortType: (sortType: AssistantsSortType) => dispatch(setAssistantsTabSortType(sortType))
}
}

View File

@@ -0,0 +1,53 @@
import { flatMap, groupBy, uniq } from 'lodash'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useAssistants } from './useAssistant'
// 定义useTags的返回类型包含所有标签和获取特定标签的助手函数
// 为了不增加新的概念,标签直接作为助手的属性,所以这里的标签是指助手的标签属性
// 但是为了方便管理,增加了一个获取特定标签的助手函数
export const useTags = () => {
const { assistants } = useAssistants()
const { t } = useTranslation()
// 计算所有标签
const allTags = useMemo(() => {
return uniq(flatMap(assistants, (assistant) => assistant.tags || []))
}, [assistants])
const getAssistantsByTag = useCallback(
(tag: string) => assistants.filter((assistant) => assistant.tags?.includes(tag)),
[assistants]
)
const getGroupedAssistants = useMemo(() => {
// 按标签分组,处理多标签的情况
const assistantsByTags = flatMap(assistants, (assistant) => {
const tags = assistant.tags?.length ? assistant.tags : [t('assistants.tags.untagged')]
return tags.map((tag) => ({ tag, assistant }))
})
// 按标签分组并构建结果
const grouped = Object.entries(groupBy(assistantsByTags, 'tag')).map(([tag, group]) => ({
tag,
assistants: group.map((g) => g.assistant)
}))
// 将未标记的组移到最前面
const untaggedIndex = grouped.findIndex((g) => g.tag === t('assistants.tags.untagged'))
if (untaggedIndex > -1) {
const [untagged] = grouped.splice(untaggedIndex, 1)
grouped.unshift(untagged)
}
return grouped
}, [assistants, t])
return {
allTags,
getAssistantsByTag,
getGroupedAssistants
}
}

View File

@@ -99,6 +99,8 @@ export const autoRenameTopic = async (assistant: Assistant, topicId: string) =>
const data = { ...topic, name: summaryText }
_setActiveTopic(data)
store.dispatch(updateTopic({ assistantId: assistant.id, topic: data }))
} else {
window.message?.error(i18n.t('message.error.fetchTopicName'))
}
}
} finally {

View File

@@ -0,0 +1,29 @@
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { setUserTheme, UserTheme } from '@renderer/store/settings'
import Color from 'color'
export default function useUserTheme() {
const userTheme = useAppSelector((state) => state.settings.userTheme)
const dispatch = useAppDispatch()
const initUserTheme = (theme: UserTheme = userTheme) => {
const colorPrimary = Color(theme.colorPrimary)
document.body.style.setProperty('--color-primary', colorPrimary.toString())
document.body.style.setProperty('--color-primary-soft', colorPrimary.alpha(0.6).toString())
document.body.style.setProperty('--color-primary-mute', colorPrimary.alpha(0.3).toString())
}
return {
colorPrimary: Color(userTheme.colorPrimary),
initUserTheme,
setUserTheme(userTheme: UserTheme) {
dispatch(setUserTheme(userTheme))
initUserTheme(userTheme)
}
}
}

View File

@@ -8,6 +8,7 @@
"add.name.placeholder": "Enter name",
"add.prompt": "Prompt",
"add.prompt.placeholder": "Enter prompt",
"add.prompt.variables.tip": "Available variables: {{date}}, {{time}}, {{datetime}}, {{system}}, {{arch}}, {{language}}, {{model_name}}",
"add.title": "Create Agent",
"import": {
"title": "Import from External",
@@ -101,6 +102,22 @@
"titlePlaceholder": "Enter title",
"contentLabel": "Content",
"contentPlaceholder": "Please enter phrase content, support using variables, and press Tab to quickly locate the variable to modify. For example: \nHelp me plan a route from ${from} to ${to}, and send it to ${email}."
},
"list": {
"showByList": "List View",
"showByTags": "Tag View"
},
"tags": {
"untagged": "Untagged",
"none": "No tags",
"manage": "Tag Management",
"modify": "Modify Tag",
"add": "Add Tag",
"delete": "Delete Tag",
"deleteConfirm": "Are you sure to delete this tag?",
"settings": {
"title": "Tag Settings"
}
}
},
"auth": {
@@ -241,7 +258,7 @@
"settings.show_line_numbers": "Show line numbers in code",
"settings.temperature": "Temperature",
"settings.temperature.tip": "Higher values make the model more creative and unpredictable, while lower values make it more deterministic and precise.",
"settings.thought_auto_collapse": "Automatically Collapse Thought Content",
"settings.thought_auto_collapse": "Collapse Thought Content",
"settings.thought_auto_collapse.tip": "Automatically collapse thought content after thinking ends",
"settings.top_p": "Top-P",
"settings.top_p.tip": "Default value is 1, the smaller the value, the less variety in the answers, the easier to understand, the larger the value, the larger the range of the AI's vocabulary, the more diverse",
@@ -305,6 +322,7 @@
"translate": "Translate",
"topics.export.siyuan": "Export to Siyuan Note",
"topics.export.wait_for_title_naming": "Generating title...",
"topics.export.obsidian_reasoning": "Include Reasoning Chain",
"topics.export.title_naming_success": "Title generated successfully",
"topics.export.title_naming_failed": "Failed to generate title, using default title",
"input.translating": "Translating...",
@@ -569,7 +587,14 @@
"korean": "Korean",
"portuguese": "Portuguese",
"russian": "Russian",
"spanish": "Spanish"
"spanish": "Spanish",
"polish": "Polish",
"turkish": "Turkish",
"thai": "Thai",
"vietnamese": "Vietnamese",
"indonesian": "Indonesian",
"urdu": "Urdu",
"malay": "Malay"
},
"lmstudio": {
"keep_alive_time.description": "The time in minutes to keep the connection alive, default is 5 minutes.",
@@ -610,6 +635,7 @@
"error.enter.api.key": "Please enter your API key first",
"error.enter.model": "Please select a model first",
"error.enter.name": "Please enter the name of the knowledge base",
"error.fetchTopicName": "Failed to name the topic",
"error.get_embedding_dimensions": "Failed to get embedding dimensions",
"error.invalid.api.host": "Invalid API Host",
"error.invalid.api.key": "Invalid API Key",
@@ -636,7 +662,7 @@
"message.code_style": "Code style",
"message.delete.content": "Are you sure you want to delete this message?",
"message.delete.title": "Delete Message",
"message.multi_model_style": "Multi-model response style",
"message.multi_model_style": "Group style",
"message.multi_model_style.fold": "Fold view",
"message.multi_model_style.fold.compress": "Switch to compact layout",
"message.multi_model_style.fold.expand": "Switch to expanded layout",
@@ -820,14 +846,15 @@
"seed_desc_tip": "The same seed and prompt can generate similar images, setting -1 will generate different results each time",
"title": "Images",
"magic_prompt_option": "Magic Prompt",
"model": "Model Version",
"model": "Model",
"aspect_ratio": "Aspect Ratio",
"style_type": "Style",
"rendering_speed": "Rendering Speed",
"learn_more": "Learn More",
"paint_course":"tutorial",
"paint_course": "tutorial",
"prompt_placeholder_edit": "Enter your image description, text drawing uses \"double quotes\" to wrap",
"proxy_required": "Currently, you need to open a proxy to view the generated images, it will be supported in the future",
"prompt_placeholder_en": "Enter your image description, currently Imagen only supports English prompts",
"proxy_required": "Open the proxy and enable “TUN mode” to view generated images or copy them to the browser for opening. In the future, domestic direct connection will be supported",
"image_file_required": "Please upload an image first",
"image_file_retry": "Please re-upload an image first",
"image_placeholder": "No image available",
@@ -846,6 +873,34 @@
"turbo": "Turbo",
"quality": "Quality"
},
"quality_options": {
"auto": "Auto",
"low": "Low",
"medium": "Medium",
"high": "High"
},
"moderation_options": {
"auto": "Auto",
"low": "Low"
},
"background_options": {
"auto": "Auto",
"transparent": "Transparent",
"opaque": "Opaque"
},
"aspect_ratios": {
"square": "Square",
"portrait": "Portrait",
"landscape": "Landscape"
},
"person_generation_options": {
"allow_all": "Allow all",
"allow_adult": "Allow adult",
"allow_none": "Not allowed"
},
"quality": "Quality",
"moderation": "Moderation",
"background": "Background",
"mode": {
"generate": "Draw",
"edit": "Edit",
@@ -859,7 +914,9 @@
"negative_prompt_tip": "Describe unwanted elements, only for V_1, V_1_TURBO, V_2, and V_2_TURBO",
"magic_prompt_option_tip": "Intelligently enhances prompts for better results",
"style_type_tip": "Image generation style for V_2 and above",
"rendering_speed_tip": "Controls rendering speed vs. quality trade-off, only available for V_3"
"rendering_speed_tip": "Controls rendering speed vs. quality trade-off, only available for V_3",
"person_generation": "Generate person",
"person_generation_tip": "Allow model to generate person images"
},
"edit": {
"image_file": "Edited Image",
@@ -892,7 +949,20 @@
"seed_tip": "Controls upscaling randomness",
"magic_prompt_option_tip": "Intelligently enhances upscaling prompts"
},
"text_desc_required": "Please enter image description first"
"text_desc_required": "Please enter image description first",
"req_error_text": "Operation failed. Please try again. Avoid using 'copyrighted' or 'sensitive' words in your prompt.",
"auto_create_paint": "Auto-create image",
"auto_create_paint_tip": "After the image is generated, a new image will be created automatically.",
"select_model": "Select Model",
"input_parameters": "Input Parameters",
"input_image": "Input Image",
"generated_image": "Generated Image",
"pricing": "Pricing",
"model_and_pricing": "Model & Pricing",
"per_image": "per image",
"per_images": "per images",
"required_field": "Required field",
"uploaded_input": "Uploaded input"
},
"prompts": {
"explanation": "Explain this concept to me",
@@ -946,7 +1016,7 @@
"zhinao": "360AI",
"zhipu": "ZHIPU AI",
"voyageai": "Voyage AI",
"qiniu": "Qiniu",
"qiniu": "Qiniu AI",
"tokenflux": "TokenFlux"
},
"restore": {
@@ -1045,7 +1115,9 @@
"token": "Joplin Authorization Token",
"token_placeholder": "Joplin Authorization Token",
"url": "Joplin Web Clipper Service URL",
"url_placeholder": "http://127.0.0.1:41184/"
"url_placeholder": "http://127.0.0.1:41184/",
"export_reasoning.title": "Include Reasoning Chain in Export",
"export_reasoning.help": "When enabled, the exported content will include the reasoning chain (thought process) generated by the assistant."
},
"markdown_export.force_dollar_math.help": "When enabled, $$ will be forcibly used to mark LaTeX formulas when exporting to Markdown. Note: This option also affects all export methods through Markdown, such as Notion, Yuque, etc.",
"markdown_export.force_dollar_math.title": "Force $$ for LaTeX formulas",
@@ -1054,12 +1126,14 @@
"markdown_export.path_placeholder": "Export Path",
"markdown_export.select": "Select",
"markdown_export.title": "Markdown Export",
"markdown_export.show_model_name.title": "Use Model Name on Export",
"markdown_export.show_model_name.help": "When enabled, the topic-naming model will be used to create titles for exported messages. Note: This option also affects all export methods through Markdown, such as Notion, Yuque, etc.",
"markdown_export.show_model_provider.title": "Show Model Provider",
"markdown_export.show_model_provider.help": "Display the model provider (e.g., OpenAI, Gemini) when exporting to Markdown",
"minute_interval_one": "{{count}} minute",
"minute_interval_other": "{{count}} minutes",
"notion.api_key": "Notion API Key",
"notion.api_key_placeholder": "Enter Notion API Key",
"notion.auto_split": "Auto split when exporting",
"notion.auto_split_tip": "Automatically split pages when exporting long topics to Notion",
"notion.check": {
"button": "Check",
"empty_api_key": "API key is not configured",
@@ -1073,10 +1147,9 @@
"notion.help": "Notion Configuration Documentation",
"notion.page_name_key": "Page Title Field Name",
"notion.page_name_key_placeholder": "Enter page title field name, default is Name",
"notion.split_size": "Split size",
"notion.split_size_help": "Recommended: 90 for Free plan, 24990 for Plus plan, default is 90",
"notion.split_size_placeholder": "Enter block limit per page (default 90)",
"notion.title": "Notion Configuration",
"notion.title": "Notion Settings",
"notion.export_reasoning.title": "Include Reasoning Chain in Export",
"notion.export_reasoning.help": "When enabled, exported content will include reasoning chain (thought process).",
"title": "Data Settings",
"webdav": {
"autoSync": "Auto Backup",
@@ -1213,6 +1286,7 @@
"display.sidebar.translate.icon": "Show Translate icon",
"display.sidebar.visible": "Show icons",
"display.title": "Display Settings",
"display.zoom.title": "Zoom Settings",
"display.topic.title": "Topic Settings",
"miniapps": {
"title": "Mini Apps Settings",
@@ -1438,6 +1512,7 @@
"advancedSettings": "Advanced Settings"
},
"messages.prompt": "Show prompt",
"messages.tokens": "Show token usage",
"messages.divider": "Show divider between messages",
"messages.grid_columns": "Message grid display columns",
"messages.grid_popover_trigger": "Grid detail trigger",
@@ -1448,14 +1523,14 @@
"messages.input.send_shortcuts": "Send shortcuts",
"messages.input.show_estimated_tokens": "Show estimated tokens",
"messages.input.title": "Input Settings",
"messages.input.enable_quick_triggers": "Enable '/' and '@' triggers",
"messages.input.enable_quick_triggers": "Enable / and @ triggers",
"messages.input.enable_delete_model": "Enable the backspace key to delete models/attachments.",
"messages.markdown_rendering_input_message": "Markdown render input message",
"messages.math_engine": "Math engine",
"messages.math_engine.none": "None",
"messages.metrics": "{{time_first_token_millsec}}ms to first token | {{token_speed}} tok/sec",
"messages.model.title": "Model Settings",
"messages.navigation": "Message Navigation",
"messages.navigation": "Navigation bar",
"messages.navigation.anchor": "Message Anchor",
"messages.navigation.buttons": "Navigation Buttons",
"messages.navigation.none": "None",
@@ -1491,6 +1566,7 @@
"models.check.start": "Start",
"models.check.title": "Model health check",
"models.check.use_all_keys": "Key(s)",
"models.check.disclaimer": "Health check requires sending requests, please use it with caution. Models that charge per request may incur additional costs, please bear the responsibility.",
"models.default_assistant_model": "Default Assistant Model",
"models.default_assistant_model_description": "Model used when creating a new assistant, if the assistant is not set, this model will be used",
"models.empty": "No models found",
@@ -1638,10 +1714,11 @@
"zoom_out": "Zoom Out",
"zoom_reset": "Reset Zoom"
},
"theme.auto": "Auto",
"theme.system": "System",
"theme.dark": "Dark",
"theme.light": "Light",
"theme.title": "Theme",
"theme.color_primary": "Primary Color",
"theme.window.style.opaque": "Opaque Window",
"theme.window.style.title": "Window Style",
"theme.window.style.transparent": "Transparent Window",
@@ -1739,10 +1816,13 @@
},
"translate": {
"any.language": "Any language",
"target_language": "Target Language",
"alter_language": "Alternative Language",
"button.translate": "Translate",
"close": "Close",
"closed": "Translation closed",
"copied": "Translation content copied",
"detected.language": "Detected Language",
"empty": "Translation content is empty",
"not.found": "Translation content not found",
"confirm": {
@@ -1761,8 +1841,16 @@
"input.placeholder": "Enter text to translate",
"output.placeholder": "Translation",
"processing": "Translation in progress...",
"scroll_sync.disable": "Disable synced scroll",
"scroll_sync.enable": "Enable synced scroll",
"language.same": "Source and target languages are the same",
"language.not_pair": "Source language is different from the set language",
"settings": {
"title": "Translation Settings",
"model": "Model Settings",
"model_desc": "Model used for translation service",
"bidirectional": "Bidirectional Translation Settings",
"bidirectional_tip": "When enabled, only bidirectional translation between source and target languages is supported",
"scroll_sync": "Scroll Sync Settings"
},
"title": "Translation",
"tooltip.newline": "Newline",
"menu": {
@@ -1779,6 +1867,174 @@
"quit": "Quit",
"show_window": "Show Window",
"visualization": "Visualization"
},
"update": {
"title": "Update",
"message": "New version {{version}} is ready, do you want to install it now?",
"later": "Later",
"install": "Install",
"noReleaseNotes": "No release notes"
},
"selection": {
"name": "Selection Assistant",
"action": {
"builtin": {
"translate": "Translate",
"explain": "Explain",
"summary": "Summarize",
"search": "Search",
"refine": "Refine",
"copy": "Copy"
},
"window": {
"pin": "Pin",
"pinned": "Pinned",
"opacity": "Window Opacity",
"original_show": "Show Original",
"original_hide": "Hide Original",
"original_copy": "Copy Original",
"esc_close": "Esc: Close",
"esc_stop": "Esc: Stop",
"c_copy": "C: Copy",
"r_regenerate": "R: Regenerate"
},
"translate": {
"smart_translate_tips": "Smart Translation: Content will be translated to the target language first; content already in the target language will be translated to the alternative language"
}
},
"settings": {
"experimental": "Experimental Features",
"enable": {
"title": "Enable",
"description": "Currently only supported on Windows systems"
},
"toolbar": {
"title": "Toolbar",
"trigger_mode": {
"title": "Trigger Mode",
"description": "Show toolbar immediately when text is selected, or show only when Ctrl key is held after selection.",
"description_note": "The Ctrl key may not work in some apps. If you use AHK or other tools to remap the Ctrl key, it may not work.",
"selected": "Selection",
"ctrlkey": "Ctrl Key"
},
"compact_mode": {
"title": "Compact Mode",
"description": "In compact mode, only icons are displayed without text"
}
},
"window": {
"title": "Action Window",
"follow_toolbar": {
"title": "Follow Toolbar",
"description": "Window position will follow the toolbar. When disabled, it will always be centered."
},
"remember_size": {
"title": "Remember Size",
"description": "Window will display at the last adjusted size during the application running"
},
"auto_close": {
"title": "Auto Close",
"description": "Automatically close the window when it's not pinned and loses focus"
},
"auto_pin": {
"title": "Auto Pin",
"description": "Pin the window by default"
},
"opacity": {
"title": "Opacity",
"description": "Set the default opacity of the window, 100% is fully opaque"
}
},
"actions": {
"title": "Actions",
"reset": {
"button": "Reset",
"tooltip": "Reset to default actions. Custom actions will not be deleted.",
"confirm": "Are you sure you want to reset to default actions? Custom actions will not be deleted."
},
"add_tooltip": {
"enabled": "Add Custom Action",
"disabled": "Maximum number of custom actions reached ({{max}})"
},
"delete_confirm": "Are you sure you want to delete this custom action?",
"drag_hint": "Drag to reorder. Move above to enable action ({{enabled}}/{{max}})"
},
"advanced": {
"title": "Advanced",
"filter_mode": {
"title": "Application Filter",
"description": "Can limit the selection assistant to only work in specific applications (whitelist) or not work (blacklist)",
"default": "Off",
"whitelist": "Whitelist",
"blacklist": "Blacklist"
},
"filter_list": {
"title": "Filter List",
"description": "Advanced feature, recommended for users with experience"
}
},
"user_modal": {
"title": {
"add": "Add Custom Action",
"edit": "Edit Custom Action"
},
"name": {
"label": "Name",
"hint": "Please enter action name"
},
"icon": {
"label": "Icon",
"placeholder": "Enter Lucide icon name",
"error": "Invalid icon name, please check your input",
"tooltip": "Lucide icon names are lowercase, e.g. arrow-right",
"view_all": "View All Icons",
"random": "Random Icon"
},
"model": {
"label": "Model",
"tooltip": "Using Assistant: Will use both the assistant's system prompt and model parameters",
"default": "Default Model",
"assistant": "Use Assistant"
},
"assistant": {
"label": "Select Assistant",
"default": "Default"
},
"prompt": {
"label": "User Prompt",
"tooltip": "User prompt serves as a supplement to user input and won't override the assistant's system prompt",
"placeholder": "Use placeholder {{text}} to represent selected text. When empty, selected text will be appended to this prompt",
"placeholder_text": "Placeholder",
"copy_placeholder": "Copy Placeholder"
}
},
"search_modal": {
"title": "Set Search Engine",
"engine": {
"label": "Search Engine",
"custom": "Custom"
},
"custom": {
"name": {
"label": "Custom Name",
"hint": "Please enter search engine name",
"max_length": "Name cannot exceed 16 characters"
},
"url": {
"label": "Custom Search URL",
"hint": "Use {{queryString}} to represent the search term",
"required": "Please enter search URL",
"invalid_format": "Please enter a valid URL starting with http:// or https://",
"missing_placeholder": "URL must contain {{queryString}} placeholder"
},
"test": "Test"
}
},
"filter_modal": {
"title": "Application Filter List",
"user_tips": "Please enter the executable file name of the application, one per line, case insensitive, can be fuzzy matched. For example: chrome.exe, weixin.exe, Cherry Studio.exe, etc."
}
}
}
}
}

View File

@@ -8,6 +8,7 @@
"add.name.placeholder": "名前を入力",
"add.prompt": "プロンプト",
"add.prompt.placeholder": "プロンプトを入力",
"add.prompt.variables.tip": "利用可能な変数:{{date}}, {{time}}, {{datetime}}, {{system}}, {{arch}}, {{language}}, {{model_name}}",
"add.title": "エージェントを作成",
"import": {
"title": "外部からインポート",
@@ -99,6 +100,22 @@
"settings.knowledge_base.recognition": "ナレッジベースの呼び出し",
"settings.knowledge_base.recognition.off": "強制検索",
"settings.knowledge_base.recognition.on": "意図認識",
"list": {
"showByList": "リスト表示",
"showByTags": "タグ表示"
},
"tags": {
"untagged": "未分類",
"none": "タグなし",
"manage": "タグ管理",
"add": "タグ追加",
"modify": "タグ修正",
"delete": "タグ削除",
"deleteConfirm": "このタグを削除してもよろしいですか?",
"settings": {
"title": "タグ設定"
}
},
"settings.tool_use_mode": "工具調用方式",
"settings.tool_use_mode.function": "関数",
"settings.tool_use_mode.prompt": "提示詞"
@@ -305,6 +322,7 @@
"translate": "翻訳",
"topics.export.siyuan": "思源笔记にエクスポート",
"topics.export.wait_for_title_naming": "タイトルを生成中...",
"topics.export.obsidian_reasoning": "思考過程を含める",
"topics.export.title_naming_success": "タイトルの生成に成功しました",
"topics.export.title_naming_failed": "タイトルの生成に失敗しました。デフォルトのタイトルを使用します",
"input.translating": "翻訳中...",
@@ -569,7 +587,14 @@
"korean": "韓国語",
"portuguese": "ポルトガル語",
"russian": "ロシア語",
"spanish": "スペイン語"
"spanish": "スペイン語",
"polish": "ポーランド語",
"turkish": "トルコ語",
"thai": "タイ語",
"vietnamese": "ベトナム語",
"indonesian": "インドネシア語",
"urdu": "ウルドゥー語",
"malay": "マレー語"
},
"lmstudio": {
"keep_alive_time.description": "モデルがメモリに保持される時間デフォルト5分",
@@ -610,6 +635,7 @@
"error.enter.api.key": "APIキーを入力してください",
"error.enter.model": "モデルを選択してください",
"error.enter.name": "ナレッジベース名を入力してください",
"error.fetchTopicName": "トピックの命名に失敗しました",
"error.get_embedding_dimensions": "埋込み次元を取得できませんでした",
"error.invalid.api.host": "無効なAPIアドレスです",
"error.invalid.api.key": "無効なAPIキーです",
@@ -820,13 +846,14 @@
"seed_desc_tip": "同じシードとプロンプトで類似した画像を生成できますが、-1 に設定すると毎回異なる結果が生成されます",
"title": "画像",
"magic_prompt_option": "プロンプト強化",
"model": "モデルバージョン",
"model": "モデル",
"aspect_ratio": "画幅比例",
"style_type": "スタイル",
"learn_more": "詳しくはこちら",
"prompt_placeholder_edit": "画像の説明を入力します。テキスト描画には '二重引用符' を使用します",
"paint_course":"チュートリアル",
"proxy_required": "現在、プロキシを開く必要があります。これは、将来サポートされる予定です",
"prompt_placeholder_en": "「英語」の説明を入力します。Imagenは現在、英語のプロンプト語のみをサポートしています",
"paint_course": "チュートリアル",
"proxy_required": "打開代理並開啟”TUN模式“查看生成圖片或複製到瀏覽器開啟後續會支持國內直連",
"image_file_required": "画像を先にアップロードしてください",
"image_file_retry": "画像を先にアップロードしてください",
"image_placeholder": "画像がありません",
@@ -844,6 +871,34 @@
"turbo": "高速",
"quality": "高品質"
},
"quality_options": {
"auto": "自動",
"low": "低",
"medium": "中",
"high": "高"
},
"moderation_options": {
"auto": "自動",
"low": "低"
},
"background_options": {
"auto": "自動",
"transparent": "透明",
"opaque": "不透明"
},
"aspect_ratios": {
"square": "正方形",
"portrait": "縦図",
"landscape": "横図"
},
"person_generation_options": {
"allow_all": "許可する",
"allow_adult": "許可する",
"allow_none": "許可しない"
},
"quality": "品質",
"moderation": "敏感度",
"background": "背景",
"mode": {
"generate": "画像生成",
"edit": "部分編集",
@@ -857,7 +912,9 @@
"negative_prompt_tip": "画像に含めたくない内容を説明します",
"magic_prompt_option_tip": "生成効果を向上させるための提示詞を最適化します",
"style_type_tip": "画像生成スタイル、V_2 以上のバージョンでのみ適用",
"rendering_speed_tip": "レンダリング速度と品質のバランスを調整します。V_3バージョンでのみ利用可能です"
"rendering_speed_tip": "レンダリング速度と品質のバランスを調整します。V_3バージョンでのみ利用可能です",
"person_generation": "人物生成",
"person_generation_tip": "人物画像を生成する"
},
"edit": {
"image_file": "編集画像",
@@ -892,7 +949,20 @@
},
"rendering_speed": "レンダリング速度",
"translating": "翻訳中...",
"text_desc_required": "画像の説明を先に入力してください"
"text_desc_required": "画像の説明を先に入力してください",
"req_error_text": "実行に失敗しました。もう一度お試しください。プロンプトに「著作権用語」や「センシティブな用語」を含めないでください。",
"auto_create_paint": "画像を自動作成",
"auto_create_paint_tip": "画像が生成された後、自動的に新しい画像が作成されます。",
"select_model": "モデルを選択",
"input_parameters": "パラメータ入力",
"input_image": "入力画像",
"generated_image": "生成画像",
"pricing": "料金",
"model_and_pricing": "モデルと料金",
"per_image": "1枚あたり",
"per_images": "複数枚あたり",
"required_field": "必須項目",
"uploaded_input": "アップロード済みの入力"
},
"prompts": {
"explanation": "この概念を説明してください",
@@ -946,7 +1016,7 @@
"zhinao": "360智脳",
"zhipu": "智譜AI",
"voyageai": "Voyage AI",
"qiniu": "七牛云",
"qiniu": "七牛云 AI 推理",
"tokenflux": "TokenFlux"
},
"restore": {
@@ -1043,7 +1113,9 @@
"token": "Joplin 認証トークン",
"token_placeholder": "Joplin 認証トークンを入力してください",
"url": "Joplin 剪輯服務 URL",
"url_placeholder": "http://127.0.0.1:41184/"
"url_placeholder": "http://127.0.0.1:41184/",
"export_reasoning.title": "エクスポート時に思考過程を含める",
"export_reasoning.help": "有効にすると、エクスポートされる内容にアシスタントが生成した思考過程(リースニングチェーン)が含まれます。"
},
"markdown_export.force_dollar_math.help": "有効にすると、Markdownにエクスポートする際にLaTeX数式を$$で強制的にマークします。注意この設定はNotion、Yuqueなど、Markdownを通じたすべてのエクスポート方法にも影響します。",
"markdown_export.force_dollar_math.title": "LaTeX数式に$$を強制使用",
@@ -1052,29 +1124,12 @@
"markdown_export.path_placeholder": "エクスポートパス",
"markdown_export.select": "選択",
"markdown_export.title": "Markdown エクスポート",
"markdown_export.show_model_name.title": "エクスポート時にモデル名を使用",
"markdown_export.show_model_name.help": "有効にすると、トピック命名モデルがエクスポートされたメッセージのタイトル作成に使用されます。注意この設定はNotion、Yuqueなど、Markdownを通じたすべてのエクスポート方法にも影響します。",
"markdown_export.show_model_provider.title": "モデルプロバイダーを表示",
"markdown_export.show_model_provider.help": "Markdownエクスポート時にモデルプロバイダーOpenAI、Geminiなどを表示します。",
"minute_interval_one": "{{count}} 分",
"minute_interval_other": "{{count}} 分",
"notion.api_key": "Notion APIキー",
"notion.api_key_placeholder": "Notion APIキーを入力してください",
"notion.auto_split": "ダイアログをエクスポートすると自動ページ分割",
"notion.auto_split_tip": "ダイアログが長い場合、Notionに自動的にページ分割してエクスポートします",
"notion.check": {
"button": "確認",
"empty_api_key": "Api_keyが設定されていません",
"empty_database_id": "Database_idが設定されていません",
"error": "接続エラー、ネットワーク設定とApi_keyとDatabase_idを確認してください",
"fail": "接続エラー、ネットワーク設定とApi_keyとDatabase_idを確認してください",
"success": "接続に成功しました。"
},
"notion.database_id": "Notion データベースID",
"notion.database_id_placeholder": "Notion データベースIDを入力してください",
"notion.help": "Notion 設定ドキュメント",
"notion.page_name_key": "ページタイトルフィールド名",
"notion.page_name_key_placeholder": "ページタイトルフィールド名を入力してください。デフォルトは Name です",
"notion.split_size": "自動ページ分割サイズ",
"notion.split_size_help": "Notion無料版ユーザーは90、有料版ユーザーは24990、デフォルトは90",
"notion.split_size_placeholder": "ページごとのブロック数制限を入力してください(デフォルト90)",
"notion.title": "Notion 設定",
"title": "データ設定",
"webdav": {
"autoSync": "自動バックアップ",
@@ -1194,7 +1249,25 @@
"new_folder.button": "新しいフォルダー"
},
"message_title.use_topic_naming.title": "トピック命名モデルを使用してメッセージのタイトルを作成",
"message_title.use_topic_naming.help": "この設定は、すべてのMarkdownエクスポート方法に影響します。"
"message_title.use_topic_naming.help": "この設定は、すべてのMarkdownエクスポート方法に影響します。",
"notion.api_key": "Notion APIキー",
"notion.api_key_placeholder": "Notion APIキーを入力してください",
"notion.check": {
"button": "確認",
"empty_api_key": "Api_keyが設定されていません",
"empty_database_id": "Database_idが設定されていません",
"error": "接続エラー、ネットワーク設定とApi_keyとDatabase_idを確認してください",
"fail": "接続エラー、ネットワーク設定とApi_keyとDatabase_idを確認してください",
"success": "接続に成功しました。"
},
"notion.database_id": "Notion データベースID",
"notion.database_id_placeholder": "Notion データベースIDを入力してください",
"notion.help": "Notion 設定ドキュメント",
"notion.page_name_key": "ページタイトルフィールド名",
"notion.page_name_key_placeholder": "ページタイトルフィールド名を入力してください。デフォルトは Name です",
"notion.title": "Notion 設定",
"notion.export_reasoning.title": "エクスポート時に思考チェーンを含める",
"notion.export_reasoning.help": "有効にすると、Notionにエクスポートする際に思考チェーンの内容が含まれます。"
},
"display.assistant.title": "アシスタント設定",
"display.custom.css": "カスタムCSS",
@@ -1211,6 +1284,7 @@
"display.sidebar.translate.icon": "翻訳のアイコンを表示",
"display.sidebar.visible": "アイコンを表示",
"display.title": "表示設定",
"display.zoom.title": "ズーム設定",
"display.topic.title": "トピック設定",
"miniapps": {
"title": "ミニアプリ設定",
@@ -1434,6 +1508,7 @@
"advancedSettings": "詳細設定"
},
"messages.prompt": "プロンプト表示",
"messages.tokens": "トークン使用量を表示",
"messages.divider": "メッセージ間に区切り線を表示",
"messages.grid_columns": "メッセージグリッドの表示列数",
"messages.grid_popover_trigger": "グリッド詳細トリガー",
@@ -1444,7 +1519,7 @@
"messages.input.send_shortcuts": "送信ショートカット",
"messages.input.show_estimated_tokens": "推定トークン数を表示",
"messages.input.title": "入力設定",
"messages.input.enable_quick_triggers": "'/' と '@' を有効にしてクイックメニューを表示します。",
"messages.input.enable_quick_triggers": "/ と @ を有効にしてクイックメニューを表示します。",
"messages.input.enable_delete_model": "バックスペースキーでモデル/添付ファイルを削除します。",
"messages.markdown_rendering_input_message": "Markdownで入力メッセージをレンダリング",
"messages.math_engine": "数式エンジン",
@@ -1487,6 +1562,7 @@
"models.check.start": "開始",
"models.check.title": "モデル健康チェック",
"models.check.use_all_keys": "キー",
"models.check.disclaimer": "健康チェックはリクエストを送信するため、費用が発生する可能性があります。慎重に使用してください。",
"models.default_assistant_model": "デフォルトアシスタントモデル",
"models.default_assistant_model_description": "新しいアシスタントを作成する際に使用されるモデル。アシスタントがモデルを設定していない場合、このモデルが使用されます",
"models.empty": "モデルが見つかりません",
@@ -1628,10 +1704,11 @@
"zoom_out": "ズームアウト",
"zoom_reset": "ズームをリセット"
},
"theme.auto": "自動",
"theme.system": "システム",
"theme.dark": "ダーク",
"theme.light": "ライト",
"theme.title": "テーマ",
"theme.color_primary": "テーマ色",
"theme.window.style.opaque": "不透明ウィンドウ",
"theme.window.style.title": "ウィンドウスタイル",
"theme.window.style.transparent": "透明ウィンドウ",
@@ -1739,6 +1816,8 @@
},
"translate": {
"any.language": "任意の言語",
"target_language": "目標言語",
"alter_language": "備用言語",
"button.translate": "翻訳",
"close": "閉じる",
"closed": "翻訳は閉じられました",
@@ -1761,13 +1840,22 @@
"input.placeholder": "翻訳するテキストを入力",
"output.placeholder": "翻訳",
"processing": "翻訳中...",
"scroll_sync.disable": "關閉滾動同步",
"scroll_sync.enable": "開啟滾動同步",
"language.same": "ソース言語と目標言語が同じです",
"language.not_pair": "ソース言語が設定された言語と異なります",
"settings": {
"title": "翻訳設定",
"model": "モデル設定",
"model_desc": "翻訳サービスで使用されるモデル",
"bidirectional": "双方向翻訳設定",
"bidirectional_tip": "有効にすると、ソース言語と目標言語間の双方向翻訳のみがサポートされます",
"scroll_sync": "スクロール同期設定"
},
"title": "翻訳",
"tooltip.newline": "改行",
"menu": {
"description": "對當前輸入框內容進行翻譯"
}
},
"detected.language": "検出された言語"
},
"tray": {
"quit": "終了",
@@ -1779,6 +1867,174 @@
"quit": "終了",
"show_window": "ウィンドウを表示",
"visualization": "可視化"
},
"update": {
"title": "更新",
"message": "新バージョン {{version}} が利用可能です。今すぐインストールしますか?",
"later": "後で",
"install": "今すぐインストール",
"noReleaseNotes": "暫無更新日誌"
},
"selection": {
"name": "テキスト選択ツール",
"action": {
"builtin": {
"translate": "翻訳",
"explain": "解説",
"summary": "要約",
"search": "検索",
"refine": "最適化",
"copy": "コピー"
},
"window": {
"pin": "最前面に固定",
"pinned": "固定中",
"opacity": "ウィンドウの透過度",
"original_show": "原文を表示",
"original_hide": "原文を非表示",
"original_copy": "原文をコピー",
"esc_close": "Escで閉じる",
"esc_stop": "Escで停止",
"c_copy": "Cでコピー",
"r_regenerate": "Rで再生成"
},
"translate": {
"smart_translate_tips": "スマート翻訳:内容は優先的に目標言語に翻訳されます。すでに目標言語の場合は、備用言語に翻訳されます。"
}
},
"settings": {
"experimental": "実験的機能",
"enable": {
"title": "有効化",
"description": "現在Windowsのみ対応"
},
"toolbar": {
"title": "ツールバー",
"trigger_mode": {
"title": "表示方法",
"description": "テキスト選択時に即時表示、またはCtrlキー押下時のみ表示",
"description_note": "一部のアプリはCtrlキーでのテキスト選択に対応していません。AHKなどでCtrlキーをリマップすると、選択できなくなる場合があります。",
"selected": "選択時",
"ctrlkey": "Ctrlキー"
},
"compact_mode": {
"title": "コンパクトモード",
"description": "アイコンのみ表示(テキスト非表示)"
}
},
"window": {
"title": "機能ウィンドウ",
"follow_toolbar": {
"title": "ツールバーに追従",
"description": "ウィンドウ位置をツールバーに連動(無効時は中央表示)"
},
"remember_size": {
"title": "サイズを記憶",
"description": "アプリケーション実行中、ウィンドウは最後に調整されたサイズで表示されます"
},
"auto_close": {
"title": "自動閉じる",
"description": "最前面固定されていない場合、フォーカス喪失時に自動閉じる"
},
"auto_pin": {
"title": "自動で最前面に固定",
"description": "デフォルトで最前面表示"
},
"opacity": {
"title": "透明度",
"description": "デフォルトの透明度を設定100%は完全不透明)"
}
},
"actions": {
"title": "機能設定",
"reset": {
"button": "リセット",
"tooltip": "デフォルト機能にリセット(カスタム機能は保持)",
"confirm": "デフォルト機能にリセットしますか?\nカスタム機能は削除されません"
},
"add_tooltip": {
"enabled": "カスタム機能を追加",
"disabled": "カスタム機能の上限に達しました (最大{{max}}個)"
},
"delete_confirm": "このカスタム機能を削除しますか?",
"drag_hint": "ドラッグで並べ替え (有効{{enabled}}/最大{{max}})"
},
"advanced": {
"title": "進階",
"filter_mode": {
"title": "アプリケーションフィルター",
"description": "特定のアプリケーションでのみ選択ツールを有効にするか、無効にするかを選択できます。",
"default": "オフ",
"whitelist": "ホワイトリスト",
"blacklist": "ブラックリスト"
},
"filter_list": {
"title": "フィルターリスト",
"description": "進階機能です。経験豊富なユーザー向けです。"
}
},
"user_modal": {
"title": {
"add": "カスタム機能追加",
"edit": "カスタム機能編集"
},
"name": {
"label": "機能名",
"hint": "機能名を入力"
},
"icon": {
"label": "アイコン",
"placeholder": "Lucideアイコン名を入力",
"error": "無効なアイコン名です",
"tooltip": "例: arrow-right小文字で入力",
"view_all": "全アイコンを表示",
"random": "ランダム選択"
},
"model": {
"label": "モデル",
"tooltip": "アシスタント使用時はシステムプロンプトとモデルパラメータも適用",
"default": "デフォルトモデル",
"assistant": "アシスタントを使用"
},
"assistant": {
"label": "アシスタント選択",
"default": "デフォルト"
},
"prompt": {
"label": "ユーザープロンプト",
"tooltip": "アシスタントのシステムプロンプトを上書きせず、入力補助として機能",
"placeholder": "{{text}}で選択テキストを参照(未入力時は末尾に追加)",
"placeholder_text": "プレースホルダー",
"copy_placeholder": "プレースホルダーをコピー"
}
},
"search_modal": {
"title": "検索エンジン設定",
"engine": {
"label": "検索エンジン",
"custom": "カスタム"
},
"custom": {
"name": {
"label": "表示名",
"hint": "検索エンジン名16文字以内",
"max_length": "16文字以内で入力"
},
"url": {
"label": "検索URL",
"hint": "{{queryString}}で検索語を表す",
"required": "URLを入力してください",
"invalid_format": "http:// または https:// で始まるURLを入力",
"missing_placeholder": "{{queryString}}を含めてください"
},
"test": "テスト"
}
},
"filter_modal": {
"title": "アプリケーションフィルターリスト",
"user_tips": "アプリケーションの実行ファイル名を1行ずつ入力してください。大文字小文字は区別しません。例: chrome.exe, weixin.exe, Cherry Studio.exe, など。"
}
}
}
}
}

View File

@@ -8,6 +8,7 @@
"add.name.placeholder": "Введите имя",
"add.prompt": "Промпт",
"add.prompt.placeholder": "Введите промпт",
"add.prompt.variables.tip": "Доступные переменные: {{date}}, {{time}}, {{datetime}}, {{system}}, {{arch}}, {{language}}, {{model_name}}",
"add.title": "Создать агента",
"delete.popup.content": "Вы уверены, что хотите удалить этого агента?",
"edit.message.add.title": "Добавить",
@@ -101,6 +102,22 @@
"titlePlaceholder": "Введите заголовок",
"contentLabel": "Содержание",
"contentPlaceholder": "Введите содержание фразы, поддерживает использование переменных, и нажмите Tab для быстрого перехода к переменной для изменения. Например: \nПомоги мне спланировать маршрут от ${from} до ${to} и отправить его на ${email}."
},
"list": {
"showByList": "Список",
"showByTags": "По тегам"
},
"tags": {
"untagged": "Несгруппированные метки",
"none": "Нет тегов",
"manage": "Управление тегами",
"add": "Добавить тег",
"modify": "Изменить тег",
"delete": "Удалить тег",
"deleteConfirm": "Вы уверены, что хотите удалить этот тег?",
"settings": {
"title": "Настройки тегов"
}
}
},
"auth": {
@@ -305,6 +322,7 @@
"translate": "Перевести",
"topics.export.siyuan": "Экспорт в Siyuan Note",
"topics.export.wait_for_title_naming": "Создание заголовка...",
"topics.export.obsidian_reasoning": "Включить цепочку рассуждений",
"topics.export.title_naming_success": "Заголовок успешно создан",
"topics.export.title_naming_failed": "Не удалось создать заголовок, используется заголовок по умолчанию",
"input.translating": "Перевод...",
@@ -569,7 +587,14 @@
"korean": "Корейский",
"portuguese": "Португальский",
"russian": "Русский",
"spanish": "Испанский"
"spanish": "Испанский",
"polish": "Польский",
"turkish": "Туркменский",
"thai": "Тайский",
"vietnamese": "Вьетнамский",
"indonesian": "Индонезийский",
"urdu": "Урду",
"malay": "Малайзийский"
},
"lmstudio": {
"keep_alive_time.description": "Время в минутах, в течение которого модель остается активной, по умолчанию 5 минут.",
@@ -610,6 +635,7 @@
"error.enter.api.key": "Пожалуйста, введите ваш API ключ",
"error.enter.model": "Пожалуйста, выберите модель",
"error.enter.name": "Пожалуйста, введите название базы знаний",
"error.fetchTopicName": "Не удалось назвать тему",
"error.get_embedding_dimensions": "Не удалось получить размерность встраивания",
"error.invalid.api.host": "Неверный API адрес",
"error.invalid.api.key": "Неверный API ключ",
@@ -820,14 +846,15 @@
"seed_desc_tip": "Одинаковые сиды и промпты могут генерировать похожие изображения, установка -1 будет создавать разные результаты каждый раз",
"title": "Изображения",
"magic_prompt_option": "Улучшение промпта",
"model": "Версия",
"model": "Модель",
"aspect_ratio": "Пропорции изображения",
"style_type": "Стиль",
"rendering_speed": "Скорость рендеринга",
"learn_more": "Узнать больше",
"prompt_placeholder_edit": "Введите ваше описание изображения, текстовая отрисовка использует двойные кавычки для обертки",
"paint_course":"Руководство / Учебник",
"proxy_required": "Сейчас необходимо открыть прокси для просмотра сгенерированных изображений, в будущем будет поддерживаться прямое соединение",
"prompt_placeholder_en": "Введите” английский “описание изображения, текстовая отрисовка использует двойные кавычки для обертки",
"paint_course": "Руководство / Учебник",
"proxy_required": "Открыть прокси и включить “TUN режим” для просмотра сгенерированных изображений или скопировать их в браузер для открытия. В будущем будет поддерживаться прямое соединение",
"image_file_required": "Пожалуйста, сначала загрузите изображение",
"image_file_retry": "Пожалуйста, сначала загрузите изображение",
"image_placeholder": "Изображение недоступно",
@@ -841,11 +868,39 @@
"3d": "3D",
"anime": "Аниме"
},
"quality_options": {
"auto": "Авто",
"low": "Низкое",
"medium": "Среднее",
"high": "Высокое"
},
"moderation_options": {
"auto": "Авто",
"low": "Низкое"
},
"background_options": {
"auto": "Авто",
"transparent": "Прозрачный",
"opaque": "Непрозрачный"
},
"rendering_speeds": {
"default": "По умолчанию",
"turbo": "Быстро",
"quality": "Качественно"
},
"aspect_ratios": {
"square": "Квадрат",
"portrait": "Портрет",
"landscape": "Пейзаж"
},
"person_generation_options": {
"allow_all": "Разрешено все",
"allow_adult": "Разрешено взрослые",
"allow_none": "Не разрешено"
},
"quality": "Качество",
"moderation": "Сенсорность",
"background": "Фон",
"mode": {
"generate": "Рисование",
"edit": "Редактирование",
@@ -859,7 +914,9 @@
"negative_prompt_tip": "Описывает, что вы не хотите видеть в изображении",
"magic_prompt_option_tip": "Интеллектуально оптимизирует подсказки для улучшения эффекта генерации",
"style_type_tip": "Стиль генерации изображений, доступен только для версий V_2 и выше",
"rendering_speed_tip": "Управляет балансом между скоростью рендеринга и качеством, доступно только для V_3"
"rendering_speed_tip": "Управляет балансом между скоростью рендеринга и качеством, доступно только для V_3",
"person_generation": "Генерация персонажа",
"person_generation_tip": "Разрешить модель генерировать изображения людей"
},
"edit": {
"image_file": "Изображение для редактирования",
@@ -892,8 +949,20 @@
"seed_tip": "Контролирует случайный характер увеличения изображений для воспроизводимых результатов",
"magic_prompt_option_tip": "Улучшает увеличение изображений с помощью интеллектуального оптимизирования промптов"
},
"rendering_speed": "Скорость рендеринга",
"text_desc_required": "Пожалуйста, сначала введите описание изображения"
"text_desc_required": "Пожалуйста, сначала введите описание изображения",
"req_error_text": "Операция не удалась, повторите попытку. Пожалуйста, избегайте защищенных авторским правом терминов и конфиденциальных слов в запросах.",
"auto_create_paint": "Автоматическое создание изображения",
"auto_create_paint_tip": "После генерации изображения будет автоматически создано новое.",
"select_model": "Выбрать модель",
"input_parameters": "Ввести параметры",
"input_image": "Входное изображение",
"generated_image": "Сгенерированное изображение",
"pricing": "Цены",
"model_and_pricing": "Модель и цены",
"per_image": "за изображение",
"per_images": "за изображения",
"required_field": "Обязательное поле",
"uploaded_input": "Загруженный ввод"
},
"prompts": {
"explanation": "Объясните мне этот концепт",
@@ -947,7 +1016,7 @@
"zhinao": "360AI",
"zhipu": "ZHIPU AI",
"voyageai": "Voyage AI",
"qiniu": "Qiniu",
"qiniu": "Qiniu AI",
"tokenflux": "TokenFlux"
},
"restore": {
@@ -1044,7 +1113,9 @@
"token": "Токен Joplin",
"token_placeholder": "Введите токен Joplin",
"url": "URL Joplin",
"url_placeholder": "http://127.0.0.1:41184/"
"url_placeholder": "http://127.0.0.1:41184/",
"export_reasoning.title": "Включить цепочку рассуждений при экспорте",
"export_reasoning.help": "Если включено, экспортируемый контент будет содержать цепочку рассуждений, сгенерированную ассистентом."
},
"markdown_export.force_dollar_math.help": "Если включено, при экспорте в Markdown для обозначения формул LaTeX будет принудительно использоваться $$. Примечание: Эта опция также влияет на все методы экспорта через Markdown, такие как Notion, Yuque и т.д.",
"markdown_export.force_dollar_math.title": "Принудительно использовать $$ для формул LaTeX",
@@ -1053,12 +1124,14 @@
"markdown_export.path_placeholder": "Путь экспорта",
"markdown_export.select": "Выбрать",
"markdown_export.title": "Экспорт в Markdown",
"markdown_export.show_model_name.title": "Использовать имя модели при экспорте",
"markdown_export.show_model_name.help": "Если включено, для создания заголовков экспортируемых сообщений будет использоваться модель именования темы. Примечание: Эта опция также влияет на все методы экспорта через Markdown, такие как Notion, Yuque и т.д.",
"markdown_export.show_model_provider.title": "Показать поставщика модели",
"markdown_export.show_model_provider.help": "Показывать поставщика модели (например, OpenAI, Gemini) при экспорте в Markdown",
"minute_interval_one": "{{count}} минута",
"minute_interval_other": "{{count}} минут",
"notion.api_key": "Ключ API Notion",
"notion.api_key_placeholder": "Введите ключ API Notion",
"notion.auto_split": "Автоматическое разбиение на страницы при экспорте диалога",
"notion.auto_split_tip": "Автоматическое разбиение на страницы при экспорте в Notion, если тема слишком длинная",
"notion.check": {
"button": "Проверить",
"empty_api_key": "Не настроен API key",
@@ -1072,10 +1145,9 @@
"notion.help": "Документация по настройке Notion",
"notion.page_name_key": "Название поля заголовка страницы",
"notion.page_name_key_placeholder": "Введите название поля заголовка страницы, по умолчанию Name",
"notion.split_size": "Размер автоматического разбиения",
"notion.split_size_help": "Рекомендуется 90 для пользователей бесплатной версии Notion, 24990 для пользователей премиум-версии, значение по умолчанию — 90",
"notion.split_size_placeholder": "Введите ограничение количества блоков на странице (по умолчанию 90)",
"notion.title": "Настройки Notion",
"notion.export_reasoning.title": "Включить цепочку рассуждений при экспорте",
"notion.export_reasoning.help": "При включении, содержимое цепочки рассуждений будет включено при экспорте в Notion.",
"title": "Настройки данных",
"webdav": {
"autoSync": "Автоматическое резервное копирование",
@@ -1212,6 +1284,7 @@
"display.sidebar.translate.icon": "Показывать иконку перевода",
"display.sidebar.visible": "Показывать иконки",
"display.title": "Настройки отображения",
"display.zoom.title": "Настройки масштаба",
"display.topic.title": "Настройки топиков",
"miniapps": {
"title": "Настройки мини-приложений",
@@ -1435,6 +1508,7 @@
"advancedSettings": "Расширенные настройки"
},
"messages.prompt": "Показывать подсказки",
"messages.tokens": "Показать использование токенов",
"messages.divider": "Показывать разделитель между сообщениями",
"messages.grid_columns": "Количество столбцов сетки сообщений",
"messages.grid_popover_trigger": "Триггер для отображения подробной информации в сетке",
@@ -1445,7 +1519,7 @@
"messages.input.send_shortcuts": "Горячие клавиши для отправки",
"messages.input.show_estimated_tokens": "Показывать затраты токенов",
"messages.input.title": "Настройки ввода",
"messages.input.enable_quick_triggers": "Включите '/' и '@', чтобы вызвать быстрое меню.",
"messages.input.enable_quick_triggers": "Включите / и @, чтобы вызвать быстрое меню.",
"messages.input.enable_delete_model": "Включите удаление модели/вложения с помощью клавиши Backspace",
"messages.markdown_rendering_input_message": "Отображение ввода в формате Markdown",
"messages.math_engine": "Математический движок",
@@ -1488,6 +1562,7 @@
"models.check.start": "Начать",
"models.check.title": "Проверка состояния моделей",
"models.check.use_all_keys": "Использовать все ключи",
"models.check.disclaimer": "Проверка состояния моделей требует отправки запросов, пожалуйста, используйте эту функцию с осторожностью. Модели, которые взимают плату за запросы, могут привести к дополнительным расходам, пожалуйста, самостоятельно несем ответственность за них.",
"models.default_assistant_model": "Модель ассистента по умолчанию",
"models.default_assistant_model_description": "Модель, используемая при создании нового ассистента, если ассистент не имеет настроенной модели, будет использоваться эта модель",
"models.empty": "Модели не найдены",
@@ -1629,10 +1704,11 @@
"zoom_out": "Уменьшить",
"zoom_reset": "Сбросить масштаб"
},
"theme.auto": "Автоматически",
"theme.system": "Системная",
"theme.dark": "Темная",
"theme.light": "Светлая",
"theme.title": "Тема",
"theme.color_primary": "Цвет темы",
"theme.window.style.opaque": "Непрозрачное окно",
"theme.window.style.title": "Стиль окна",
"theme.window.style.transparent": "Прозрачное окно",
@@ -1740,6 +1816,8 @@
},
"translate": {
"any.language": "Любой язык",
"target_language": "Целевой язык",
"alter_language": "Альтернативный язык",
"button.translate": "Перевести",
"close": "Закрыть",
"closed": "Перевод закрыт",
@@ -1762,13 +1840,22 @@
"input.placeholder": "Введите текст для перевода",
"output.placeholder": "Перевод",
"processing": "Перевод в процессе...",
"scroll_sync.disable": "Отключить синхронизацию прокрутки",
"scroll_sync.enable": "Включить синхронизацию прокрутки",
"language.same": "Исходный и целевой языки совпадают",
"language.not_pair": "Исходный язык отличается от настроенного",
"settings": {
"title": "Настройки перевода",
"model": "Настройки модели",
"model_desc": "Модель, используемая для службы перевода",
"bidirectional": "Настройки двунаправленного перевода",
"scroll_sync": "Настройки синхронизации прокрутки",
"bidirectional_tip": "Если включено, перевод будет выполняться в обоих направлениях, исходный текст будет переведен на целевой язык и наоборот."
},
"title": "Перевод",
"tooltip.newline": "Перевести",
"menu": {
"description": "Перевести содержимое текущего ввода"
}
},
"detected.language": "Обнаруженный язык"
},
"tray": {
"quit": "Выйти",
@@ -1780,6 +1867,174 @@
"quit": "Выйти",
"show_window": "Показать окно",
"visualization": "Визуализация"
},
"update": {
"title": "Обновление",
"message": "Новая версия {{version}} готова, установить сейчас?",
"later": "Позже",
"install": "Установить",
"noReleaseNotes": "Нет заметок об обновлении"
},
"selection": {
"name": "Помощник выбора",
"action": {
"builtin": {
"translate": "Перевести",
"explain": "Объяснить",
"summary": "Суммаризировать",
"search": "Поиск",
"refine": "Уточнить",
"copy": "Копировать"
},
"window": {
"pin": "Закрепить",
"pinned": "Закреплено",
"opacity": "Прозрачность окна",
"original_show": "Показать оригинал",
"original_hide": "Скрыть оригинал",
"original_copy": "Копировать оригинал",
"esc_close": "Esc - закрыть",
"esc_stop": "Esc - остановить",
"c_copy": "C - копировать",
"r_regenerate": "R - перегенерировать"
},
"translate": {
"smart_translate_tips": "Смарт-перевод: содержимое будет переведено на целевой язык; содержимое уже на целевом языке будет переведено на альтернативный язык"
}
},
"settings": {
"experimental": "Экспериментальные функции",
"enable": {
"title": "Включить",
"description": "Поддерживается только в Windows"
},
"toolbar": {
"title": "Панель инструментов",
"trigger_mode": {
"title": "Режим активации",
"description": "Показывать панель сразу при выделении или только при удержании Ctrl.",
"description_note": "В некоторых приложениях Ctrl может не работать. Если вы используете AHK или другие инструменты для переназначения Ctrl, это может привести к тому, что некоторые приложения не смогут выделить текст.",
"selected": "При выделении",
"ctrlkey": "По Ctrl"
},
"compact_mode": {
"title": "Компактный режим",
"description": "Отображать только иконки без текста"
}
},
"window": {
"title": "Окно действий",
"follow_toolbar": {
"title": "Следовать за панелью",
"description": "Окно будет следовать за панелью. Иначе - по центру."
},
"remember_size": {
"title": "Запомнить размер",
"description": "При отключенном режиме, окно будет восстанавливаться до последнего размера при запуске приложения"
},
"auto_close": {
"title": "Автозакрытие",
"description": "Закрывать окно при потере фокуса (если не закреплено)"
},
"auto_pin": {
"title": "Автозакрепление",
"description": "Закреплять окно по умолчанию"
},
"opacity": {
"title": "Прозрачность",
"description": "Установить прозрачность окна по умолчанию"
}
},
"actions": {
"title": "Действия",
"reset": {
"button": "Сбросить",
"tooltip": "Сбросить стандартные действия. Пользовательские останутся.",
"confirm": "Сбросить стандартные действия? Пользовательские останутся."
},
"add_tooltip": {
"enabled": "Добавить действие",
"disabled": "Достигнут лимит ({{max}})"
},
"delete_confirm": "Удалить это действие?",
"drag_hint": "Перетащите для сортировки. Включено: {{enabled}}/{{max}}"
},
"advanced": {
"title": "Расширенные",
"filter_mode": {
"title": "Режим фильтрации",
"description": "Можно ограничить выборку по определенным приложениям (белый список) или исключить их (черный список)",
"default": "Выключено",
"whitelist": "Белый список",
"blacklist": "Черный список"
},
"filter_list": {
"title": "Список фильтрации",
"description": "Расширенная функция, рекомендуется для пользователей с опытом"
}
},
"user_modal": {
"title": {
"add": "Добавить действие",
"edit": "Редактировать действие"
},
"name": {
"label": "Название",
"hint": "Введите название"
},
"icon": {
"label": "Иконка",
"placeholder": "Название иконки Lucide",
"error": "Некорректное название",
"tooltip": "Названия в lowercase, например arrow-right",
"view_all": "Все иконки",
"random": "Случайная"
},
"model": {
"label": "Модель",
"tooltip": "Использовать ассистента: будут применены его системные настройки",
"default": "По умолчанию",
"assistant": "Ассистент"
},
"assistant": {
"label": "Ассистент",
"default": "По умолчанию"
},
"prompt": {
"label": "Промпт",
"tooltip": "Дополняет ввод пользователя, не заменяя системный промпт ассистента",
"placeholder": "Используйте {{text}} для выделенного текста. Если пусто - текст будет добавлен",
"placeholder_text": "Плейсхолдер",
"copy_placeholder": "Копировать плейсхолдер"
}
},
"search_modal": {
"title": "Поисковая система",
"engine": {
"label": "Поисковик",
"custom": "Свой"
},
"custom": {
"name": {
"label": "Название",
"hint": "Название поисковика",
"max_length": "Не более 16 символов"
},
"url": {
"label": "URL поиска",
"hint": "Используйте {{queryString}} для представления поискового запроса",
"required": "Введите URL",
"invalid_format": "URL должен начинаться с http:// или https://",
"missing_placeholder": "Должен содержать {{queryString}}"
},
"test": "Тест"
}
},
"filter_modal": {
"title": "Список фильтрации",
"user_tips": "Введите имя исполняемого файла приложения, один на строку, не учитывая регистр, можно использовать подстановку *"
}
}
}
}
}

View File

@@ -8,6 +8,7 @@
"add.name.placeholder": "输入名称",
"add.prompt": "提示词",
"add.prompt.placeholder": "输入提示词",
"add.prompt.variables.tip": "可用的变量:{{date}}, {{time}}, {{datetime}}, {{system}}, {{arch}}, {{language}}, {{model_name}}",
"add.title": "创建智能体",
"import": {
"title": "从外部导入",
@@ -101,6 +102,22 @@
"titlePlaceholder": "输入标题",
"contentLabel": "内容",
"contentPlaceholder": "请输入短语内容支持使用变量然后按Tab键可以快速定位到变量进行修改。比如\n帮我规划从${from}到${to}的路线,然后发送到${email}"
},
"list": {
"showByList": "列表展示",
"showByTags": "标签展示"
},
"tags": {
"none": "暂无标签",
"manage": "标签管理",
"add": "添加标签",
"untagged": "未分组",
"modify": "修改标签",
"delete": "删除标签",
"deleteConfirm": "确定要删除这个标签吗?",
"settings": {
"title": "标签设置"
}
}
},
"auth": {
@@ -308,6 +325,7 @@
"topics.export.obsidian_no_vault_selected": "请先选择一个保管库",
"topics.export.obsidian_select_vault_first": "请先选择保管库",
"topics.export.obsidian_root_directory": "根目录",
"topics.export.obsidian_reasoning": "导出思维链",
"topics.export.title": "导出",
"topics.export.word": "导出为 Word",
"topics.export.yuque": "导出到语雀",
@@ -569,7 +587,14 @@
"korean": "韩文",
"portuguese": "葡萄牙文",
"russian": "俄文",
"spanish": "西班牙文"
"spanish": "西班牙文",
"polish": "波兰文",
"turkish": "土耳其文",
"thai": "泰文",
"vietnamese": "越南文",
"indonesian": "印尼文",
"urdu": "乌尔都文",
"malay": "马来文"
},
"lmstudio": {
"keep_alive_time.description": "对话后模型在内存中保持的时间默认5分钟",
@@ -610,6 +635,7 @@
"error.enter.api.key": "请输入您的 API 密钥",
"error.enter.model": "请选择一个模型",
"error.enter.name": "请输入知识库名称",
"error.fetchTopicName": "话题命名失败",
"error.get_embedding_dimensions": "获取嵌入维度失败",
"error.invalid.api.host": "无效的 API 地址",
"error.invalid.api.key": "无效的 API 密钥",
@@ -629,7 +655,7 @@
"group.delete.content": "删除分组消息会删除用户提问和所有助手的回答",
"group.delete.title": "删除分组消息",
"ignore.knowledge.base": "联网模式开启,忽略知识库",
"info.notion.block_reach_limit": "对话过长,正在分导出到Notion",
"info.notion.block_reach_limit": "对话过长,正在分导出到Notion",
"loading.notion.exporting_progress": "正在导出到Notion ({{current}}/{{total}})...",
"loading.notion.preparing": "正在准备导出到Notion...",
"mention.title": "切换模型回答",
@@ -820,14 +846,15 @@
"seed_desc_tip": "相同的种子和提示词可以生成相似的图片,设置 -1 每次生成都不一样",
"title": "图片",
"magic_prompt_option": "提示词增强",
"model": "版本",
"model": "模型",
"aspect_ratio": "画幅比例",
"style_type": "风格",
"rendering_speed": "渲染速度",
"learn_more": "了解更多",
"paint_course":"教程",
"paint_course": "教程",
"prompt_placeholder_edit": "输入你的图片描述,文本绘制用 \"双引号\" 包裹",
"proxy_required": "目前需要打开代理才能查看生成图片,后续会支持国内直连",
"prompt_placeholder_en": "输入”英文“图片描述,目前 Imagen 仅支持英文提示词",
"proxy_required": "打开代理并开启”TUN模式“查看生成图片或复制到浏览器打开后续会支持国内直连",
"image_file_required": "请先上传图片",
"image_file_retry": "请重新上传图片",
"image_placeholder": "暂无图片",
@@ -846,11 +873,39 @@
"turbo": "快速",
"quality": "高质量"
},
"quality_options": {
"auto": "自动",
"low": "低",
"medium": "中",
"high": "高"
},
"moderation_options": {
"auto": "自动",
"low": "低"
},
"background_options": {
"auto": "自动",
"transparent": "透明",
"opaque": "不透明"
},
"person_generation_options": {
"allow_all": "允许所有",
"allow_adult": "允许成人",
"allow_none": "不允许"
},
"aspect_ratios": {
"square": "方形",
"portrait": "竖图",
"landscape": "横图"
},
"quality": "质量",
"moderation": "敏感度",
"background": "背景",
"mode": {
"generate": "绘图",
"edit": "编辑",
"remix": "混合",
"upscale": "放大"
"upscale": "高清增强"
},
"generate": {
"model_tip": "模型版本V3 为最新版本V2 为之前版本V2A 为快速模型、V_1 为初代模型_TURBO 为加速版本",
@@ -859,7 +914,9 @@
"negative_prompt_tip": "描述不想在图像中出现的元素,仅支持 V_1、V_1_TURBO、V_2 和 V_2_TURBO 版本",
"magic_prompt_option_tip": "智能优化提示词以提升生成效果",
"style_type_tip": "图像生成风格,仅适用于 V_2 及以上版本",
"rendering_speed_tip": "控制渲染速度与质量的平衡,仅适用于 V_3 版本"
"rendering_speed_tip": "控制渲染速度与质量的平衡,仅适用于 V_3 版本",
"person_generation": "生成人物",
"person_generation_tip": "允许模型生成人物图像"
},
"edit": {
"image_file": "编辑的图像",
@@ -892,7 +949,20 @@
"seed_tip": "控制放大结果的随机性",
"magic_prompt_option_tip": "智能优化放大提示词"
},
"text_desc_required": "请先输入图片描述"
"text_desc_required": "请先输入图片描述",
"req_error_text": "运行失败,请重试。提示词避免“版权词”和”敏感词”哦。",
"auto_create_paint": "自动新建图片",
"auto_create_paint_tip": "在图片生成后,会自动新建图片",
"select_model": "选择模型",
"input_parameters": "输入参数",
"input_image": "输入图片",
"generated_image": "生成图片",
"pricing": "定价",
"model_and_pricing": "模型与定价",
"per_image": "每张图片",
"per_images": "每张图片",
"required_field": "必填项",
"uploaded_input": "已上传输入"
},
"prompts": {
"explanation": "帮我解释一下这个概念",
@@ -946,7 +1016,7 @@
"zhinao": "360智脑",
"zhipu": "智谱AI",
"voyageai": "Voyage AI",
"qiniu": "七牛云",
"qiniu": "七牛云 AI 推理",
"tokenflux": "TokenFlux"
},
"restore": {
@@ -1045,7 +1115,9 @@
"token": "Joplin 授权令牌",
"token_placeholder": "请输入 Joplin 授权令牌",
"url": "Joplin 剪裁服务监听 URL",
"url_placeholder": "http://127.0.0.1:41184/"
"url_placeholder": "http://127.0.0.1:41184/",
"export_reasoning.title": "导出时包含思维链",
"export_reasoning.help": "开启后导出到Joplin时会包含思维链内容。"
},
"markdown_export.force_dollar_math.help": "开启后导出Markdown时会将强制使用$$来标记LaTeX公式。注意该项也会影响所有通过Markdown导出的方式如Notion、语雀等",
"markdown_export.force_dollar_math.title": "强制使用$$来标记LaTeX公式",
@@ -1054,14 +1126,16 @@
"markdown_export.path_placeholder": "导出路径",
"markdown_export.select": "选择",
"markdown_export.title": "Markdown 导出",
"markdown_export.show_model_name.title": "导出时使用模型名称",
"markdown_export.show_model_name.help": "开启后使用话题命名模型为导出的消息创建标题。注意该项也会影响所有通过Markdown导出的方式如Notion、语雀等。",
"markdown_export.show_model_provider.title": "显示模型供应商",
"markdown_export.show_model_provider.help": "在导出Markdown时显示模型供应商如OpenAI、Gemini等",
"message_title.use_topic_naming.title": "使用话题命名模型为导出的消息创建标题",
"message_title.use_topic_naming.help": "开启后使用话题命名模型为导出的消息创建标题。该项也会影响所有通过Markdown导出的方式",
"minute_interval_one": "{{count}} 分钟",
"minute_interval_other": "{{count}} 分钟",
"notion.api_key": "Notion 密钥",
"notion.api_key_placeholder": "请输入Notion 密钥",
"notion.auto_split": "导出对话时自动分页",
"notion.auto_split_tip": "当要导出的话题过长时自动分页导出到Notion",
"notion.check": {
"button": "检测",
"empty_api_key": "未配置 API key",
@@ -1075,10 +1149,9 @@
"notion.help": "Notion 配置文档",
"notion.page_name_key": "页面标题字段名",
"notion.page_name_key_placeholder": "请输入页面标题字段名,默认为 Name",
"notion.split_size": "自动分页大小",
"notion.split_size_help": "Notion免费版用户建议设置为90高级版用户建议设置为24990默认值为90",
"notion.split_size_placeholder": "请输入每页块数限制(默认90)",
"notion.title": "Notion 配置",
"notion.title": "Notion 设置",
"notion.export_reasoning.title": "导出时包含思维链",
"notion.export_reasoning.help": "开启后导出到Notion时会包含思维链内容。",
"title": "数据设置",
"webdav": {
"autoSync": "自动备份",
@@ -1213,6 +1286,7 @@
"display.sidebar.translate.icon": "显示翻译图标",
"display.sidebar.visible": "显示的图标",
"display.title": "显示设置",
"display.zoom.title": "缩放设置",
"display.topic.title": "话题设置",
"miniapps": {
"title": "小程序设置",
@@ -1438,6 +1512,7 @@
"advancedSettings": "高级设置"
},
"messages.prompt": "显示提示词",
"messages.tokens": "显示Token用量",
"messages.divider": "消息分割线",
"messages.grid_columns": "消息网格展示列数",
"messages.grid_popover_trigger": "网格详情触发",
@@ -1448,7 +1523,7 @@
"messages.input.send_shortcuts": "发送快捷键",
"messages.input.show_estimated_tokens": "显示预估 Token 数",
"messages.input.title": "输入设置",
"messages.input.enable_quick_triggers": "启用 '/' 和 '@' 触发快捷菜单",
"messages.input.enable_quick_triggers": "启用 / 和 @ 触发快捷菜单",
"messages.input.enable_delete_model": "启用删除键删除输入的模型/附件",
"messages.markdown_rendering_input_message": "Markdown 渲染输入消息",
"messages.math_engine": "数学公式引擎",
@@ -1491,6 +1566,7 @@
"models.check.start": "开始",
"models.check.title": "模型健康检测",
"models.check.use_all_keys": "使用密钥",
"models.check.disclaimer": "健康检查需要发送请求,请谨慎使用。按次收费的模型可能产生更多费用,请自行承担。",
"models.default_assistant_model": "默认助手模型",
"models.default_assistant_model_description": "创建新助手时使用的模型,如果助手未设置模型,则使用此模型",
"models.empty": "没有模型",
@@ -1638,10 +1714,11 @@
"zoom_out": "缩小界面",
"zoom_reset": "重置缩放"
},
"theme.auto": "自动",
"theme.system": "系统",
"theme.dark": "深色",
"theme.light": "浅色",
"theme.title": "主题",
"theme.color_primary": "主题颜色",
"theme.window.style.opaque": "不透明窗口",
"theme.window.style.title": "窗口样式",
"theme.window.style.transparent": "透明窗口",
@@ -1739,6 +1816,8 @@
},
"translate": {
"any.language": "任意语言",
"target_language": "目标语言",
"alter_language": "备用语言",
"button.translate": "翻译",
"close": "关闭",
"closed": "翻译已关闭",
@@ -1764,10 +1843,19 @@
"input.placeholder": "输入文本进行翻译",
"output.placeholder": "翻译",
"processing": "翻译中...",
"scroll_sync.disable": "关闭滚动同步",
"scroll_sync.enable": "开启滚动同步",
"language.same": "源语言和目标语言相同",
"language.not_pair": "源语言与设置的语言不同",
"settings": {
"title": "翻译设置",
"model": "模型设置",
"model_desc": "翻译服务使用的模型",
"bidirectional": "双向翻译设置",
"bidirectional_tip": "开启后,仅支持在源语言和目标语言之间进行双向翻译",
"scroll_sync": "滚动同步设置"
},
"title": "翻译",
"tooltip.newline": "换行"
"tooltip.newline": "换行",
"detected.language": "检测到的语言"
},
"tray": {
"quit": "退出",
@@ -1779,6 +1867,174 @@
"quit": "退出",
"show_window": "显示窗口",
"visualization": "可视化"
},
"update": {
"title": "更新提示",
"message": "发现新版本 {{version}},是否立即安装?",
"later": "稍后",
"install": "立即安装",
"noReleaseNotes": "暂无更新日志"
},
"selection": {
"name": "划词助手",
"action": {
"builtin": {
"translate": "翻译",
"explain": "解释",
"summary": "总结",
"search": "搜索",
"refine": "优化",
"copy": "复制"
},
"window": {
"pin": "置顶",
"pinned": "已置顶",
"opacity": "窗口透明度",
"original_show": "显示原文",
"original_hide": "隐藏原文",
"original_copy": "复制原文",
"esc_close": "Esc 关闭",
"esc_stop": "Esc 停止",
"c_copy": "C 复制",
"r_regenerate": "R 重新生成"
},
"translate": {
"smart_translate_tips": "智能翻译:内容将优先翻译为目标语言;内容已是目标语言的,将翻译为备选语言"
}
},
"settings": {
"experimental": "实验性功能",
"enable": {
"title": "启用",
"description": "当前仅支持 Windows 系统"
},
"toolbar": {
"title": "工具栏",
"trigger_mode": {
"title": "触发方式",
"description": "划词立即显示工具栏,或者划词后按住 Ctrl 键才显示工具栏。",
"description_note": "少数应用不支持通过 Ctrl 键划词。若使用了AHK等工具对 Ctrl 键进行了重映射,可能导致部分应用无法划词。",
"selected": "划词",
"ctrlkey": "Ctrl 键"
},
"compact_mode": {
"title": "紧凑模式",
"description": "紧凑模式下,只显示图标,不显示文字"
}
},
"window": {
"title": "功能窗口",
"follow_toolbar": {
"title": "跟随工具栏",
"description": "窗口位置将跟随工具栏显示,禁用后则始终居中显示"
},
"remember_size": {
"title": "记住大小",
"description": "应用运行期间,窗口会按上次调整的大小显示"
},
"auto_close": {
"title": "自动关闭",
"description": "当窗口未置顶且失去焦点时,将自动关闭该窗口"
},
"auto_pin": {
"title": "自动置顶",
"description": "默认将窗口置于顶部"
},
"opacity": {
"title": "透明度",
"description": "设置窗口的默认透明度100%为完全不透明"
}
},
"actions": {
"title": "功能",
"reset": {
"button": "重置",
"tooltip": "重置为默认功能,自定义功能不会被删除",
"confirm": "确定要重置为默认功能吗?自定义功能不会被删除。"
},
"add_tooltip": {
"enabled": "添加自定义功能",
"disabled": "自定义功能已达上限 ({{max}}个)"
},
"delete_confirm": "确定要删除这个自定义功能吗?",
"drag_hint": "拖拽排序,移动到上方以启用功能 ({{enabled}}/{{max}})"
},
"advanced": {
"title": "高级",
"filter_mode": {
"title": "应用筛选",
"description": "可以限制划词助手只在特定应用中生效(白名单)或不生效(黑名单)",
"default": "关闭",
"whitelist": "白名单",
"blacklist": "黑名单"
},
"filter_list": {
"title": "筛选名单",
"description": "高级功能,建议有经验的用户在了解的情况下再进行设置"
}
},
"user_modal": {
"title": {
"add": "添加自定义功能",
"edit": "编辑自定义功能"
},
"name": {
"label": "名称",
"hint": "请输入功能名称"
},
"icon": {
"label": "图标",
"placeholder": "输入 Lucide 图标名称",
"error": "无效的图标名称,请检查输入",
"tooltip": "Lucide图标名称为小写如 arrow-right",
"view_all": "查看所有图标",
"random": "随机图标"
},
"model": {
"label": "模型",
"tooltip": "使用助手:会同时使用助手的系统提示词和模型参数",
"default": "默认模型",
"assistant": "使用助手"
},
"assistant": {
"label": "选择助手",
"default": "默认"
},
"prompt": {
"label": "用户提示词(Prompt)",
"tooltip": "用户提示词,作为用户输入的补充,不会覆盖助手的系统提示词",
"placeholder": "使用占位符{{text}}代表选中的文本,不填写时,选中的文本将添加到本提示词的末尾",
"placeholder_text": "占位符",
"copy_placeholder": "复制占位符"
}
},
"search_modal": {
"title": "设置搜索引擎",
"engine": {
"label": "搜索引擎",
"custom": "自定义"
},
"custom": {
"name": {
"label": "自定义名称",
"hint": "请输入搜索引擎名称",
"max_length": "名称不能超过16个字符"
},
"url": {
"label": "自定义搜索 URL",
"hint": "用 {{queryString}} 代表搜索词",
"required": "请输入搜索 URL",
"invalid_format": "请输入以 http:// 或 https:// 开头的有效 URL",
"missing_placeholder": "URL 必须包含 {{queryString}} 占位符"
},
"test": "测试"
}
},
"filter_modal": {
"title": "应用筛选名单",
"user_tips": "请输入应用的执行文件名每行一个不区分大小写可以模糊匹配。例如chrome.exe、weixin.exe、Cherry Studio.exe等"
}
}
}
}
}

View File

@@ -8,6 +8,7 @@
"add.name.placeholder": "輸入名稱",
"add.prompt": "提示詞",
"add.prompt.placeholder": "輸入提示詞",
"add.prompt.variables.tip": "可用的變數:{{date}}, {{time}}, {{datetime}}, {{system}}, {{arch}}, {{language}}, {{model_name}}",
"add.title": "建立智慧代理人",
"import": {
"title": "從外部導入",
@@ -99,6 +100,22 @@
"settings.knowledge_base.recognition": "調用知識庫",
"settings.knowledge_base.recognition.off": "強制檢索",
"settings.knowledge_base.recognition.on": "意圖識別",
"list": {
"showByList": "列表展示",
"showByTags": "標籤展示"
},
"tags": {
"untagged": "未分組",
"none": "暫無標籤",
"manage": "標籤管理",
"add": "添加標籤",
"modify": "修改標籤",
"delete": "刪除標籤",
"deleteConfirm": "確定要刪除這個標籤嗎?",
"settings": {
"title": "標籤設定"
}
},
"settings.tool_use_mode": "工具調用方式",
"settings.tool_use_mode.function": "函數",
"settings.tool_use_mode.prompt": "提示詞"
@@ -305,6 +322,7 @@
"translate": "翻譯",
"topics.export.siyuan": "匯出到思源筆記",
"topics.export.wait_for_title_naming": "正在生成標題...",
"topics.export.obsidian_reasoning": "包含思維鏈",
"topics.export.title_naming_success": "標題生成成功",
"topics.export.title_naming_failed": "標題生成失敗,使用預設標題",
"input.translating": "翻譯中...",
@@ -569,7 +587,14 @@
"korean": "韓文",
"portuguese": "葡萄牙文",
"russian": "俄文",
"spanish": "西班牙文"
"spanish": "西班牙文",
"polish": "波蘭文",
"turkish": "土耳其文",
"thai": "泰文",
"vietnamese": "越南文",
"indonesian": "印尼文",
"urdu": "烏爾都文",
"malay": "馬來文"
},
"lmstudio": {
"keep_alive_time.description": "對話後模型在記憶體中保持的時間(預設為 5 分鐘)",
@@ -598,12 +623,11 @@
"citations": "引用內容",
"copied": "已複製!",
"copy.failed": "複製失敗",
"copy.success": "複製",
"copy.success": "複製成功",
"delete.confirm.title": "刪除確認",
"delete.confirm.content": "確認刪除選中的 {{count}} 條訊息嗎?",
"delete.failed": "刪除失敗",
"delete.success": "刪除成功",
"copy.success": "複製成功",
"empty_url": "無法下載圖片,可能是提示詞包含敏感內容或違禁詞彙",
"error.chunk_overlap_too_large": "分段重疊不能大於分段大小",
"error.dimension_too_large": "內容尺寸過大",
@@ -611,6 +635,7 @@
"error.enter.api.key": "請先輸入您的 API 金鑰",
"error.enter.model": "請先選擇一個模型",
"error.enter.name": "請先輸入知識庫名稱",
"error.fetchTopicName": "話題命名失敗",
"error.get_embedding_dimensions": "取得嵌入維度失敗",
"error.invalid.api.host": "無效的 API 位址",
"error.invalid.api.key": "無效的 API 金鑰",
@@ -821,13 +846,14 @@
"seed_desc_tip": "相同的種子和提示詞可以生成相似的圖片,設置 -1 每次生成都不一樣",
"title": "繪圖",
"magic_prompt_option": "提示詞增強",
"model": "版本",
"model": "模型",
"aspect_ratio": "畫幅比例",
"style_type": "風格",
"learn_more": "了解更多",
"prompt_placeholder_edit": "輸入你的圖片描述,文本繪製用 '雙引號' 包裹",
"paint_course":"教程",
"proxy_required": "目前需要打開代理才能查看生成圖片,後續會支持國內直連",
"prompt_placeholder_en": "輸入”英文“圖片描述,目前 Imagen 僅支持英文提示詞",
"paint_course": "教程",
"proxy_required": "打開代理並開啟”TUN模式“查看生成圖片或複製到瀏覽器開啟後續會支持國內直連",
"image_file_required": "請先上傳圖片",
"image_file_retry": "請重新上傳圖片",
"image_placeholder": "無圖片",
@@ -846,6 +872,34 @@
"turbo": "快速",
"quality": "高品質"
},
"quality_options": {
"auto": "自動",
"low": "低",
"medium": "中",
"high": "高"
},
"moderation_options": {
"auto": "自動",
"low": "低"
},
"background_options": {
"auto": "自動",
"transparent": "透明",
"opaque": "不透明"
},
"aspect_ratios": {
"square": "方形",
"portrait": "豎圖",
"landscape": "橫圖"
},
"person_generation_options": {
"allow_all": "允許所有",
"allow_adult": "允許成人",
"allow_none": "不允許"
},
"quality": "品質",
"moderation": "敏感度",
"background": "背景",
"mode": {
"generate": "繪圖",
"edit": "編輯",
@@ -859,7 +913,9 @@
"negative_prompt_tip": "描述不想在圖像中出現的內容",
"magic_prompt_option_tip": "智能優化生成效果的提示詞",
"style_type_tip": "圖像生成風格,僅適用於 V_2 及以上版本",
"rendering_speed_tip": "控制渲染速度與品質之間的平衡僅適用於V_3版本"
"rendering_speed_tip": "控制渲染速度與品質之間的平衡僅適用於V_3版本",
"person_generation": "人物生成",
"person_generation_tip": "允許模型生成人物圖像"
},
"edit": {
"image_file": "編輯圖像",
@@ -893,7 +949,20 @@
"magic_prompt_option_tip": "智能優化放大提示詞"
},
"rendering_speed": "渲染速度",
"text_desc_required": "請先輸入圖片描述"
"text_desc_required": "請先輸入圖片描述",
"req_error_text": "运行失败,请重试。提示词避免“版权词”和”敏感词”哦。",
"auto_create_paint": "自動新增圖片",
"auto_create_paint_tip": "圖片生成後,會自動新增圖片",
"select_model": "選擇模型",
"input_parameters": "輸入參數",
"input_image": "輸入圖片",
"generated_image": "生成圖片",
"pricing": "定價",
"model_and_pricing": "模型與定價",
"per_image": "每張圖片",
"per_images": "每張圖片",
"required_field": "必填欄位",
"uploaded_input": "已上傳輸入"
},
"prompts": {
"explanation": "幫我解釋一下這個概念",
@@ -947,7 +1016,7 @@
"zhinao": "360 智腦",
"zhipu": "智譜 AI",
"voyageai": "Voyage AI",
"qiniu": "七牛雲",
"qiniu": "七牛雲 AI 推理",
"tokenflux": "TokenFlux"
},
"restore": {
@@ -1046,7 +1115,9 @@
"token": "Joplin 授權Token",
"token_placeholder": "請輸入 Joplin 授權Token",
"url": "Joplin 剪輯服務 URL",
"url_placeholder": "http://127.0.0.1:41184/"
"url_placeholder": "http://127.0.0.1:41184/",
"export_reasoning.title": "匯出時包含思維鏈",
"export_reasoning.help": "啟用後,匯出內容將包含助手生成的思維鏈(思考過程)。"
},
"markdown_export.force_dollar_math.help": "開啟後匯出Markdown時會強制使用$$來標記LaTeX公式。注意該項也會影響所有透過Markdown匯出的方式如Notion、語雀等",
"markdown_export.force_dollar_math.title": "LaTeX公式強制使用$$",
@@ -1055,12 +1126,14 @@
"markdown_export.path_placeholder": "匯出路徑",
"markdown_export.select": "選擇",
"markdown_export.title": "Markdown 匯出",
"markdown_export.show_model_name.title": "匯出時使用模型名稱",
"markdown_export.show_model_name.help": "啟用後將以主題命名模型為匯出的訊息建立標題。注意該項也會影響所有透過Markdown匯出的方式如Notion、語雀等。",
"markdown_export.show_model_provider.title": "顯示模型供應商",
"markdown_export.show_model_provider.help": "在匯出Markdown時顯示模型供應商如OpenAI、Gemini等",
"minute_interval_one": "{{count}} 分鐘",
"minute_interval_other": "{{count}} 分鐘",
"notion.api_key": "Notion 金鑰",
"notion.api_key_placeholder": "請輸入 Notion 金鑰",
"notion.auto_split": "匯出對話時自動分頁",
"notion.auto_split_tip": "當要匯出的話題過長時自動分頁匯出到 Notion",
"notion.check": {
"button": "檢查",
"empty_api_key": "未設定 API key",
@@ -1074,10 +1147,9 @@
"notion.help": "Notion 設定文件",
"notion.page_name_key": "頁面標題欄位名稱",
"notion.page_name_key_placeholder": "請輸入頁面標題欄位名稱,預設為 Name",
"notion.split_size": "自動分頁大小",
"notion.split_size_help": "Notion 免費版使用者建議設定為 90進階版使用者建議設定為 24990預設值為 90",
"notion.split_size_placeholder": "請輸入每頁塊數限制 (預設 90)",
"notion.title": "Notion 設定",
"notion.export_reasoning.title": "匯出時包含思維鏈",
"notion.export_reasoning.help": "啟用後匯出到Notion時會包含思維鏈內容。",
"title": "資料設定",
"webdav": {
"autoSync": "自動備份",
@@ -1214,6 +1286,7 @@
"display.sidebar.translate.icon": "顯示翻譯圖示",
"display.sidebar.visible": "顯示的圖示",
"display.title": "顯示設定",
"display.zoom.title": "縮放設定",
"display.topic.title": "話題設定",
"miniapps": {
"title": "小程式設置",
@@ -1438,6 +1511,7 @@
"advancedSettings": "高級設定"
},
"messages.prompt": "提示詞顯示",
"messages.tokens": "Token用量顯示",
"messages.divider": "訊息間顯示分隔線",
"messages.grid_columns": "訊息網格展示列數",
"messages.grid_popover_trigger": "網格詳細資訊觸發",
@@ -1448,7 +1522,7 @@
"messages.input.send_shortcuts": "傳送快捷鍵",
"messages.input.show_estimated_tokens": "顯示預估 Token 數",
"messages.input.title": "輸入設定",
"messages.input.enable_quick_triggers": "啟用 '/' 和 '@' 觸發快捷選單",
"messages.input.enable_quick_triggers": "啟用 / 和 @ 觸發快捷選單",
"messages.input.enable_delete_model": "啟用刪除鍵刪除模型/附件",
"messages.markdown_rendering_input_message": "Markdown 渲染輸入訊息",
"messages.math_engine": "數學公式引擎",
@@ -1491,6 +1565,7 @@
"models.check.start": "開始",
"models.check.title": "模型健康檢查",
"models.check.use_all_keys": "使用密鑰",
"models.check.disclaimer": "健康檢查需要發送請求,請謹慎使用。按次收費的模型可能產生更多費用,請自行承擔。",
"models.default_assistant_model": "預設助手模型",
"models.default_assistant_model_description": "建立新助手時使用的模型,如果助手未設定模型,則使用此模型",
"models.empty": "找不到模型",
@@ -1632,10 +1707,11 @@
"zoom_reset": "重設縮放",
"exit_fullscreen": "退出螢幕"
},
"theme.auto": "自動",
"theme.system": "系統",
"theme.dark": "深色",
"theme.light": "淺色",
"theme.title": "主題",
"theme.color_primary": "主題顏色",
"theme.window.style.opaque": "不透明視窗",
"theme.window.style.title": "視窗樣式",
"theme.window.style.transparent": "透明視窗",
@@ -1740,6 +1816,8 @@
},
"translate": {
"any.language": "任意語言",
"target_language": "目標語言",
"alter_language": "備用語言",
"button.translate": "翻譯",
"close": "關閉",
"closed": "翻譯已關閉",
@@ -1762,13 +1840,22 @@
"input.placeholder": "輸入文字進行翻譯",
"output.placeholder": "翻譯",
"processing": "翻譯中...",
"scroll_sync.disable": "關閉滾動同步",
"scroll_sync.enable": "開啟滾動同步",
"language.same": "源語言和目標語言相同",
"language.not_pair": "源語言與設定的語言不同",
"settings": {
"title": "翻譯設定",
"model": "模型設定",
"model_desc": "翻譯服務使用的模型",
"bidirectional": "雙向翻譯設定",
"bidirectional_tip": "開啟後,僅支援在源語言和目標語言之間進行雙向翻譯",
"scroll_sync": "滾動同步設定"
},
"title": "翻譯",
"tooltip.newline": "換行",
"menu": {
"description": "對當前輸入框內容進行翻譯"
}
},
"detected.language": "檢測到的語言"
},
"tray": {
"quit": "結束",
@@ -1780,6 +1867,174 @@
"quit": "結束",
"show_window": "顯示視窗",
"visualization": "視覺化"
},
"update": {
"title": "更新提示",
"message": "新版本 {{version}} 已準備就緒,是否立即安裝?",
"later": "稍後",
"install": "立即安裝",
"noReleaseNotes": "暫無更新日誌"
},
"selection": {
"name": "劃詞助手",
"action": {
"builtin": {
"translate": "翻譯",
"explain": "解釋",
"summary": "總結",
"search": "搜尋",
"refine": "優化",
"copy": "複製"
},
"window": {
"pin": "置頂",
"pinned": "已置頂",
"opacity": "視窗透明度",
"original_show": "顯示原文",
"original_hide": "隱藏原文",
"original_copy": "複製原文",
"esc_close": "Esc 關閉",
"esc_stop": "Esc 停止",
"c_copy": "C 複製",
"r_regenerate": "R 重新生成"
},
"translate": {
"smart_translate_tips": "智能翻譯:內容將優先翻譯為目標語言;內容已是目標語言的,將翻譯為備用語言"
}
},
"settings": {
"experimental": "實驗性功能",
"enable": {
"title": "啟用",
"description": "目前僅支援 Windows 系統"
},
"toolbar": {
"title": "工具列",
"trigger_mode": {
"title": "觸發方式",
"description": "劃詞立即顯示工具列,或者劃詞後按住 Ctrl 鍵才顯示工具列。",
"description_note": "在某些應用中可能無法透過 Ctrl 鍵劃詞。若使用了AHK等工具對Ctrl鍵進行了重新對應可能導致部分應用程式無法劃詞。",
"selected": "劃詞",
"ctrlkey": "Ctrl 鍵"
},
"compact_mode": {
"title": "緊湊模式",
"description": "緊湊模式下,只顯示圖示,不顯示文字"
}
},
"window": {
"title": "功能視窗",
"follow_toolbar": {
"title": "跟隨工具列",
"description": "視窗位置將跟隨工具列顯示,停用後則始終置中顯示"
},
"remember_size": {
"title": "記住大小",
"description": "應用運行期間,視窗會按上次調整的大小顯示"
},
"auto_close": {
"title": "自動關閉",
"description": "當視窗未置頂且失去焦點時,將自動關閉該視窗"
},
"auto_pin": {
"title": "自動置頂",
"description": "預設將視窗置於頂部"
},
"opacity": {
"title": "透明度",
"description": "設置視窗的默認透明度100%為完全不透明"
}
},
"actions": {
"title": "功能",
"reset": {
"button": "重設",
"tooltip": "重設為預設功能,自訂功能不會被刪除",
"confirm": "確定要重設為預設功能嗎?自訂功能不會被刪除。"
},
"add_tooltip": {
"enabled": "新增自訂功能",
"disabled": "自訂功能已達上限 ({{max}}個)"
},
"delete_confirm": "確定要刪除這個自訂功能嗎?",
"drag_hint": "拖曳排序,移動到上方以啟用功能 ({{enabled}}/{{max}})"
},
"advanced": {
"title": "進階",
"filter_mode": {
"title": "應用篩選",
"description": "可以限制劃詞助手只在特定應用中生效(白名單)或不生效(黑名單)",
"default": "關閉",
"whitelist": "白名單",
"blacklist": "黑名單"
},
"filter_list": {
"title": "篩選名單",
"description": "進階功能,建議有經驗的用戶在了解情況下再進行設置"
}
},
"user_modal": {
"title": {
"add": "新增自訂功能",
"edit": "編輯自訂功能"
},
"name": {
"label": "名稱",
"hint": "請輸入功能名稱"
},
"icon": {
"label": "圖示",
"placeholder": "輸入 Lucide 圖示名稱",
"error": "無效的圖示名稱,請檢查輸入",
"tooltip": "Lucide圖示名稱為小寫如 arrow-right",
"view_all": "檢視所有圖示",
"random": "隨機圖示"
},
"model": {
"label": "模型",
"tooltip": "使用助手:會同時使用助手的系統提示詞和模型參數",
"default": "預設模型",
"assistant": "使用助手"
},
"assistant": {
"label": "選擇助手",
"default": "預設"
},
"prompt": {
"label": "使用者提示詞(Prompt)",
"tooltip": "使用者提示詞,作為使用者輸入的補充,不會覆蓋助手的系統提示詞",
"placeholder": "使用佔位符{{text}}代表選取的文字,不填寫時,選取的文字將加到本提示詞的末尾",
"placeholder_text": "佔位符",
"copy_placeholder": "複製佔位符"
}
},
"search_modal": {
"title": "設定搜尋引擎",
"engine": {
"label": "搜尋引擎",
"custom": "自訂"
},
"custom": {
"name": {
"label": "自訂名稱",
"hint": "請輸入搜尋引擎名稱",
"max_length": "名稱不能超過16個字元"
},
"url": {
"label": "自訂搜尋 URL",
"hint": "使用 {{queryString}} 代表搜尋詞",
"required": "請輸入搜尋 URL",
"invalid_format": "請輸入以 http:// 或 https:// 開頭的有效 URL",
"missing_placeholder": "URL 必須包含 {{queryString}} 佔位符"
},
"test": "測試"
}
},
"filter_modal": {
"title": "應用篩選名單",
"user_tips": "請輸入應用的執行檔名稱每行一個不區分大小寫可以模糊匹配。例如chrome.exe、weixin.exe、Cherry Studio.exe等"
}
}
}
}
}

View File

@@ -557,6 +557,7 @@
"error.enter.api.key": "Παρακαλώ εισάγετε το κλειδί API σας",
"error.enter.model": "Παρακαλώ επιλέξτε ένα μοντέλο",
"error.enter.name": "Παρακαλώ εισάγετε ένα όνομα για τη βάση γνώσεων",
"error.fetchTopicName": "Αποτυχία ονοματοδοσίας θέματος",
"error.get_embedding_dimensions": "Απέτυχε η πρόσληψη διαστάσεων ενσωμάτωσης",
"error.invalid.api.host": "Μη έγκυρη διεύθυνση API",
"error.invalid.api.key": "Μη έγκυρο κλειδί API",
@@ -880,7 +881,7 @@
"zhinao": "360 Intelligent Brain",
"zhipu": "Zhipu AI",
"voyageai": "Voyage AI",
"qiniu": "Qiniu Cloud"
"qiniu": "Qiniu AI"
},
"restore": {
"confirm": "Είστε σίγουροι ότι θέλετε να επαναφέρετε τα δεδομένα;",
@@ -1140,6 +1141,7 @@
"display.sidebar.translate.icon": "Εμφάνιση εικονιδίου μετάφρασης",
"display.sidebar.visible": "Εμφανιζόμενα εικονίδια",
"display.title": "Ρυθμίσεις εμφάνισης",
"display.zoom.title": "Ρυθμίσεις κλίμακας",
"display.topic.title": "Ρυθμίσεις Θεμάτων",
"font_size.title": "Μέγεθος γραμμάτων των μηνυμάτων",
"general": "Γενικές ρυθμίσεις",
@@ -1477,7 +1479,7 @@
"zoom_out": "Σμικρύνση εμφάνισης",
"zoom_reset": "Επαναφορά εμφάνισης"
},
"theme.auto": "Αυτόματο",
"theme.system": "Σύστημα",
"theme.dark": "Σκοτεινό",
"theme.light": "Φωτεινό",
"theme.title": "Θέμα",
@@ -1656,6 +1658,13 @@
"quit": "Έξοδος",
"show_window": "Εμφάνιση Παραθύρου",
"visualization": "προβολή"
},
"update": {
"title": "Ενημέρωση",
"message": "Νέα έκδοση {{version}} είναι έτοιμη, θέλετε να την εγκαταστήσετε τώρα;",
"later": "Μετά",
"install": "Εγκατάσταση",
"noReleaseNotes": "Χωρίς σημειώσεις"
}
}
}

View File

@@ -558,6 +558,7 @@
"error.enter.api.key": "Ingrese su clave API",
"error.enter.model": "Seleccione un modelo",
"error.enter.name": "Ingrese el nombre de la base de conocimiento",
"error.fetchTopicName": "Error al nombrar el tema",
"error.get_embedding_dimensions": "Fallo al obtener las dimensiones de incrustación",
"error.invalid.api.host": "Dirección API inválida",
"error.invalid.api.key": "Clave API inválida",
@@ -849,7 +850,7 @@
"doubao": "Volcán Motor",
"fireworks": "Fuegos Artificiales",
"gemini": "Géminis",
"gitee-ai": "Gitee IA",
"gitee-ai": "Gitee AI",
"github": "GitHub Modelos",
"gpustack": "GPUStack",
"grok": "Grok",
@@ -881,7 +882,7 @@
"zhinao": "360 Inteligente",
"zhipu": "ZhiPu IA",
"voyageai": "Voyage AI",
"qiniu": "Qiniu Yun"
"qiniu": "Qiniu AI"
},
"restore": {
"confirm": "¿Está seguro de que desea restaurar los datos?",
@@ -1139,6 +1140,7 @@
"display.sidebar.translate.icon": "Mostrar icono de traducción",
"display.sidebar.visible": "Iconos visibles",
"display.title": "Configuración de visualización",
"display.zoom.title": "Configuración de zoom",
"display.topic.title": "Configuración de tema",
"font_size.title": "Tamaño de fuente de mensajes",
"general": "Configuración general",
@@ -1476,7 +1478,7 @@
"zoom_out": "Reducir interfaz",
"zoom_reset": "Restablecer zoom"
},
"theme.auto": "Automático",
"theme.system": "Sistema",
"theme.dark": "Oscuro",
"theme.light": "Claro",
"theme.title": "Tema",
@@ -1655,6 +1657,13 @@
"quit": "Salir",
"show_window": "Mostrar Ventana",
"visualization": "Visualización"
},
"update": {
"title": "Actualización",
"message": "Nueva versión {{version}} disponible, ¿desea instalarla ahora?",
"later": "Más tarde",
"install": "Instalar",
"noReleaseNotes": "Sin notas de la versión"
}
}
}

View File

@@ -557,6 +557,7 @@
"error.enter.api.key": "Veuillez entrer votre clé API",
"error.enter.model": "Veuillez sélectionner un modèle",
"error.enter.name": "Veuillez entrer le nom de la base de connaissances",
"error.fetchTopicName": "Échec de la dénomination du sujet",
"error.get_embedding_dimensions": "Impossible d'obtenir les dimensions d'encodage",
"error.invalid.api.host": "Adresse API invalide",
"error.invalid.api.key": "Clé API invalide",
@@ -848,7 +849,7 @@
"doubao": "Huoshan Engine",
"fireworks": "Fireworks",
"gemini": "Gemini",
"gitee-ai": "Gitee IA",
"gitee-ai": "Gitee AI",
"github": "GitHub Modèles",
"gpustack": "GPUStack",
"grok": "Grok",
@@ -880,7 +881,7 @@
"zhinao": "360 ZhiNao",
"zhipu": "ZhiPu IA",
"voyageai": "Voyage AI",
"qiniu": "Qiniu Cloud"
"qiniu": "Qiniu AI"
},
"restore": {
"confirm": "Êtes-vous sûr de vouloir restaurer les données ?",
@@ -1140,6 +1141,7 @@
"display.sidebar.translate.icon": "Afficher l'icône de traduction",
"display.sidebar.visible": "Icônes affichées",
"display.title": "Paramètres d'affichage",
"display.zoom.title": "Paramètres de zoom",
"display.topic.title": "Paramètres de sujet",
"font_size.title": "Taille de police des messages",
"general": "Paramètres généraux",
@@ -1477,7 +1479,7 @@
"zoom_out": "Réduire l'interface",
"zoom_reset": "Réinitialiser le zoom"
},
"theme.auto": "Automatique",
"theme.system": "Système",
"theme.dark": "Sombre",
"theme.light": "Clair",
"theme.title": "Thème",
@@ -1656,6 +1658,13 @@
"quit": "Quitter",
"show_window": "Afficher la fenêtre",
"visualization": "Visualisation"
},
"update": {
"title": "Mise à jour",
"message": "Nouvelle version {{version}} disponible, voulez-vous l'installer maintenant ?",
"later": "Plus tard",
"install": "Installer",
"noReleaseNotes": "Aucune note de version"
}
}
}

View File

@@ -559,6 +559,7 @@
"error.enter.api.key": "Insira sua chave API",
"error.enter.model": "Selecione um modelo",
"error.enter.name": "Insira o nome da base de conhecimento",
"error.fetchTopicName": "Falha ao nomear o tópico",
"error.get_embedding_dimensions": "Falha ao obter dimensões de incorporação",
"error.invalid.api.host": "Endereço API inválido",
"error.invalid.api.key": "Chave API inválida",
@@ -850,7 +851,7 @@
"doubao": "Volcano Engine",
"fireworks": "Fogos de Artifício",
"gemini": "Gêmeos",
"gitee-ai": "Gitee IA",
"gitee-ai": "Gitee AI",
"github": "GitHub Models",
"gpustack": "GPUStack",
"grok": "Compreender",
@@ -882,7 +883,7 @@
"zhinao": "360 Inteligência Artificial",
"zhipu": "ZhiPu IA",
"voyageai": "Voyage AI",
"qiniu": "Qiniu Cloud"
"qiniu": "Qiniu AI"
},
"restore": {
"confirm": "Tem certeza de que deseja restaurar os dados?",
@@ -1142,6 +1143,7 @@
"display.sidebar.translate.icon": "Mostrar ícone de tradução",
"display.sidebar.visible": "Ícones visíveis",
"display.title": "Configurações de exibição",
"display.zoom.title": "Configurações de zoom",
"display.topic.title": "Configurações de tópico",
"font_size.title": "Tamanho da fonte da mensagem",
"general": "Configurações gerais",
@@ -1479,7 +1481,7 @@
"zoom_out": "Diminuir interface",
"zoom_reset": "Redefinir zoom"
},
"theme.auto": "Automático",
"theme.system": "Sistema",
"theme.dark": "Escuro",
"theme.light": "Claro",
"theme.title": "Tema",
@@ -1658,6 +1660,13 @@
"quit": "Sair",
"show_window": "Exibir Janela",
"visualization": "Visualização"
},
"update": {
"title": "Atualização",
"message": "Nova versão {{version}} disponível, deseja instalar agora?",
"later": "Mais tarde",
"install": "Instalar",
"noReleaseNotes": "Sem notas de versão"
}
}
}

View File

@@ -5,8 +5,6 @@ import { Col, Image, Row, Spin, Table } from 'antd'
import React, { memo } from 'react'
import styled from 'styled-components'
import GeminiFiles from './GeminiFiles'
interface ContentViewProps {
id: FileTypes | 'all' | string
files?: FileType[]
@@ -45,10 +43,6 @@ const ContentView: React.FC<ContentViewProps> = ({ id, files, dataSource, column
)
}
if (id.startsWith('gemini_')) {
return <GeminiFiles id={id.replace('gemini_', '') as string} />
}
return (
<Table
dataSource={dataSource}

View File

@@ -8,7 +8,6 @@ import React, { memo } from 'react'
import styled from 'styled-components'
import FileItem from './FileItem'
import GeminiFiles from './GeminiFiles'
interface FileItemProps {
id: FileTypes | 'all' | string
@@ -58,10 +57,6 @@ const FileList: React.FC<FileItemProps> = ({ id, list, files }) => {
)
}
if (id.startsWith('gemini_')) {
return <GeminiFiles id={id.replace('gemini_', '') as string} />
}
return (
<VirtualList
data={list}

View File

@@ -1,102 +0,0 @@
import { DeleteOutlined } from '@ant-design/icons'
import type { File } from '@google/genai'
import { useProvider } from '@renderer/hooks/useProvider'
import { runAsyncFunction } from '@renderer/utils'
import { MB } from '@shared/config/constant'
import { Spin } from 'antd'
import dayjs from 'dayjs'
import { FC, useCallback, useEffect, useState } from 'react'
import styled from 'styled-components'
import FileItem from './FileItem'
interface GeminiFilesProps {
id: string
}
const GeminiFiles: FC<GeminiFilesProps> = ({ id }) => {
const { provider } = useProvider(id)
const [files, setFiles] = useState<File[]>([])
const [loading, setLoading] = useState(false)
const fetchFiles = useCallback(async () => {
const files = await window.api.gemini.listFiles(provider.apiKey)
files && setFiles(files.filter((file) => file.state === 'ACTIVE'))
}, [provider])
useEffect(() => {
runAsyncFunction(async () => {
try {
setLoading(true)
await fetchFiles()
setLoading(false)
} catch (error: any) {
console.error('Failed to fetch files:', error)
window.message.error(error.message)
setLoading(false)
}
})
}, [fetchFiles])
useEffect(() => {
setFiles([])
}, [id])
if (loading) {
return (
<Container>
<LoadingWrapper>
<Spin />
</LoadingWrapper>
</Container>
)
}
return (
<Container>
<FileListContainer>
{files.map((file) => (
<FileItem
key={file.name}
fileInfo={{
name: file.displayName,
ext: `.${file.name?.split('.').pop()}`,
extra: `${dayjs(file.createTime).format('MM-DD HH:mm')} · ${(parseInt(file.sizeBytes || '0') / MB).toFixed(2)} MB`,
actions: (
<DeleteOutlined
style={{ cursor: 'pointer', color: 'var(--color-error)' }}
onClick={() => {
setFiles(files.filter((f) => f.name !== file.name))
window.api.gemini.deleteFile(file.name!, provider.apiKey).catch((error) => {
console.error('Failed to delete file:', error)
setFiles((prev) => [...prev, file])
})
}}
/>
)
}}
/>
))}
</FileListContainer>
</Container>
)
}
const Container = styled.div`
width: 100%;
`
const FileListContainer = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
`
const LoadingWrapper = styled.div`
display: flex;
justify-content: center;
align-items: center;
min-height: 200px;
`
export default GeminiFiles

View File

@@ -36,8 +36,8 @@ const Chat: FC<Props> = (props) => {
const maxWidth = useMemo(() => {
const showRightTopics = showTopics && topicPosition === 'right'
const minusAssistantsWidth = showAssistants ? `- var(--assistants-width) - var(--scrollbar-width)` : ''
const minusRightTopicsWidth = showRightTopics ? `- var(--assistants-width) - var(--scrollbar-width)` : ''
const minusAssistantsWidth = showAssistants ? '- var(--assistants-width)' : ''
const minusRightTopicsWidth = showRightTopics ? '- var(--assistants-width)' : ''
return `calc(100vw - var(--sidebar-width) ${minusAssistantsWidth} ${minusRightTopicsWidth})`
}, [showAssistants, showTopics, topicPosition])
@@ -115,16 +115,14 @@ const Chat: FC<Props> = (props) => {
includeUser={filterIncludeUser}
onIncludeUserChange={userOutlinedItemClickHandler}
/>
<MessagesContainer>
<Messages
key={props.activeTopic.id}
assistant={assistant}
topic={props.activeTopic}
setActiveTopic={props.setActiveTopic}
onComponentUpdate={messagesComponentUpdateHandler}
onFirstUpdate={messagesComponentFirstUpdateHandler}
/>
</MessagesContainer>
<Messages
key={props.activeTopic.id}
assistant={assistant}
topic={props.activeTopic}
setActiveTopic={props.setActiveTopic}
onComponentUpdate={messagesComponentUpdateHandler}
onFirstUpdate={messagesComponentFirstUpdateHandler}
/>
<QuickPanelProvider>
<Inputbar assistant={assistant} setActiveTopic={props.setActiveTopic} topic={props.activeTopic} />
{isMultiSelectMode && <MultiSelectActionPopup topic={props.activeTopic} />}
@@ -143,13 +141,6 @@ const Chat: FC<Props> = (props) => {
)
}
const MessagesContainer = styled.div`
display: flex;
flex-direction: column;
overflow: hidden;
flex: 1;
`
const Container = styled.div`
display: flex;
flex-direction: row;

View File

@@ -450,10 +450,6 @@ const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, Toolbar
openResourcesList
}))
if (activedMcpServers.length === 0) {
return null
}
return (
<Tooltip placement="top" title={t('settings.mcp.title')} arrow>
<ToolbarButton type="text" onClick={handleOpenQuickPanel}>

View File

@@ -74,29 +74,33 @@ const MentionModelsButton: FC<Props> = ({ ref, mentionModels, onMentionModel, To
}
providers.forEach((p) => {
const providerModels = p.models
.filter((m) => !isEmbeddingModel(m) && !isRerankModel(m))
.filter((m) => !pinnedModels.includes(getModelUniqId(m)))
.map((m) => ({
label: (
<>
<ProviderName>{p.isSystem ? t(`provider.${p.id}`) : p.name}</ProviderName>
<span style={{ opacity: 0.8 }}> | {m.name}</span>
</>
),
description: <ModelTagsWithLabel model={m} showLabel={false} size={10} style={{ opacity: 0.8 }} />,
icon: (
<Avatar src={getModelLogo(m.id)} size={20}>
{first(m.name)}
</Avatar>
),
filterText: (p.isSystem ? t(`provider.${p.id}`) : p.name) + m.name,
action: () => onMentionModel(m),
isSelected: mentionModels.some((selected) => getModelUniqId(selected) === getModelUniqId(m))
}))
const providerModels = sortBy(
p.models
.filter((m) => !isEmbeddingModel(m) && !isRerankModel(m))
.filter((m) => !pinnedModels.includes(getModelUniqId(m))),
['group', 'name']
)
if (providerModels.length > 0) {
items.push(...sortBy(providerModels, ['label']))
const providerModelItems = providerModels.map((m) => ({
label: (
<>
<ProviderName>{p.isSystem ? t(`provider.${p.id}`) : p.name}</ProviderName>
<span style={{ opacity: 0.8 }}> | {m.name}</span>
</>
),
description: <ModelTagsWithLabel model={m} showLabel={false} size={10} style={{ opacity: 0.8 }} />,
icon: (
<Avatar src={getModelLogo(m.id)} size={20}>
{first(m.name)}
</Avatar>
),
filterText: (p.isSystem ? t(`provider.${p.id}`) : p.name) + m.name,
action: () => onMentionModel(m),
isSelected: mentionModels.some((selected) => getModelUniqId(selected) === getModelUniqId(m))
}))
if (providerModelItems.length > 0) {
items.push(...providerModelItems)
}
})

View File

@@ -1,6 +1,6 @@
import Favicon from '@renderer/components/Icons/FallbackFavicon'
import { Tooltip } from 'antd'
import React from 'react'
import React, { memo, useCallback, useMemo } from 'react'
import styled from 'styled-components'
interface CitationTooltipProps {
@@ -13,56 +13,62 @@ interface CitationTooltipProps {
}
const CitationTooltip: React.FC<CitationTooltipProps> = ({ children, citation }) => {
let hostname = ''
try {
hostname = new URL(citation.url).hostname
} catch {
hostname = citation.url
}
const hostname = useMemo(() => {
try {
return new URL(citation.url).hostname
} catch {
return citation.url
}
}, [citation.url])
const sourceTitle = useMemo(() => {
return citation.title?.trim() || hostname
}, [citation.title, hostname])
const handleClick = useCallback(() => {
window.open(citation.url, '_blank', 'noopener,noreferrer')
}, [citation.url])
// 自定义悬浮卡片内容
const tooltipContent = (
<TooltipContentWrapper>
<TooltipHeader onClick={() => window.open(citation.url, '_blank')}>
<Favicon hostname={hostname} alt={citation.title || hostname} />
<TooltipTitle title={citation.title || hostname}>{citation.title || hostname}</TooltipTitle>
</TooltipHeader>
{citation.content && <TooltipBody>{citation.content}</TooltipBody>}
<TooltipFooter onClick={() => window.open(citation.url, '_blank')}>{hostname}</TooltipFooter>
</TooltipContentWrapper>
const tooltipContent = useMemo(
() => (
<div>
<TooltipHeader role="button" aria-label={`Open ${sourceTitle} in new tab`} onClick={handleClick}>
<Favicon hostname={hostname} alt={sourceTitle} />
<TooltipTitle role="heading" aria-level={3} title={sourceTitle}>
{sourceTitle}
</TooltipTitle>
</TooltipHeader>
{citation.content?.trim() && (
<TooltipBody role="article" aria-label="Citation content">
{citation.content}
</TooltipBody>
)}
<TooltipFooter role="button" aria-label={`Visit ${hostname}`} onClick={handleClick}>
{hostname}
</TooltipFooter>
</div>
),
[citation.content, hostname, handleClick, sourceTitle]
)
return (
<StyledTooltip
title={tooltipContent}
<Tooltip
overlay={tooltipContent}
placement="top"
arrow={false}
overlayInnerStyle={{
backgroundColor: 'var(--color-background-mute)',
border: '1px solid var(--color-border)',
padding: 0,
borderRadius: '8px'
color="var(--color-background-mute)"
styles={{
body: {
border: '1px solid var(--color-border)',
padding: '12px',
borderRadius: '8px'
}
}}>
{children}
</StyledTooltip>
</Tooltip>
)
}
// 使用styled-components来自定义Tooltip的样式包括箭头
const StyledTooltip = styled(Tooltip)`
.ant-tooltip-arrow {
.ant-tooltip-arrow-content {
background-color: var(--color-background-1);
}
}
`
const TooltipContentWrapper = styled.div`
padding: 12px;
background-color: var(--color-background-soft);
border-radius: 8px;
`
const TooltipHeader = styled.div`
display: flex;
align-items: center;
@@ -108,4 +114,4 @@ const TooltipFooter = styled.div`
}
`
export default CitationTooltip
export default memo(CitationTooltip)

View File

@@ -0,0 +1,377 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import CitationTooltip from '../CitationTooltip'
// Mock dependencies
const mockWindowOpen = vi.fn()
vi.mock('@renderer/components/Icons/FallbackFavicon', () => ({
__esModule: true,
default: (props: any) => <div data-testid="mock-favicon" {...props} />
}))
vi.mock('antd', () => ({
Tooltip: ({ children, overlay, title, placement, color, styles, ...props }: any) => (
<div
data-testid="tooltip-wrapper"
data-placement={placement}
data-color={color}
data-styles={JSON.stringify(styles)}
{...props}>
{children}
<div data-testid="tooltip-content">{overlay || title}</div>
</div>
)
}))
const originalWindowOpen = window.open
describe('CitationTooltip', () => {
beforeEach(() => {
vi.clearAllMocks()
Object.defineProperty(window, 'open', {
value: mockWindowOpen,
writable: true
})
})
afterEach(() => {
vi.restoreAllMocks()
window.open = originalWindowOpen
})
// Test data factory
const createCitationData = (overrides = {}) => ({
url: 'https://example.com/article',
title: 'Example Article',
content: 'This is the article content for testing purposes.',
...overrides
})
const renderCitationTooltip = (citation: any, children = <span>Trigger</span>) => {
return render(<CitationTooltip citation={citation}>{children}</CitationTooltip>)
}
const expectWindowOpenCalled = (url: string) => {
expect(mockWindowOpen).toHaveBeenCalledWith(url, '_blank', 'noopener,noreferrer')
}
const getTooltipContent = () => screen.getByTestId('tooltip-content')
const getCitationHeaderButton = () => screen.getByRole('button', { name: /open .* in new tab/i })
const getCitationFooterButton = () => screen.getByRole('button', { name: /visit .*/i })
const getCitationTitle = () => screen.getByRole('heading', { level: 3 })
const getCitationContent = () => screen.queryByRole('article', { name: /citation content/i })
describe('basic rendering', () => {
it('should render children and basic tooltip structure', () => {
const citation = createCitationData()
renderCitationTooltip(citation, <span>Click me</span>)
expect(screen.getByText('Click me')).toBeInTheDocument()
expect(screen.getByTestId('tooltip-wrapper')).toBeInTheDocument()
expect(getTooltipContent()).toBeInTheDocument()
})
it('should render Favicon with correct props', () => {
const citation = createCitationData({
url: 'https://example.com',
title: 'Example Title'
})
renderCitationTooltip(citation)
const favicon = screen.getByTestId('mock-favicon')
expect(favicon).toHaveAttribute('hostname', 'example.com')
expect(favicon).toHaveAttribute('alt', 'Example Title')
})
it('should pass correct props to Tooltip component', () => {
const citation = createCitationData()
renderCitationTooltip(citation)
const tooltip = screen.getByTestId('tooltip-wrapper')
expect(tooltip).toHaveAttribute('data-placement', 'top')
expect(tooltip).toHaveAttribute('data-color', 'var(--color-background-mute)')
const styles = JSON.parse(tooltip.getAttribute('data-styles') || '{}')
expect(styles.body).toEqual({
border: '1px solid var(--color-border)',
padding: '12px',
borderRadius: '8px'
})
})
it('should match snapshot', () => {
const citation = createCitationData()
const { container } = render(
<CitationTooltip citation={citation}>
<span>Test content</span>
</CitationTooltip>
)
expect(container.firstChild).toMatchSnapshot()
})
})
describe('URL processing and hostname extraction', () => {
it('should extract hostname from valid URLs', () => {
const testCases = [
{ url: 'https://www.example.com/path/to/page?query=1', expected: 'www.example.com' },
{ url: 'http://test.com', expected: 'test.com' },
{ url: 'https://api.v2.example.com/endpoint', expected: 'api.v2.example.com' },
{ url: 'ftp://files.domain.net', expected: 'files.domain.net' }
]
testCases.forEach(({ url, expected }) => {
const { unmount } = renderCitationTooltip(createCitationData({ url }))
expect(screen.getByText(expected)).toBeInTheDocument()
unmount()
})
})
it('should handle URLs with ports correctly', () => {
const citation = createCitationData({ url: 'https://localhost:3000/api/data' })
renderCitationTooltip(citation)
// URL.hostname strips the port
expect(screen.getByText('localhost')).toBeInTheDocument()
})
it('should fallback to original URL when parsing fails', () => {
const testCases = ['not-a-valid-url', '', 'http://']
testCases.forEach((invalidUrl) => {
const { unmount } = renderCitationTooltip(createCitationData({ url: invalidUrl }))
const favicon = screen.getByTestId('mock-favicon')
expect(favicon).toHaveAttribute('hostname', invalidUrl)
unmount()
})
})
})
describe('content display and title logic', () => {
it('should display citation title when provided', () => {
const citation = createCitationData({ title: 'Custom Article Title' })
renderCitationTooltip(citation)
expect(screen.getByText('Custom Article Title')).toBeInTheDocument()
expect(screen.getByText('example.com')).toBeInTheDocument() // hostname in footer
})
it('should fallback to hostname when title is empty or whitespace', () => {
const testCases = [
{ title: undefined, url: 'https://fallback-test.com' },
{ title: '', url: 'https://empty-title.com' },
{ title: ' ', url: 'https://whitespace-title.com' },
{ title: '\n\t \n', url: 'https://mixed-whitespace.com' }
]
testCases.forEach(({ title, url }) => {
const { unmount } = renderCitationTooltip(createCitationData({ title, url }))
const titleElement = getCitationTitle()
const expectedHostname = new URL(url).hostname
expect(titleElement).toHaveTextContent(expectedHostname)
unmount()
})
})
it('should display content when provided and meaningful', () => {
const citation = createCitationData({ content: 'Meaningful article content' })
renderCitationTooltip(citation)
expect(screen.getByText('Meaningful article content')).toBeInTheDocument()
})
it('should not render content section when content is empty or whitespace', () => {
const testCases = [undefined, null, '', ' ', '\n\t \n']
testCases.forEach((content) => {
const { unmount } = renderCitationTooltip(createCitationData({ content }))
expect(getCitationContent()).not.toBeInTheDocument()
unmount()
})
})
it('should handle long content with proper styling', () => {
const longContent =
'This is a very long content that should be clamped to three lines using CSS line-clamp property for better visual presentation in the tooltip interface.'
const citation = createCitationData({ content: longContent })
renderCitationTooltip(citation)
const contentElement = screen.getByText(longContent)
expect(contentElement).toHaveStyle({
display: '-webkit-box',
overflow: 'hidden'
})
})
it('should handle special characters in title and content', () => {
const citation = createCitationData({
title: 'Article with Special: <>{}[]()&"\'`',
content: 'Content with chars: <>{}[]()&"\'`'
})
renderCitationTooltip(citation)
expect(screen.getByText('Article with Special: <>{}[]()&"\'`')).toBeInTheDocument()
expect(screen.getByText('Content with chars: <>{}[]()&"\'`')).toBeInTheDocument()
})
})
describe('user interactions', () => {
it('should open URL when header is clicked', async () => {
const user = userEvent.setup()
const citation = createCitationData({ url: 'https://header-click.com' })
renderCitationTooltip(citation)
const header = getCitationHeaderButton()
await user.click(header)
expectWindowOpenCalled('https://header-click.com')
})
it('should open URL when footer is clicked', async () => {
const user = userEvent.setup()
const citation = createCitationData({ url: 'https://footer-click.com' })
renderCitationTooltip(citation)
const footer = getCitationFooterButton()
await user.click(footer)
expectWindowOpenCalled('https://footer-click.com')
})
it('should not trigger click when content area is clicked', async () => {
const user = userEvent.setup()
const citation = createCitationData({ content: 'Non-clickable content' })
renderCitationTooltip(citation)
const content = screen.getByText('Non-clickable content')
await user.click(content)
expect(mockWindowOpen).not.toHaveBeenCalled()
})
it('should handle invalid URLs gracefully', async () => {
const user = userEvent.setup()
const citation = createCitationData({ url: 'invalid-url' })
renderCitationTooltip(citation)
const footer = getCitationFooterButton()
await user.click(footer)
expectWindowOpenCalled('invalid-url')
})
})
describe('real-world usage scenarios', () => {
it('should work with actual citation link structure', () => {
const citation = createCitationData({
url: 'https://research.example.com/study',
title: 'Research Study on AI',
content:
'This study demonstrates significant improvements in AI capabilities through novel training methodologies and evaluation frameworks.'
})
const citationLink = (
<a href="https://research.example.com/study" target="_blank" rel="noreferrer">
<sup>1</sup>
</a>
)
renderCitationTooltip(citation, citationLink)
// Should display all citation information
expect(screen.getByText('Research Study on AI')).toBeInTheDocument()
expect(screen.getByText('research.example.com')).toBeInTheDocument()
expect(screen.getByText(/This study demonstrates/)).toBeInTheDocument()
// Should contain the sup element
expect(screen.getByText('1')).toBeInTheDocument()
})
it('should handle truncated content as used in real implementation', () => {
const fullContent = 'A'.repeat(250) // Longer than typical 200 char limit
const citation = createCitationData({ content: fullContent })
renderCitationTooltip(citation)
expect(screen.getByText(fullContent)).toBeInTheDocument()
})
it('should handle missing title with hostname fallback in real scenario', () => {
const citation = createCitationData({
url: 'https://docs.python.org/3/library/urllib.html',
title: undefined, // Common case when title extraction fails
content: 'urllib.request module documentation for Python 3'
})
renderCitationTooltip(citation)
const titleElement = getCitationTitle()
expect(titleElement).toHaveTextContent('docs.python.org')
})
})
describe('edge cases', () => {
it('should handle malformed URLs', () => {
const malformedUrls = ['http://', 'https://', '://missing-protocol']
malformedUrls.forEach((url) => {
expect(() => {
const { unmount } = renderCitationTooltip(createCitationData({ url }))
unmount()
}).not.toThrow()
})
})
it('should handle missing children gracefully', () => {
const citation = createCitationData()
expect(() => {
render(<CitationTooltip citation={citation}>{null}</CitationTooltip>)
}).not.toThrow()
})
it('should handle extremely long URLs without breaking', () => {
const longUrl = 'https://extremely-long-domain-name.example.com/' + 'a'.repeat(500)
const citation = createCitationData({ url: longUrl })
expect(() => {
renderCitationTooltip(citation)
}).not.toThrow()
})
})
describe('performance', () => {
it('should memoize calculations correctly', () => {
const citation = createCitationData({ url: 'https://memoize-test.com' })
const { rerender } = renderCitationTooltip(citation)
expect(screen.getByText('memoize-test.com')).toBeInTheDocument()
// Re-render with same props should work correctly
rerender(
<CitationTooltip citation={citation}>
<span>Trigger</span>
</CitationTooltip>
)
expect(screen.getByText('memoize-test.com')).toBeInTheDocument()
})
it('should update when citation data changes', () => {
const citation1 = createCitationData({ url: 'https://first.com' })
const { rerender } = renderCitationTooltip(citation1)
expect(screen.getByText('first.com')).toBeInTheDocument()
const citation2 = createCitationData({ url: 'https://second.com' })
rerender(
<CitationTooltip citation={citation2}>
<span>Trigger</span>
</CitationTooltip>
)
expect(screen.getByText('second.com')).toBeInTheDocument()
expect(screen.queryByText('first.com')).not.toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,368 @@
import 'katex/dist/katex.min.css'
import type { MainTextMessageBlock, ThinkingMessageBlock, TranslationMessageBlock } from '@renderer/types/newMessage'
import { MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage'
import { render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import Markdown from '../Markdown'
// Mock dependencies
const mockUseSettings = vi.fn()
const mockUseTranslation = vi.fn()
// Mock hooks
vi.mock('@renderer/hooks/useSettings', () => ({
useSettings: () => mockUseSettings()
}))
vi.mock('react-i18next', () => ({
useTranslation: () => mockUseTranslation()
}))
// Mock services
vi.mock('@renderer/services/EventService', () => ({
EVENT_NAMES: {
EDIT_CODE_BLOCK: 'EDIT_CODE_BLOCK'
},
EventEmitter: {
emit: vi.fn()
}
}))
// Mock utilities
vi.mock('@renderer/utils', () => ({
parseJSON: vi.fn((str) => {
try {
return JSON.parse(str || '{}')
} catch {
return {}
}
})
}))
vi.mock('@renderer/utils/formats', () => ({
escapeBrackets: vi.fn((str) => str),
removeSvgEmptyLines: vi.fn((str) => str)
}))
vi.mock('@renderer/utils/markdown', () => ({
findCitationInChildren: vi.fn(() => '{"id": 1, "url": "https://example.com"}'),
getCodeBlockId: vi.fn(() => 'code-block-1')
}))
// Mock components with more realistic behavior
vi.mock('../CodeBlock', () => ({
__esModule: true,
default: ({ id, onSave, children }: any) => (
<div data-testid="code-block" data-id={id}>
<code>{children}</code>
<button type="button" onClick={() => onSave(id, 'new content')}>
Save
</button>
</div>
)
}))
vi.mock('../ImagePreview', () => ({
__esModule: true,
default: (props: any) => <img data-testid="image-preview" {...props} />
}))
vi.mock('../Link', () => ({
__esModule: true,
default: ({ citationData, children, ...props }: any) => (
<a data-testid="citation-link" data-citation={citationData} {...props}>
{children}
</a>
)
}))
vi.mock('@renderer/components/MarkdownShadowDOMRenderer', () => ({
__esModule: true,
default: ({ children }: any) => <div data-testid="shadow-dom">{children}</div>
}))
// Mock plugins
vi.mock('remark-gfm', () => ({ __esModule: true, default: vi.fn() }))
vi.mock('remark-cjk-friendly', () => ({ __esModule: true, default: vi.fn() }))
vi.mock('remark-math', () => ({ __esModule: true, default: vi.fn() }))
vi.mock('rehype-katex', () => ({ __esModule: true, default: vi.fn() }))
vi.mock('rehype-mathjax', () => ({ __esModule: true, default: vi.fn() }))
vi.mock('rehype-raw', () => ({ __esModule: true, default: vi.fn() }))
// Mock ReactMarkdown with realistic rendering
vi.mock('react-markdown', () => ({
__esModule: true,
default: ({ children, components, className }: any) => (
<div data-testid="markdown-content" className={className}>
{children}
{/* Simulate component rendering */}
{components?.a && <span data-testid="has-link-component">link</span>}
{components?.code && (
<div data-testid="has-code-component">
{components.code({ children: 'test code', node: { position: { start: { line: 1 } } } })}
</div>
)}
{components?.img && <span data-testid="has-img-component">img</span>}
{components?.style && <span data-testid="has-style-component">style</span>}
</div>
)
}))
describe('Markdown', () => {
let mockEventEmitter: any
beforeEach(async () => {
vi.clearAllMocks()
// Default settings
mockUseSettings.mockReturnValue({ mathEngine: 'KaTeX' })
mockUseTranslation.mockReturnValue({
t: (key: string) => (key === 'message.chat.completion.paused' ? 'Paused' : key)
})
// Get mocked EventEmitter
const { EventEmitter } = await import('@renderer/services/EventService')
mockEventEmitter = EventEmitter
})
afterEach(() => {
vi.restoreAllMocks()
})
// Test data helpers
const createMainTextBlock = (overrides: Partial<MainTextMessageBlock> = {}): MainTextMessageBlock => ({
id: 'test-block-1',
messageId: 'test-message-1',
type: MessageBlockType.MAIN_TEXT,
status: MessageBlockStatus.SUCCESS,
createdAt: new Date().toISOString(),
content: '# Test Markdown\n\nThis is **bold** text.',
...overrides
})
describe('rendering', () => {
it('should render markdown content with correct structure', () => {
const block = createMainTextBlock({ content: 'Test content' })
render(<Markdown block={block} />)
const markdown = screen.getByTestId('markdown-content')
expect(markdown).toBeInTheDocument()
expect(markdown).toHaveClass('markdown')
expect(markdown).toHaveTextContent('Test content')
})
it('should handle empty content gracefully', () => {
const block = createMainTextBlock({ content: '' })
expect(() => render(<Markdown block={block} />)).not.toThrow()
const markdown = screen.getByTestId('markdown-content')
expect(markdown).toBeInTheDocument()
})
it('should show paused message when content is empty and status is paused', () => {
const block = createMainTextBlock({
content: '',
status: MessageBlockStatus.PAUSED
})
render(<Markdown block={block} />)
const markdown = screen.getByTestId('markdown-content')
expect(markdown).toHaveTextContent('Paused')
})
it('should prioritize actual content over paused status', () => {
const block = createMainTextBlock({
content: 'Real content',
status: MessageBlockStatus.PAUSED
})
render(<Markdown block={block} />)
const markdown = screen.getByTestId('markdown-content')
expect(markdown).toHaveTextContent('Real content')
expect(markdown).not.toHaveTextContent('Paused')
})
it('should process content through format utilities', async () => {
const { escapeBrackets, removeSvgEmptyLines } = await import('@renderer/utils/formats')
const content = 'Content with [brackets] and SVG'
render(<Markdown block={createMainTextBlock({ content })} />)
expect(escapeBrackets).toHaveBeenCalledWith(content)
expect(removeSvgEmptyLines).toHaveBeenCalledWith(content)
})
it('should match snapshot', () => {
const { container } = render(<Markdown block={createMainTextBlock()} />)
expect(container.firstChild).toMatchSnapshot()
})
})
describe('block type support', () => {
const testCases = [
{
name: 'MainTextMessageBlock',
block: createMainTextBlock({ content: 'Main text content' }),
expectedContent: 'Main text content'
},
{
name: 'ThinkingMessageBlock',
block: {
id: 'thinking-1',
messageId: 'msg-1',
type: MessageBlockType.THINKING,
status: MessageBlockStatus.SUCCESS,
createdAt: new Date().toISOString(),
content: 'Thinking content',
thinking_millsec: 5000
} as ThinkingMessageBlock,
expectedContent: 'Thinking content'
},
{
name: 'TranslationMessageBlock',
block: {
id: 'translation-1',
messageId: 'msg-1',
type: MessageBlockType.TRANSLATION,
status: MessageBlockStatus.SUCCESS,
createdAt: new Date().toISOString(),
content: 'Translated content',
targetLanguage: 'en'
} as TranslationMessageBlock,
expectedContent: 'Translated content'
}
]
testCases.forEach(({ name, block, expectedContent }) => {
it(`should handle ${name} correctly`, () => {
render(<Markdown block={block} />)
const markdown = screen.getByTestId('markdown-content')
expect(markdown).toBeInTheDocument()
expect(markdown).toHaveTextContent(expectedContent)
})
})
})
describe('math engine configuration', () => {
it('should configure KaTeX when mathEngine is KaTeX', () => {
mockUseSettings.mockReturnValue({ mathEngine: 'KaTeX' })
render(<Markdown block={createMainTextBlock()} />)
// Component should render successfully with KaTeX configuration
expect(screen.getByTestId('markdown-content')).toBeInTheDocument()
})
it('should configure MathJax when mathEngine is MathJax', () => {
mockUseSettings.mockReturnValue({ mathEngine: 'MathJax' })
render(<Markdown block={createMainTextBlock()} />)
// Component should render successfully with MathJax configuration
expect(screen.getByTestId('markdown-content')).toBeInTheDocument()
})
it('should not load math plugins when mathEngine is none', () => {
mockUseSettings.mockReturnValue({ mathEngine: 'none' })
render(<Markdown block={createMainTextBlock()} />)
// Component should render successfully without math plugins
expect(screen.getByTestId('markdown-content')).toBeInTheDocument()
})
})
describe('custom components', () => {
it('should integrate Link component for citations', () => {
render(<Markdown block={createMainTextBlock()} />)
expect(screen.getByTestId('has-link-component')).toBeInTheDocument()
})
it('should integrate CodeBlock component with edit functionality', () => {
const block = createMainTextBlock({ id: 'test-block-123' })
render(<Markdown block={block} />)
expect(screen.getByTestId('has-code-component')).toBeInTheDocument()
// Test code block edit event
const saveButton = screen.getByText('Save')
saveButton.click()
expect(mockEventEmitter.emit).toHaveBeenCalledWith('EDIT_CODE_BLOCK', {
msgBlockId: 'test-block-123',
codeBlockId: 'code-block-1',
newContent: 'new content'
})
})
it('should integrate ImagePreview component', () => {
render(<Markdown block={createMainTextBlock()} />)
expect(screen.getByTestId('has-img-component')).toBeInTheDocument()
})
it('should handle style tags with Shadow DOM', () => {
const block = createMainTextBlock({ content: '<style>body { color: red; }</style>' })
render(<Markdown block={block} />)
expect(screen.getByTestId('has-style-component')).toBeInTheDocument()
})
})
describe('HTML content support', () => {
it('should handle mixed markdown and HTML content', () => {
const block = createMainTextBlock({
content: '# Header\n<div>HTML content</div>\n**Bold text**'
})
expect(() => render(<Markdown block={block} />)).not.toThrow()
const markdown = screen.getByTestId('markdown-content')
expect(markdown).toBeInTheDocument()
expect(markdown).toHaveTextContent('# Header')
expect(markdown).toHaveTextContent('HTML content')
expect(markdown).toHaveTextContent('**Bold text**')
})
it('should handle malformed content gracefully', () => {
const block = createMainTextBlock({
content: '<unclosed-tag>content\n# Invalid markdown **unclosed'
})
expect(() => render(<Markdown block={block} />)).not.toThrow()
const markdown = screen.getByTestId('markdown-content')
expect(markdown).toBeInTheDocument()
})
})
describe('component behavior', () => {
it('should re-render when content changes', () => {
const { rerender } = render(<Markdown block={createMainTextBlock({ content: 'Initial' })} />)
expect(screen.getByTestId('markdown-content')).toHaveTextContent('Initial')
rerender(<Markdown block={createMainTextBlock({ content: 'Updated' })} />)
expect(screen.getByTestId('markdown-content')).toHaveTextContent('Updated')
})
it('should re-render when math engine changes', () => {
mockUseSettings.mockReturnValue({ mathEngine: 'KaTeX' })
const { rerender } = render(<Markdown block={createMainTextBlock()} />)
expect(screen.getByTestId('markdown-content')).toBeInTheDocument()
mockUseSettings.mockReturnValue({ mathEngine: 'MathJax' })
rerender(<Markdown block={createMainTextBlock()} />)
// Should still render correctly with new math engine
expect(screen.getByTestId('markdown-content')).toBeInTheDocument()
})
})
})

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