Compare commits

..

134 Commits

Author SHA1 Message Date
suyao
e4434eb7c8 feat: add settings window functionality and shortcuts
- Introduced a new IPC channel for showing the settings window.
- Registered IPC handler for the settings window in the main process.
- Updated shortcut service to include a shortcut for opening the settings window.
- Modified the settings popup to use the new IPC method for displaying the settings.
- Adjusted the main sidebar to navigate to the settings window using the new IPC call.
- Enhanced the navbar to accommodate the new settings window functionality.
2025-07-11 16:05:57 +08:00
suyao
762732af9d Merge branch 'main' into feat/sidebar-ui 2025-07-11 13:55:51 +08:00
kangfenmao
5b9ff3053b refactor: update styles and layout in markdown and message components
- Removed unnecessary letter and word spacing in markdown styles.
- Adjusted padding in Inputbar for improved layout.
- Modified margin properties in CitationsList and Message components for consistency.
- Enhanced MessageHeader logic to conditionally hide based on message type.
- Updated icon sizes in MessageMenubar for better alignment.
- Added margin adjustments in ThinkingBlock for improved spacing.
2025-07-11 11:33:20 +08:00
SuYao
8340922263 fix: smartblock update not persist to db (#8046)
* chore(version): 1.4.10

* feat: enhance ThinkingTagExtractionMiddleware and update smartBlockUpdate function

- Added support for THINKING_START and TEXT_START chunk types in ThinkingTagExtractionMiddleware.
- Updated smartBlockUpdate function to include an isComplete parameter for better block state management.
- Ensured proper handling of block updates based on completion status across various message types.

* fix: refine block update logic in messageThunk

- Adjusted conditions for canceling throttled block updates based on block type changes and completion status.
- Improved handling of block updates to ensure accurate state management during message processing.

* chore: add comment

* fix: update message block status handling

- Changed the status of image blocks from STREAMING to PENDING to better reflect the processing state.
- Refined logic in OpenAIResponseAPIClient to ensure user messages are correctly handled based on assistant message content.
- Improved rendering conditions in ImageBlock component for better user experience during image loading.

---------

Co-authored-by: kangfenmao <kangfenmao@qq.com>
2025-07-11 11:33:05 +08:00
one
a93cab6b43 fix(CodePreview): revert to absolute positioning (#7980)
* fix(CodePreview): revert to absolute positioning

* fix: add min width to codeblockview
2025-07-11 11:06:21 +08:00
one
9a81c400ab fix: sticky code toolbar (#8012)
fix: sticky code toolbar for single-model message
2025-07-11 11:05:08 +08:00
西街工坊
808a22d5c6 fix(Doc2xPreprocessProvider): replace filePath split with path.parse… (#8042) 2025-07-11 11:03:05 +08:00
kangfenmao
10e512f32e chore(version): 1.4.10 2025-07-10 23:31:03 +08:00
one
4d75515bd6 refactor: raise the max count of document chunks from 30 to 50 (#7863)
* refactor: raise the max count of document chunks from 30 to 70

- Raise the max count of document chunks count
- Update i18n for websearch rag for consistency

* refactor: lower the count to 50
2025-07-10 22:52:18 +08:00
kangfenmao
3d6c84de6d refactor: improve styling and layout in MessageTools and Prompt components
- Adjusted spacing and border styles in MessageTools for better alignment.
- Updated margin and border properties in Prompt for consistent UI.
- Enhanced background color handling in ToolContentWrapper based on status.
2025-07-10 22:36:48 +08:00
SuYao
3dd393b840 fix: azure-openai (#7978) 2025-07-10 22:17:20 +08:00
LiuVaayne
8f86c53941 feat: implement MCP tool auto-approve functionality (#8007)
*  feat: implement MCP tool auto-approve functionality

- Add auto-approve toggle for MCP tools in settings
- Add improved UI for tool approval with Run/Cancel/Auto-approve buttons
- Add internationalization support for tool approval interface
- Update tool confirmation logic to support auto-approved tools
- Enhance tool status indicators and button styling
- Add disabledAutoApproveTools configuration for MCP servers

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: use table for mcp tools setting

* refactor: improve styles, add missing i18n

* refactor: extract renderStatusIndicator, reuse colors

* refactor: simplify the table

* feat: auto approve same tool in a turn

* feat(i18n): add confirmation tooltip for auto-approve tool in multiple languages

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: one <wangan.cs@gmail.com>
Co-authored-by: suyao <sy20010504@gmail.com>
Co-authored-by: Teo <cheesen.xu@gmail.com>
2025-07-10 22:14:52 +08:00
Phantom
a7b78c547a fix(encoding): encoding detection and decoding logic (#8024) 2025-07-10 22:13:40 +08:00
Chen Tao
bcc1046cdf feat: add upload file (#8035) 2025-07-10 22:06:09 +08:00
Phantom
c05c06b7a1 fix: VoyageEmbeddings (#8034)
* fix(embeddings): 修复VoyageAI嵌入格式和模型验证错误

修复OpenAIBaseClient中VoyageAI提供商的embedding格式设置问题
完善VoyageEmbeddings模型验证的错误提示信息

* refactor(embeddings): 移除VoyageEmbeddings的模型维度限制检查

简化VoyageEmbeddings的创建逻辑,不再对支持的模型维度进行校验

* fix(embeddings): 修复VoyageEmbeddings模型维度设置问题

修复VoyageEmbeddings中未正确校验模型是否支持设置outputDimension的问题
当provider为voyageai且模型不支持设置dimensions时,自动忽略传入的dimensions参数

* refactor(embeddings): 集中管理支持设置维度的模型列表

将各嵌入模型支持设置维度的模型列表集中到utils模块
不再让VoyageEmbeddings中getDimensions抛出错误,而是自动修复
2025-07-10 21:53:37 +08:00
Phantom
446ebae175 feat(ui): better infinite context (#8021)
* feat(上下文): 添加最大上下文数量限制及显示组件

- 在常量配置中添加 MAX_CONTEXT_COUNT
- 创建 MaxContextCount 组件用于显示无限上下文标识
- 在相关组件中替换硬编码的上下文最大值
- 优化 TokenCount 组件的上下文计数显示样式

* refactor(常量): 添加UNLIMITED_CONTEXT_COUNT常量并替换硬编码值

使用UNLIMITED_CONTEXT_COUNT常量替代多处硬编码的100000值,提高代码可维护性

* refactor(Inputbar): 使用 SlashSeparatorSpan 组件替换内联样式

将 TokenCount.tsx 中的斜杠分隔符内联样式替换为 SlashSeparatorSpan 组件,提高代码可维护性

* fix: 为 InfinityIcon 添加 aria-label 并统一样式
2025-07-10 21:51:31 +08:00
one
ba742b7b1f feat: save to knowledge (#7528)
* feat: save to knowledge

* refactor: simplify checkbox

* feat(i18n): add 'Save to Local File' translation key for multiple languages

---------

Co-authored-by: suyao <sy20010504@gmail.com>
2025-07-10 21:34:01 +08:00
fullex
7c6db809bb fix(SelectionAssistant): [macOS] show actionWindow on fullscreen app (#8004)
* feat(SelectionService): enhance action window handling for macOS fullscreen mode

- Updated processAction and showActionWindow methods to support fullscreen mode on macOS.
- Added isFullScreen parameter to manage action window visibility and positioning.
- Improved action window positioning logic to ensure it remains within screen boundaries.
- Adjusted IPC channel to pass fullscreen state from the renderer to the service.
- Updated SelectionToolbar to track fullscreen state and pass it to the action processing function.

* chore(deps): update selection-hook to version 1.0.6 in package.json and yarn.lock

* fix(SelectionService): improve macOS fullscreen handling and action window focus

- Added app import to manage dock visibility on macOS.
- Enhanced fullscreen handling logic to ensure the dock icon is restored correctly.
- Updated action window focus behavior to prevent unintended hiding when blurred.
- Refactored SelectionActionApp to streamline auto pinning logic and remove redundant useEffect.
- Cleaned up SelectionToolbar by removing unnecessary window size updates when demo is false.

* refactor(SelectionService): remove commented-out code for clarity

* refactor(SelectionService): streamline macOS handling and improve code clarity
2025-07-10 20:41:01 +08:00
Konv Suu
855499681f feat: add confirm for unsaved content in creating agent (#7965) 2025-07-10 19:37:18 +08:00
one
92be3c0f56 chore: update vscode settings (#7974)
* chore: update vscode settings

* refactor: add editorconfig to extensions
2025-07-10 19:34:57 +08:00
one
2a72f391b7 feat: codeblock dot language (#6783)
* feat(CodeBlock): support dot language in code block

- render DOT using @viz-js/viz
- highlight DOT using @viz-js/lang-dot (CodeEditor only)
- extract a special view map, update file structure
- extract and reuse the PreviewError component across special views
- update dependencies, fix peer dependencies

* chore: prepare for merge
2025-07-10 19:32:51 +08:00
SuYao
db642f0837 feat(models): support Grok4 (#8032)
refactor(models): rename and enhance reasoning model functions for clarity and functionality
2025-07-10 19:27:53 +08:00
kangfenmao
fca93b6c51 style: update various component styles for improved layout and readability
- Adjusted color for list items in color.scss for better contrast.
- Modified line-height and margins in markdown.scss for improved text readability.
- Changed height property in FloatingSidebar.tsx for consistent layout.
- Increased padding in AgentsPage.tsx for better spacing.
- Updated padding and border-radius in Inputbar.tsx for enhanced aesthetics.
- Reduced margin in MessageHeader.tsx for tighter layout.
- Refactored GroupTitle styles in AssistantsTab.tsx for better alignment and spacing.
2025-07-10 18:59:00 +08:00
one
7e672d86e7 refactor: do not jump on enabling content search (#7922)
* fix: content search count on enable

* refactor(ContentSearch): do not jump on enabling content search

* refactor: simplify result count
2025-07-10 17:29:43 +08:00
SuYao
e9112cad0f fix(McpToolChunkMiddleware): add logging for tool calls and enhance l… (#8028)
fix(McpToolChunkMiddleware): add logging for tool calls and enhance lookup logic
2025-07-10 17:26:57 +08:00
one
ffbd6445df refactor(Inputbar): make button tooltips disappear faster (#8011) 2025-07-10 17:26:38 +08:00
Alaina Hardie
dff44f2721 Fix: Require typechecking for Mac and Linux target builds (#7219)
fix: Mac builds do not auto-run typecheck, but Windows builds do. This requires an extra manual step when building for Mac.

Update build scripts in package.json to use `npm run build` directly for Mac and Linux targets..
2025-07-10 17:01:31 +08:00
SuYao
3afa81eb5d fix(Anthropic): content truncation (#7942)
* fix(Anthropic): content truncation

* feat: add start event and fix content truncation

* fix (gemini): some event

* revert: index.tsx

* revert(messageThunk): error block

* fix: ci

* chore: unuse log
2025-07-10 16:58:35 +08:00
SuYao
3350c3e2e5 fix(GeminiAPIClient, mcp-tools): enhance tool call handling and lookup logic (#8009)
* fix(GeminiAPIClient, mcp-tools): enhance tool call handling and lookup logic

* fix: unuse log
2025-07-10 15:16:23 +08:00
SuYao
f85f46c248 fix(middleware): ollama qwen think (#8026)
refactor(AiProvider): comment out unnecessary middleware removal for performance optimization

- Commented out the removal of ThinkingTagExtractionMiddlewareName to prevent potential performance degradation while maintaining existing functionality.
- Retained the removal of ThinkChunkMiddlewareName as part of the existing logic for non-reasoning scenarios.
2025-07-10 15:15:38 +08:00
SuYao
05f3b88f30 fix(Inputbar): update resizeTextArea call to improve functionality (#8010) 2025-07-10 15:15:13 +08:00
自由的世界人
f8c6b5c05f Fix translation key for unlimited backups label (#7987)
Updated the translation key for the 'unlimited' backups option in WebDavSettings to use the correct namespace.
2025-07-10 15:09:59 +08:00
Jason Young
97dbfe492e test: enhance download and fetch utility test coverage with bug fix (#7891)
* test: enhance download and fetch utility test coverage

- Add MIME type handling tests for data URLs in download.test.ts
- Add timestamp generation tests for blob and network downloads
- Add Content-Type header handling test for extensionless files
- Add format parameter tests (markdown/html/text) for fetchWebContent
- Add timeout signal handling tests for fetch operations
- Add combined signal (user + timeout) test for AbortSignal.any

These tests improve coverage of edge cases and ensure critical functionality
is properly tested.

* fix: add missing error handling for fetch in download utility

- Add .catch() handler for network request failures in download()
- Use window.message.error() for user-friendly error notifications
- Update tests to verify error handling behavior
- Ensure proper error messages are shown to users

This fixes a missing error handler that was discovered during test development.

* refactor: improve test structure and add i18n support for download utility

- Unified test structure with two-layer describe blocks (filename -> function name)
- Added afterEach with restoreAllMocks for consistent mock cleanup
- Removed individual mockRestore calls in favor of centralized cleanup
- Added i18n support to download.ts for error messages
- Updated error handling logic to avoid duplicate messages
- Updated test expectations to match new i18n error messages

* test: fix react-i18next mock for Markdown test

Add missing initReactI18next to mock to resolve test failures caused by i18n initialization when download utility imports i18n module.
2025-07-10 14:35:40 +08:00
kangfenmao
186f0ed06f feat(MCPSettings): enhance MCP server management and localization
- Added BuiltinMCPServersSection and McpResourcesSection components to display available MCP servers and resources.
- Updated navigation logic to redirect users to the MCP settings upon adding a server.
- Enhanced localization by adding new keys for built-in servers in multiple languages.
- Improved the SettingsPage layout by reordering menu items for better accessibility.
2025-07-10 12:39:01 +08:00
kangfenmao
daf134f331 refactor(HtmlArtifacts): enhance HTML validation and rendering logic
- Added checks for complete HTML documents based on presence of critical tags.
- Updated unmatched tag detection to include a comprehensive list of HTML5 void elements.
- Improved HTML content rendering with a fixed interval update mechanism.
- Adjusted modal header styles for better layout consistency.
- Enabled editing capabilities in the CodeEditor component for HTML content.
2025-07-10 12:28:25 +08:00
kangfenmao
3f7f78da15 fix(release.yml): add missing environment variables for build jobs 2025-07-10 10:49:10 +08:00
kangfenmao
1d289621fc style(markdown): enhance typography and spacing for improved readability
- Increased line height and adjusted margins for headers and paragraphs to enhance text clarity.
- Added letter and word spacing for better text presentation.
- Updated blockquote and table styles for a more visually appealing layout.
- Improved hover effect for table rows to enhance user interaction.
2025-07-10 10:49:10 +08:00
kangfenmao
d7002cda11 refactor: quick panel remove multi-select mode 2025-07-10 02:45:41 +08:00
kangfenmao
559fcecf77 refactor(CodeBlockView): replace HtmlArtifacts component with HtmlArtifactsCard
- Removed the obsolete HtmlArtifacts component and its associated logic.
- Introduced the new HtmlArtifactsCard component to enhance the rendering of HTML artifacts.
- Updated the CodeBlockView to utilize HtmlArtifactsCard, improving maintainability and user experience.
- Added a new HtmlArtifactsPopup component for better HTML content preview and editing capabilities.
- Enhanced localization by adding translation keys for HTML artifacts in multiple languages.
2025-07-10 02:45:32 +08:00
kangfenmao
1d854c232e refactor(Messages): update message styling and structure for improved clarity
- Simplified the message header and footer components by removing unnecessary props and logic.
- Adjusted the message container styles for better alignment and spacing.
- Enhanced the message tokens display logic and corrected the component name for consistency.
- Removed unused translation keys related to token usage from multiple language files to streamline localization.
2025-07-10 02:45:32 +08:00
kangfenmao
8c6684cbdf refactor(WebSearchButton): simplify web search button logic and improve tooltip behavior
- Removed unused imports and streamlined the logic for enabling web search.
- Updated the tooltip title to reflect the current state of web search functionality.
- Enhanced the handling of quick panel opening based on the assistant's web search settings.
2025-07-10 02:45:32 +08:00
kangfenmao
c7ab71f01f refactor(OpenAISettingsGroup): simplify component structure and remove styled components
- Removed unused imports and the StyledSelect component, replacing it with a standard Selector for improved clarity.
- Streamlined the layout by eliminating unnecessary styles, enhancing maintainability and readability of the code.
2025-07-10 00:42:36 +08:00
SuYao
9b57351d1e fix(McpToolChunkMiddleware): enhance tool call confirmation logic (#8005)
* fix(McpToolChunkMiddleware): enhance tool call confirmation logic

- Added additional condition to confirm tool calls by checking the toolCallId in the confirmed object.
- Included a console log for confirmed tool calls to aid in debugging and tracking tool call execution.

* chore: unuse log
2025-07-09 23:39:58 +08:00
kangfenmao
f9e88fb6ee refactor(Navbar): remove MinAppsPopover component
- Deleted the MinAppsPopover component to streamline the Navbar.
- Updated Navbar to remove references to MinAppsPopover, enhancing code maintainability.
2025-07-09 19:32:36 +08:00
kangfenmao
074ba0ae05 feat(i18n): add "Open Logs" button translations for multiple languages
- Introduced new translation keys for the "Open Logs" button in various languages (en-us, ja-jp, ru-ru, zh-cn, zh-tw, el-gr, es-es, fr-fr, pt-pt).
- Updated the DataSettings component to include a button for opening application logs, enhancing user accessibility to log files.
2025-07-09 19:22:57 +08:00
kangfenmao
4a8a5e8428 feat(i18n): enhance localization for GitHub Copilot settings
- Added new translation keys for error messages and steps in the GitHub Copilot authentication process across multiple languages (en-us, ja-jp, ru-ru, zh-cn, zh-tw).
- Updated the GitHubCopilotSettings component to reflect the new steps for user guidance during the authentication process.
- Improved user experience by providing detailed descriptions and success/error messages related to the authorization flow.
2025-07-09 19:07:32 +08:00
kangfenmao
f7fa665f3a feat(CustomHeaderPopup): add custom headers management for providers
- Introduced a new CustomHeaderPopup component for managing extra headers for providers.
- Integrated the popup into the ProviderSetting component, allowing users to edit headers via a modal.
- Refactored ApiKeyListPopup to use a styled container for improved layout.
2025-07-09 18:15:42 +08:00
beyondkmp
e273ddcfb0 fix(LocalBackupSettings): update input and select styles for better responsiveness (#7977)
refactor(LocalBackupSettings): update input and select styles for better responsiveness

- Adjusted the input field to have a flexible width between 200 and 400 pixels.
- Modified select components to use a minimum width of 120 pixels for improved layout consistency.
- Enhanced onChange handlers for select components to ensure proper value handling.
2025-07-09 18:05:32 +08:00
kangfenmao
41d3a1fd55 refactor(SettingsPage): reorder menu items for improved organization 2025-07-09 17:50:57 +08:00
kangfenmao
7237ba34db docs: short i18n keys 2025-07-09 17:26:42 +08:00
Phantom
fbf89b3f0a fix(translate): prevent translation from being triggered unexpectedly during IME composition (#7968)
fix(translate): 修复在输入法组合文字时意外触发翻译的问题
2025-07-09 13:46:39 +08:00
suyao
5d9b47198b Merge branch 'main' into feat/sidebar-ui 2025-07-03 05:21:20 +08:00
suyao
db1c03f9fa refactor(App): replace TabsContainer with AppLayout for improved layout structure; integrate ChatProvider for chat context management; update routing logic in NavigationHandler to utilize SettingsPopup; adjust main height variable in styles for consistency; enhance FilesPage layout by repositioning SideNav; implement new SettingsPopup component for settings management. 2025-07-02 11:50:41 +08:00
suyao
bedea8aaaa refactor(ContextMenu): simplify context menu implementation by removing unused props and handlers; streamline rendering logic in Message component 2025-07-02 03:48:21 +08:00
suyao
27d959caed refactor(SettingsTab): update import for Windows constant and improve shortcut label logic 2025-07-02 03:07:16 +08:00
suyao
0609b93a14 Merge branch 'main' into feat/sidebar-ui 2025-07-02 03:04:43 +08:00
one
c7a0b05841 refactor(Select): provide consistent search experience for antd Select (#7363)
- Add CustomSelect for enhanced searching
- Replace some Select components with CustomSelect
- Fix translation language searching problem
2025-06-19 19:23:25 +08:00
suyao
5ca0ce682b Merge branch 'main' into feat/sidebar-ui 2025-06-19 14:58:48 +08:00
suyao
7c3752a8e6 feat(TopicsHistory): integrate assistant information into topic display
- Added functionality to map and display assistant details alongside topics in the TopicsHistory component.
- Introduced a new AssistantTag styled component for better visual representation of assistants.
- Updated the layout of TopicItem to accommodate assistant information.
2025-06-19 14:20:20 +08:00
Teo
7ae10be387 refactor(ImportAgentPopup): replace Space with Flex for footer layout 2025-06-18 19:02:26 +08:00
Teo
7767dfaac7 feat(styles): add colorSplit property for Divider component in AntdProvider 2025-06-18 18:52:19 +08:00
Teo
c89ff17b36 feat(styles): add EditableNumber component and update styles for improved UI 2025-06-18 18:41:15 +08:00
Teo
0810a63fd8 feat(styles): enhance dropdown and slider components for improved UI 2025-06-18 14:15:50 +08:00
Teo
ff261fb52b refactor(styles): clean up and optimize dropdown and popover styles
- Removed redundant styles from mention-models-dropdown in ant.scss for better maintainability.
- Simplified Selector component by removing ConfigProvider wrapper around Dropdown.
- Updated AntdProvider to set consistent border radius for Dropdown and Popover components.
- Adjusted padding in SettingsTab for improved layout.
2025-06-17 15:35:29 +08:00
suyao
8f8deb9275 fix: enhance MessageSettingsPopup styles for better usability
- Added max height and overflow styles to the body of the MessageSettingsPopup to improve content visibility and scrolling behavior.
2025-06-17 13:46:18 +08:00
suyao
0a7e591f0e refactor: update icon colors to use primary color scheme
- Changed icon colors from var(--color-link) to var(--color-primary) in multiple components for consistency.
- Updated styles in ContentSearch, ReasoningIcon, WebSearchIcon, GenerateImageButton, ThinkingButton, and WebSearchButton to enhance visual coherence.
2025-06-17 12:55:32 +08:00
kangfenmao
c1e8f1063a Merge branch 'main' into feat/sidebar-ui 2025-06-16 17:53:21 +08:00
kangfenmao
4317f4b672 feat: use tabs
wip

wip

wip

wip
2025-06-16 17:52:18 +08:00
one
202504fd17 refactor(Navbar&Sidebar): rearrange navbar icons, add search functionality for assistants and topics (#7170)
* refactor(Navbar&Sidebar): rearrange navbar icons, add search functionality for assistants and topics

- Updated ChatNavbar and MainNavbar to streamline the display of assistant icons based on their visibility state.
- Introduced search input in MainSidebar for filtering assistants and topics.
- Enhanced AssistantsTab and TopicsTab to support search functionality, allowing users to filter displayed items based on input.
- Added a new utility function to improve search keyword matching logic.
- Improved overall layout and styling for better user experience.

* refactor: update icons

* refactor(Search): allow clear

* refactor: enhance search bar

* refactor: improve search bar style

* feat: new panel left icon
2025-06-16 12:35:24 +08:00
Wang Jiyuan
05727c637f refactor: Encapsulate image display related components (#7211) 2025-06-16 00:05:25 +08:00
Teo
c7843ca288 fix: 修复合并main分之后样式问题 2025-06-15 13:00:08 +08:00
kangfenmao
facf29e02b Merge branch 'main' into feat/sidebar-ui
# Conflicts:
#	src/renderer/src/assets/styles/index.scss
#	src/renderer/src/hooks/useTopic.ts
#	src/renderer/src/pages/home/Messages/Blocks/ImageBlock.tsx
#	src/renderer/src/pages/home/Messages/Blocks/index.tsx
#	src/renderer/src/pages/mcp-servers/providers/lanyun.ts
#	src/renderer/src/pages/settings/ModelSettings/TopicNamingModalPopup.tsx
2025-06-15 11:39:21 +08:00
kangfenmao
66d280136c style: enhance MainSidebar and PageContainer styles for improved UI
- Added 3D transform styles to PageContainer for better visual effects.
- Updated MainSidebar to use optional chaining for safer property access.
- Removed background color from MainSidebar to support transparency.
2025-06-15 11:36:33 +08:00
Teo
91892ea619 feat(FilesPage): 添加批量删除功能 2025-06-14 21:52:25 +08:00
Teo
7d70425c75 revert: remove backgroundMaterial for win 2025-06-14 18:44:35 +08:00
Teo
20b3db0c01 Revert "refactor(FileItem, FileList, FilesPage, i18n): enhance file management UI and localization"
This reverts commit edeb9f84f9.
2025-06-14 15:48:47 +08:00
Teo
f1804bc3a0 Revert "refactor(FileItem, FileList, FilesPage): enhance file selection UI and improve checkbox handling"
This reverts commit 58f3edb352.
2025-06-14 15:46:55 +08:00
Teo
2ec0c29087 style: change hover color 2025-06-14 15:03:58 +08:00
Teo
20a572aa46 feat(styles, useAppInit, DisplaySettings): 更新导航栏背景色和列表项样式,优化透明窗口设置
- 在 color.scss 中添加 Windows 版本的导航栏背景色。
- 修改 useAppInit 钩子以支持 Windows 和 Mac 的背景色设置。
- 更新 MainSidebar 的悬停样式以使用新的背景色。
- 在 DisplaySettings 中简化透明窗口设置的显示逻辑。
2025-06-14 14:49:30 +08:00
kangfenmao
142b624001 feat(ThemeProvider, color.scss, useAppInit): integrate transparent window settings and update styles 2025-06-14 10:36:45 +08:00
kangfenmao
3775562956 feat(DisplaySettings, MainSidebar, useSettings): implement transparent window feature and update styles
- Added functionality to manage transparent window settings in DisplaySettings and MainSidebar components.
- Updated useSettings hook to include transparent window state management.
- Modified MainSidebar and its styles to support transparency based on user settings.
- Removed obsolete opaque window translations from localization files across multiple languages for consistency.
2025-06-14 08:32:40 +08:00
Teo
3544c40d9a feat(TranslatePage, ant.scss): add ChevronDown icon to select components and update dropdown styles
- Introduced ChevronDown icon to various Select components in TranslatePage for improved visual consistency.
- Updated ant.scss to add a border style to the select dropdown for enhanced UI clarity.
2025-06-14 00:41:02 +08:00
Teo
36fa3af9e9 feat(CustomCollapse, ThinkingBlock, KnowledgeSearchPopup): add expand icons and adjust styles for improved UI
- Introduced ChevronRight expand icons in CustomCollapse and ThinkingBlock components for better visual feedback.
- Adjusted height and styling in KnowledgeSearchPopup for enhanced usability and consistency.
- Updated various styled components to improve layout and user interaction.
2025-06-14 00:28:33 +08:00
Teo
eb832cc25a refactor(AddKnowledgePopup, KnowledgeSettingsPopup, ModelSettings): enhance select components with consistent icons
- Updated Select components in AddKnowledgePopup and KnowledgeSettingsPopup to include a ChevronDown icon for improved visual consistency.
- Refactored ModelSettings to add ChevronDown icon to Select components, enhancing the overall UI experience.
- Simplified Slider component usage by removing unnecessary styling for a cleaner layout.
2025-06-13 23:27:46 +08:00
Teo
0378f9ceb1 refactor(styles, AntdProvider, DisplaySettings, DefaultAssistantSettings): enhance UI elements and improve styling consistency
- Updated ant.scss to refine modal styles for better visual distinction.
- Adjusted AntdProvider theme settings for Segmented and Switch components to improve color consistency.
- Modified DisplaySettings to change button sizes and styles for a more cohesive appearance.
- Tweaked DefaultAssistantSettings button dimensions for improved layout and usability.
2025-06-13 23:04:17 +08:00
suyao
99b23e1d5d fix(migrate): correct filter condition for assistants' template status
- Updated the filter condition in migrateConfig to check for undefined isTemplate, ensuring proper assignment of template status for assistants.
2025-06-13 22:24:50 +08:00
kangfenmao
8d23c810fe refactor(App, MainSidebar, OpenedMinapps): streamline component code and enhance layout
- Simplified the handleClick function in App component by removing unnecessary line breaks.
- Adjusted MainMenu component in MainSidebar to remove inline margin styling for cleaner layout.
- Updated OpenedMinapps to apply margin styling directly to TabsContainer for improved spacing.
- Removed the PinnedApps component to declutter the sidebar and enhance performance.
2025-06-13 20:44:08 +08:00
kangfenmao
cdfa2ac13a refactor(ChatProvider, useChat): implement context for chat state management and enhance event handling
- Introduced ChatProvider to manage active assistant and topic state using React context.
- Updated useChat hook to utilize context for accessing chat state and actions.
- Refactored message navigation to emit events for setting active assistant and topic.
- Improved topic selection logic to ensure active topic is valid when topics change.
- Integrated ChatProvider into HomePage and HistoryPage for consistent chat state management.
2025-06-13 19:31:21 +08:00
suyao
58f3edb352 refactor(FileItem, FileList, FilesPage): enhance file selection UI and improve checkbox handling
- Added isSelected prop to FileItem for visual indication of selection.
- Updated FileList to manage selected file IDs and pass selection state to FileItem.
- Enhanced FilesPage checkbox handling with improved visibility on hover and selection state.
- Introduced CheckboxContainer for consistent styling of checkboxes across components.
2025-06-13 19:20:16 +08:00
suyao
edeb9f84f9 refactor(FileItem, FileList, FilesPage, i18n): enhance file management UI and localization
- Updated FileItem component to display file count and additional metadata.
- Refactored FileList to support checkbox selection and improved layout.
- Enhanced FilesPage with batch delete functionality and improved file selection handling.
- Updated localization strings across multiple languages for consistency in delete confirmation messages.
2025-06-13 19:11:10 +08:00
Teo
2757fcf6b9 refactor(Selector, HomePage, MessageGroupSettings, NutstoreSettings, WebDavSettings): replace Select components with Selector for improved consistency
- Updated Selector component to handle value checks more robustly.
- Removed unused ContentContainer styles in HomePage for cleaner code.
- Replaced Select components with Selector in MessageGroupSettings, NutstoreSettings, and WebDavSettings for a unified UI experience.
- Enhanced option handling in Selector for better integration with existing settings.
2025-06-13 14:03:30 +08:00
kangfenmao
5339f4a9a3 refactor(MainSidebar, AssistantItem): enhance sidebar functionality and improve event handling
- Updated event handling in MainSidebar to conditionally show topics based on assistant selection.
- Integrated new settings option for showing topics when clicking on assistants.
- Removed unused code related to tab navigation for a cleaner implementation.
- Improved dependency management in useEffect hooks for better performance.
2025-06-13 13:49:15 +08:00
kangfenmao
e786feb165 refactor(i18n, settings): update localization and UI elements for improved consistency
- Removed redundant settings entries in multiple language files.
- Updated various settings titles for clarity, including "Code Settings" to "Code Block Settings".
- Added new localization keys for assistant and topic settings.
- Enhanced the layout of the ChatNavbar and MainSidebar components for better user experience.
- Adjusted the MessageMenubar to reflect updated settings terminology.
2025-06-13 13:11:06 +08:00
suyao
5794c36d0d Merge branch 'feat/sidebar-ui' of https://github.com/CherryHQ/cherry-studio into feat/sidebar-ui 2025-06-13 12:24:37 +08:00
Teo
7d8b88c56e refactor(styles, HistoryPage, MessageGroup): improve UI consistency and layout
- Removed padding from dropdown menu items for a cleaner look.
- Updated suffix rendering in HistoryPage to conditionally show icon based on search length.
- Added padding to MessageGroup for better spacing in grid layout.
2025-06-13 11:46:20 +08:00
Teo
d0daeddf14 Merge branch 'feat/sidebar-ui' of github.com:CherryHQ/cherry-studio into feat/sidebar-ui 2025-06-13 10:58:35 +08:00
Teo
2fe2ae797b refactor(styles): update CitationTooltip and CitationsList components for improved UI consistency and layout 2025-06-13 10:58:20 +08:00
kangfenmao
9bb66227b4 refactor(DragableList, AssistantsTab, AssistantItem): improve layout and styling consistency
- Adjusted margin and padding in DragableList for better spacing.
- Simplified structure in AssistantsTab by removing unnecessary divs and enhancing item layout.
- Added style prop to AssistantItem for flexible styling options.
- Updated GroupTitle and TagsContainer for improved visual consistency.
2025-06-13 10:42:09 +08:00
kangfenmao
5f4736e8c1 refactor(AssistantsTab): streamline drag-and-drop structure and improve layout consistency
- Removed unnecessary div wrappers to simplify the component structure.
- Enhanced the layout of the assistant items for better spacing and alignment.
- Added padding to the GroupTitle for improved visual consistency.
2025-06-13 10:24:52 +08:00
kangfenmao
7a44910847 Merge branch 'main' into feat/sidebar-ui
# Conflicts:
#	package.json
#	src/renderer/src/hooks/useTopic.ts
#	src/renderer/src/pages/home/Messages/Blocks/ImageBlock.tsx
#	src/renderer/src/pages/home/Messages/MessageTokens.tsx
#	src/renderer/src/store/index.ts
#	src/renderer/src/store/migrate.ts
#	src/renderer/src/store/runtime.ts
2025-06-13 10:05:43 +08:00
suyao
509632030b refactor(assistant): replace useAgents with useAssistants across components, streamline assistant management and enhance template handling 2025-06-13 05:14:53 +08:00
suyao
09fe2aa67b refactor(styles): update dropdown menu item styles to include submenu titles for consistent padding 2025-06-13 00:15:48 +08:00
neko engineer
793c641e1c feat: Optimize assistant drag-and-drop effect (#7115)
* feat: 优化助手拖拽效果
增加组内,组件,跨组拖拽效果

* feat: 提交注释

---------

Co-authored-by: linshuhao <nmnm1996>
2025-06-13 00:08:36 +08:00
Teo
38eb206a8a refactor(WindowService): 更新窗口服务配置,优化背景材质和透明度设置 2025-06-12 22:36:40 +08:00
Teo
3d9236a09a fix: fix ui 2025-06-12 21:47:24 +08:00
suyao
e6fd9b5678 Merge branch 'feat/sidebar-ui' of https://github.com/CherryHQ/cherry-studio into feat/sidebar-ui 2025-06-12 21:03:31 +08:00
suyao
d41f175a05 refactor(assistant): enhance assistant creation by automatically adding default topics; streamline default assistant handling 2025-06-12 21:03:17 +08:00
Teo
6d6a554fd3 refactor(styles): reorganize popover and citations list for improved UI consistency and user experience 2025-06-12 19:05:06 +08:00
suyao
cb1fcf7d2d Merge branch 'feat/sidebar-ui' of https://github.com/CherryHQ/cherry-studio into feat/sidebar-ui 2025-06-12 18:29:07 +08:00
suyao
6ed30fd78a Merge 'refactor/assistant-debounce' into 'feat/sidebar-ui' 2025-06-12 18:29:00 +08:00
Teo
69acb2fccd refactor(styles): adjust modal confirm body padding for improved layout consistency 2025-06-12 18:24:27 +08:00
Teo
d95a4e56f5 refactor(styles): update dropdown and modal styles for improved layout and consistency across components 2025-06-12 18:15:00 +08:00
Teo
b15dac9ef4 refactor(styles): standardize padding and width across various components for improved layout consistency 2025-06-12 17:37:20 +08:00
Teo
73fced37b4 refactor(styles): adjust border-radius and padding in various components for improved UI consistency; update layout in settings pages 2025-06-12 17:04:41 +08:00
Teo
1124090d87 refactor(styles): enhance popover styles with border and content overflow handling; update AntdProvider for background mask color 2025-06-12 15:56:35 +08:00
Teo
a92fa1b1ba refactor(MainSidebar): enhance dropdown menu with theme toggle and documentation link; update styles for dropdown menu 2025-06-12 15:46:10 +08:00
Teo
13fdfc58b6 refactor(ChatNavbar): add new topic button with tooltip and adjust sidebar styles for improved visibility 2025-06-12 15:25:03 +08:00
Teo
048a9135ac refactor(styles): update markdown table styles for improved layout and border handling 2025-06-12 14:59:26 +08:00
Teo
b5636646c9 refactor(Selector): enhance Selector component with option grouping, disabled state handling, and improved label retrieval logic 2025-06-12 12:03:04 +08:00
kangfenmao
d9def89ced refactor(HomePage): add id attribute to HStack and remove redundant id from Container for improved structure 2025-06-12 11:28:13 +08:00
kangfenmao
b16d0069bf refactor(Settings): remove windowStyle from settings and related components; transparent window 2025-06-12 10:39:56 +08:00
kangfenmao
30823691f9 refactor(SettingsPage): implement lazy loading for ProvidersList with Suspense and Spin component; remove unnecessary loading state from ProvidersList 2025-06-12 10:13:42 +08:00
kangfenmao
8be98ccbb3 refactor(Routes): enhance page transition animations and improve Navbar styling; conditionally render user name in MainSidebar 2025-06-12 10:08:46 +08:00
kangfenmao
922e85754a feat(Navigation): add IPC channels for navigation handling and implement cache service for URL management 2025-06-12 09:39:51 +08:00
kangfenmao
f041f9a231 refactor(RouteContainer): remove unused state and effect for route readiness; simplify animation handling 2025-06-12 09:39:51 +08:00
kangfenmao
0d9f1882b9 feat: add route animation
feat: add route animation
2025-06-12 09:39:51 +08:00
Teo
26d823e0a5 refactor(HomePage): update HomePage component to accept style prop and simplify layout structure 2025-06-12 09:39:51 +08:00
Teo
daa89df479 refactor(Inputbar): improve textarea height adjustment and update padding for better layout consistency 2025-06-12 09:39:51 +08:00
Teo
527740bf42 refactor(MessageSettingsPopup): replace StyledSelect with Selector component for improved consistency and maintainability 2025-06-12 09:39:51 +08:00
Teo
881e0b4713 refactor(Selector): update LabelIcon styles for improved hover effects and background consistency 2025-06-12 09:39:51 +08:00
Teo
88e251cee7 refactor(Settings): update input components to use Space.Compact for better layout and consistency across various settings pages 2025-06-12 09:39:51 +08:00
Teo
76387643f7 refactor(DisplaySettings): update button styles for zoom controls and color picker to enhance UI consistency 2025-06-12 09:39:51 +08:00
Teo
b41c89972b refactor(Selector): enhance Selector component with placeholder support and improve type safety; update GeneralSettings and WebSearchSettings to utilize Selector 2025-06-12 09:39:51 +08:00
kangfenmao
11a8154458 feat: new app sidebar
fix: adjust navbar and title bar dimensions, update icon handling

feat: implement ChatNavbar component and enhance MainNavbar with search functionality

fix: invert transparency setting for WindowService based on OS

refactor: clean up MainSidebar and useChat hooks, remove unused state handling and improve topic selection logic

refactor: simplify HomeTabs component by removing unused imports and commented code, update AssistantAddItem hover styles

fix: set WindowService transparency to false for consistent behavior across platforms

feat: add event listener to MainSidebar for topic tab navigation

feat: enhance summarization prompt and add topic sidebar visibility toggle

feat(Inputbar): add SettingButton component for settings access

style(color.scss): update border color to improve UI consistency

style: update chat background colors and margins for improved UI consistency

refactor(MainSidebar, i18n): update MCP title in sidebar and localization files, remove unused MCP entries

feat: remove prompt component

refactor(SettingsTab, OpenAISettingsGroup): restructure settings components for improved readability and maintainability, update layout and styling for better user experience

feat(ChatNavbar): add maximize and minimize icons for narrow mode toggle

style(markdown.scss, CodeBlockView): update border-radius for improved UI consistency and adjust CodeBlock styling

style(SettingsTab, OpenAISettingsGroup): adjust padding and minimum height for improved layout, comment out unused components for cleaner code

fix(i18n, MessageEditor, Settings): update localization keys for code settings and adjust border radius in MessageEditor, add gap to SettingRow for improved layout

feat(ChatNavbar, SVGIcon): add ExpandWidth icon and integrate it into ChatNavbar for narrow mode toggle

refactor(ImageBlock, MessageBlockRenderer): enhance image block styling and layout for better responsiveness

refactor(MessageAttachments): enhance file icon rendering and filename display with truncation for better UX

feat(MainSidebar, AssistantItem): integrate AssistantItem into MainSidebar for topic view, enhance event handling and styling adjustments

refactor(MessageAnchorLine): replace DownOutlined icon with CircleChevronDown for improved styling

feat(NarrowModeIcon, ChatNavbar): add NarrowModeIcon component and integrate it into ChatNavbar for narrow mode toggle

fix(MainSidebar, Inputbar, McpServersList): update event handling, adjust textarea rows, and enhance DragableList styling for improved layout and functionality

feat(MainSidebar): enhance submenu animation with framer-motion for improved user experience

style(CodeEditor, markdown.scss, SettingsTab): update border-radius to inherit and remove unused SettingDivider for improved consistency

style(MainNavbar, Message): adjust padding in MainNavbar and refine alignment logic in MessageItem for improved layout consistency

style(Inputbar, SelectModelButton): adjust margins and padding for improved layout consistency and add icon to SelectModelButton

wip

feat(MainSidebar): restructure sidebar components and add MainNavbar for enhanced navigation and user experience

style(MainSidebar, AssistantsTab): adjust margins and padding for improved layout consistency, integrate Scrollbar component for better scrolling experience

fix(MessageAnchorLine): prevent rendering of clear message type

refactor(SearchMessage, TopicMessages, MessagesService): update locateToMessage function to accept additional parameters for setting active assistant and topic, enhancing navigation logic

style(ColorStyles, Messages): update chat background colors for improved visibility and remove redundant background styles in message bubbles

revert: hide token show

refactor: settings tab

refactor(ChatNavbar): remove unused topic handling and simplify shortcut functions

fix(useChat): prevent setting active topic if it already exists in active assistant's topics

refactor(NavigationHandler, ChatNavbar, HomePage, MainSidebar): streamline navigation logic and remove unused code

chore: update react-router and react-router-dom to version 7.6.2, refactor routing logic in App component to use a RouteContainer for better location management

refactor(i18n): remove unused topic position settings and assistant display options from English, Japanese, Russian, Chinese, and Greek translations

refactor(MainSidebar, PinnedApps): remove unused imports and streamline component structure; update styling for better layout

refactor(TopicsTab): remove unused setTopicPosition function and streamline topic time display; update font size and family for TopicTime component

refactor(MainSidebar): integrate UserPopup for user settings access; streamline theme toggle logic and enhance styling for active menu items

refactor: remove topic position

fix(MainSidebar): update settings navigation path from '/settings/general' to '/settings/provider'

wip

refactor(SettingsTab): replace StyledSelect with Selector component and update styles for better UI consistency

chore(release): update fetch depth in GitHub Actions workflow for full history retrieval
2025-06-12 09:39:50 +08:00
259 changed files with 18223 additions and 12123 deletions

View File

@@ -77,9 +77,10 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
NODE_OPTIONS: --max-old-space-size=8192
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
- name: Build Mac
if: matrix.os == 'macos-latest'
@@ -93,10 +94,11 @@ jobs:
APPLE_ID: ${{ vars.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ vars.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_OPTIONS: --max-old-space-size=8192
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
- name: Build Windows
if: matrix.os == 'windows-latest'
@@ -105,9 +107,10 @@ jobs:
yarn build:win
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
NODE_OPTIONS: --max-old-space-size=8192
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
- name: Release
uses: ncipollo/release-action@v1

View File

@@ -1,3 +1,3 @@
{
"recommendations": ["dbaeumer.vscode-eslint"]
"recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode", "editorconfig.editorconfig"]
}

View File

@@ -4,6 +4,7 @@
"source.fixAll.eslint": "explicit",
"source.organizeImports": "never"
},
"files.eol": "\n",
"search.exclude": {
"**/dist/**": true,
".yarn/releases/**": true

View File

@@ -117,9 +117,8 @@ afterSign: scripts/notarize.js
artifactBuildCompleted: scripts/artifact-build-completed.js
releaseInfo:
releaseNotes: |
服务商:新增 NewAPI 服务商支持
绘图:新增 NewAPI 绘图服务商支持
备份:支持 s3 兼容存储备份
服务商:支持多个密钥管理,支持配置自定义请求头
设置:支持禁用硬件加速
其他:性能优化和错误改进
• [新增] MCP 工具调用自动审批流程
• [优化] 输入框快捷弹窗多选交互支持
• [新增] 网页内容生成实时预览功能
• [支持] Grok-4 大语言模型接入
• [修复] Anthropic 模型输出截断缺陷

View File

@@ -1,6 +1,6 @@
{
"name": "CherryStudio",
"version": "1.4.9",
"version": "1.4.10",
"private": true,
"description": "A powerful AI assistant for producer.",
"main": "./out/main/index.js",
@@ -27,12 +27,12 @@
"build:win": "dotenv npm run build && electron-builder --win --x64 --arm64",
"build:win:x64": "dotenv npm run build && electron-builder --win --x64",
"build:win:arm64": "dotenv npm run build && electron-builder --win --arm64",
"build:mac": "dotenv electron-vite build && electron-builder --mac --arm64 --x64",
"build:mac:arm64": "dotenv electron-vite build && electron-builder --mac --arm64",
"build:mac:x64": "dotenv electron-vite build && electron-builder --mac --x64",
"build:linux": "dotenv electron-vite build && electron-builder --linux --x64 --arm64",
"build:linux:arm64": "dotenv electron-vite build && electron-builder --linux --arm64",
"build:linux:x64": "dotenv electron-vite build && electron-builder --linux --x64",
"build:mac": "dotenv npm run build && electron-builder --mac --arm64 --x64",
"build:mac:arm64": "dotenv npm run build && electron-builder --mac --arm64",
"build:mac:x64": "dotenv npm run build && electron-builder --mac --x64",
"build:linux": "dotenv npm run build && electron-builder --linux --x64 --arm64",
"build:linux:arm64": "dotenv npm run build && electron-builder --linux --arm64",
"build:linux:x64": "dotenv npm run build && electron-builder --linux --x64",
"build:npm": "node scripts/build-npm.js",
"release": "node scripts/version.js",
"publish": "yarn build:check && yarn release patch push",
@@ -71,7 +71,7 @@
"notion-helper": "^1.3.22",
"os-proxy-config": "^1.1.2",
"pdfjs-dist": "4.10.38",
"selection-hook": "^1.0.5",
"selection-hook": "^1.0.6",
"turndown": "7.2.0"
},
"devDependencies": {
@@ -92,6 +92,7 @@
"@cherrystudio/embedjs-loader-xml": "^0.1.31",
"@cherrystudio/embedjs-ollama": "^0.1.31",
"@cherrystudio/embedjs-openai": "^0.1.31",
"@codemirror/view": "^6.0.0",
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
"@electron-toolkit/eslint-config-ts": "^3.0.0",
"@electron-toolkit/preload": "^3.0.0",
@@ -141,6 +142,8 @@
"@vitest/coverage-v8": "^3.1.4",
"@vitest/ui": "^3.1.4",
"@vitest/web-worker": "^3.1.4",
"@viz-js/lang-dot": "^1.0.5",
"@viz-js/viz": "^3.14.0",
"@xyflow/react": "^12.4.4",
"antd": "patch:antd@npm%3A5.24.7#~/.yarn/patches/antd-npm-5.24.7-356a553ae5.patch",
"archiver": "^7.0.1",
@@ -173,6 +176,7 @@
"eslint-plugin-unused-imports": "^4.1.4",
"fast-diff": "^1.3.0",
"fast-xml-parser": "^5.2.0",
"framer-motion": "^12.17.3",
"franc-min": "^6.2.0",
"fs-extra": "^11.2.0",
"google-auth-library": "^9.15.1",
@@ -203,8 +207,8 @@
"react-infinite-scroll-component": "^6.1.0",
"react-markdown": "^10.1.0",
"react-redux": "^9.1.2",
"react-router": "6",
"react-router-dom": "6",
"react-router": "^7.6.2",
"react-router-dom": "^7.6.2",
"react-spinners": "^0.14.1",
"react-window": "^1.8.11",
"redux": "^5.0.1",
@@ -225,6 +229,7 @@
"tiny-pinyin": "^1.3.2",
"tokenx": "^1.1.0",
"typescript": "^5.6.2",
"unified": "^11.0.5",
"uuid": "^10.0.0",
"vite": "6.2.6",
"vitest": "^3.1.4",

View File

@@ -147,6 +147,7 @@ export enum IpcChannel {
File_Base64File = 'file:base64File',
File_GetPdfInfo = 'file:getPdfInfo',
Fs_Read = 'fs:read',
File_OpenWithRelativePath = 'file:openWithRelativePath',
// file service
FileService_Upload = 'file-service:upload',
@@ -241,5 +242,12 @@ export enum IpcChannel {
Selection_ActionWindowMinimize = 'selection:action-window-minimize',
Selection_ActionWindowPin = 'selection:action-window-pin',
Selection_ProcessAction = 'selection:process-action',
Selection_UpdateActionData = 'selection:update-action-data'
Selection_UpdateActionData = 'selection:update-action-data',
// Navigation
Navigation_Url = 'navigation:url',
Navigation_Close = 'navigation:close',
// Settings Window
SettingsWindow_Show = 'settings-window:show'
}

View File

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

View File

@@ -15,6 +15,7 @@ import { Notification } from 'src/renderer/src/types/notification'
import appService from './services/AppService'
import AppUpdater from './services/AppUpdater'
import BackupManager from './services/BackupManager'
import { CacheService } from './services/CacheService'
import { configManager } from './services/ConfigManager'
import CopilotService from './services/CopilotService'
import { ExportService } from './services/ExportService'
@@ -30,6 +31,7 @@ import { pythonService } from './services/PythonService'
import { FileServiceManager } from './services/remotefile/FileServiceManager'
import { searchService } from './services/SearchService'
import { SelectionService } from './services/SelectionService'
import { SettingsWindowService } from './services/SettingsWindowService'
import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService'
import storeSyncService from './services/StoreSyncService'
import { themeService } from './services/ThemeService'
@@ -399,6 +401,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.File_Download, fileManager.downloadFile)
ipcMain.handle(IpcChannel.File_Copy, fileManager.copyFile)
ipcMain.handle(IpcChannel.File_BinaryImage, fileManager.binaryImage)
ipcMain.handle(IpcChannel.File_OpenWithRelativePath, fileManager.openFileWithRelativePath)
// file service
ipcMain.handle(IpcChannel.FileService_Upload, async (_, provider: Provider, file: FileMetadata) => {
@@ -576,4 +579,12 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.App_SetDisableHardwareAcceleration, (_, isDisable: boolean) => {
configManager.setDisableHardwareAcceleration(isDisable)
})
// Navigation
ipcMain.handle(IpcChannel.Navigation_Url, (_, url: string) => {
CacheService.set('navigation-url', url)
})
// Settings Window
SettingsWindowService.registerIpcHandler()
}

View File

@@ -5,26 +5,19 @@ import { AzureOpenAiEmbeddings } from '@cherrystudio/embedjs-openai/src/azure-op
import { getInstanceName } from '@main/utils'
import { KnowledgeBaseParams } from '@types'
import { SUPPORTED_DIM_MODELS as VOYAGE_SUPPORTED_DIM_MODELS, VoyageEmbeddings } from './VoyageEmbeddings'
import { VOYAGE_SUPPORTED_DIM_MODELS } from './utils'
import { VoyageEmbeddings } from './VoyageEmbeddings'
export default class EmbeddingsFactory {
static create({ model, provider, apiKey, apiVersion, baseURL, dimensions }: KnowledgeBaseParams): BaseEmbeddings {
const batchSize = 10
if (provider === 'voyageai') {
if (VOYAGE_SUPPORTED_DIM_MODELS.includes(model)) {
return new VoyageEmbeddings({
modelName: model,
apiKey,
outputDimension: dimensions,
batchSize: 8
})
} else {
return new VoyageEmbeddings({
modelName: model,
apiKey,
batchSize: 8
})
}
return new VoyageEmbeddings({
modelName: model,
apiKey,
outputDimension: VOYAGE_SUPPORTED_DIM_MODELS.includes(model) ? dimensions : undefined,
batchSize: 8
})
}
if (provider === 'ollama') {
if (baseURL.includes('v1/')) {

View File

@@ -1,27 +1,29 @@
import { BaseEmbeddings } from '@cherrystudio/embedjs-interfaces'
import { VoyageEmbeddings as _VoyageEmbeddings } from '@langchain/community/embeddings/voyage'
import { VOYAGE_SUPPORTED_DIM_MODELS } from './utils'
/**
* 支持设置嵌入维度的模型
*/
export const SUPPORTED_DIM_MODELS = ['voyage-3-large', 'voyage-3.5', 'voyage-3.5-lite', 'voyage-code-3']
export class VoyageEmbeddings extends BaseEmbeddings {
private model: _VoyageEmbeddings
constructor(private readonly configuration?: ConstructorParameters<typeof _VoyageEmbeddings>[0]) {
super()
if (!this.configuration) this.configuration = {}
if (!this.configuration.modelName) this.configuration.modelName = 'voyage-3'
if (!SUPPORTED_DIM_MODELS.includes(this.configuration.modelName) && this.configuration.outputDimension) {
throw new Error(`VoyageEmbeddings only supports ${SUPPORTED_DIM_MODELS.join(', ')}`)
if (!this.configuration) {
throw new Error('Pass in a configuration.')
}
if (!this.configuration.modelName) this.configuration.modelName = 'voyage-3'
this.model = new _VoyageEmbeddings(this.configuration)
if (!VOYAGE_SUPPORTED_DIM_MODELS.includes(this.configuration.modelName) && this.configuration.outputDimension) {
console.error(`VoyageEmbeddings only supports ${VOYAGE_SUPPORTED_DIM_MODELS.join(', ')} to set outputDimension.`)
this.model = new _VoyageEmbeddings({ ...this.configuration, outputDimension: undefined })
} else {
this.model = new _VoyageEmbeddings(this.configuration)
}
}
override async getDimensions(): Promise<number> {
if (!this.configuration?.outputDimension) {
throw new Error('You need to pass in the optional dimensions parameter for this model')
}
return this.configuration?.outputDimension
return this.configuration?.outputDimension ?? (this.configuration?.modelName === 'voyage-code-2' ? 1536 : 1024)
}
override async embedDocuments(texts: string[]): Promise<number[][]> {

View File

@@ -0,0 +1,45 @@
export const VOYAGE_SUPPORTED_DIM_MODELS = ['voyage-3-large', 'voyage-3.5', 'voyage-3.5-lite', 'voyage-code-3']
// NOTE: 下面的暂时没用上,但先留着吧
export const OPENAI_SUPPORTED_DIM_MODELS = ['text-embedding-3-small', 'text-embedding-3-large']
export const DASHSCOPE_SUPPORTED_DIM_MODELS = ['text-embedding-v3', 'text-embedding-v4']
export const OPENSOURCE_SUPPORTED_DIM_MODELS = ['qwen3-embedding-0.6B', 'qwen3-embedding-4B', 'qwen3-embedding-8B']
export const GOOGLE_SUPPORTED_DIM_MODELS = ['gemini-embedding-exp-03-07', 'gemini-embedding-exp']
export const SUPPORTED_DIM_MODELS = [
...VOYAGE_SUPPORTED_DIM_MODELS,
...OPENAI_SUPPORTED_DIM_MODELS,
...DASHSCOPE_SUPPORTED_DIM_MODELS,
...OPENSOURCE_SUPPORTED_DIM_MODELS,
...GOOGLE_SUPPORTED_DIM_MODELS
]
/**
* 从模型 ID 中提取基础名称。
* 例如:
* - 'deepseek/deepseek-r1' => 'deepseek-r1'
* - 'deepseek-ai/deepseek/deepseek-r1' => 'deepseek-r1'
* @param {string} id 模型 ID
* @param {string} [delimiter='/'] 分隔符,默认为 '/'
* @returns {string} 基础名称
*/
export const getBaseModelName = (id: string, delimiter: string = '/'): string => {
const parts = id.split(delimiter)
return parts[parts.length - 1]
}
/**
* 从模型 ID 中提取基础名称并转换为小写。
* 例如:
* - 'deepseek/DeepSeek-R1' => 'deepseek-r1'
* - 'deepseek-ai/deepseek/DeepSeek-R1' => 'deepseek-r1'
* @param {string} id 模型 ID
* @param {string} [delimiter='/'] 分隔符,默认为 '/'
* @returns {string} 小写的基础名称
*/
export const getLowerBaseModelName = (id: string, delimiter: string = '/'): string => {
return getBaseModelName(id, delimiter).toLowerCase()
}

View File

@@ -114,7 +114,7 @@ export async function addFileLoader(
// HTML类型处理
loaderReturn = await ragApplication.addLoader(
new WebLoader({
urlOrContent: readTextFileWithAutoEncoding(file.path),
urlOrContent: await readTextFileWithAutoEncoding(file.path),
chunkSize: base.chunkSize,
chunkOverlap: base.chunkOverlap
}) as any,
@@ -124,7 +124,7 @@ export async function addFileLoader(
case 'json':
try {
jsonObject = JSON.parse(readTextFileWithAutoEncoding(file.path))
jsonObject = JSON.parse(await readTextFileWithAutoEncoding(file.path))
} catch (error) {
jsonParsed = false
Logger.warn('[KnowledgeBase] failed parsing json file, falling back to text processing:', file.path, error)
@@ -140,7 +140,7 @@ export async function addFileLoader(
// 如果是其他文本类型且尚未读取文件,则读取文件
loaderReturn = await ragApplication.addLoader(
new TextLoader({
text: readTextFileWithAutoEncoding(file.path),
text: await readTextFileWithAutoEncoding(file.path),
chunkSize: base.chunkSize,
chunkOverlap: base.chunkOverlap
}) as any,

View File

@@ -217,7 +217,7 @@ export default class Doc2xPreprocessProvider extends BasePreprocessProvider {
* @param filePath 文件路径
*/
private async convertFile(uid: string, filePath: string): Promise<void> {
const fileName = path.basename(filePath).split('.')[0]
const fileName = path.parse(filePath).name
const config = {
...this.createAuthConfig(),
headers: {

View File

@@ -1,7 +1,7 @@
interface CacheItem<T> {
data: T
timestamp: number
duration: number
duration?: number
}
export class CacheService {
@@ -11,9 +11,9 @@ export class CacheService {
* Set cache
* @param key Cache key
* @param data Cache data
* @param duration Cache duration (in milliseconds)
* @param duration Cache duration (in milliseconds), if not set the cache will never expire
*/
static set<T>(key: string, data: T, duration: number): void {
static set<T>(key: string, data: T, duration?: number): void {
this.cache.set(key, {
data,
timestamp: Date.now(),
@@ -30,6 +30,11 @@ export class CacheService {
const item = this.cache.get(key)
if (!item) return null
// If duration is undefined, cache never expires
if (item.duration === undefined) {
return item.data
}
const now = Date.now()
if (now - item.timestamp > item.duration) {
this.remove(key)
@@ -63,6 +68,11 @@ export class CacheService {
const item = this.cache.get(key)
if (!item) return false
// If duration is undefined, cache never expires
if (item.duration === undefined) {
return true
}
const now = Date.now()
if (now - item.timestamp > item.duration) {
this.remove(key)

View File

@@ -231,7 +231,11 @@ class FileStorage {
await fs.promises.rm(path.join(this.storageDir, id), { recursive: true })
}
public readFile = async (_: Electron.IpcMainInvokeEvent, id: string): Promise<string> => {
public readFile = async (
_: Electron.IpcMainInvokeEvent,
id: string,
detectEncoding: boolean = false
): Promise<string> => {
const filePath = path.join(this.storageDir, id)
const fileExtension = path.extname(filePath)
@@ -259,8 +263,11 @@ class FileStorage {
}
try {
const result = readTextFileWithAutoEncoding(filePath)
return result
if (detectEncoding) {
return readTextFileWithAutoEncoding(filePath)
} else {
return fs.readFileSync(filePath, 'utf-8')
}
} catch (error) {
logger.error(error)
return 'failed to read file'
@@ -417,6 +424,19 @@ class FileStorage {
shell.openPath(path).catch((err) => logger.error('[IPC - Error] Failed to open file:', err))
}
/**
* 通过相对路径打开文件,跨设备时使用
* @param file
*/
public openFileWithRelativePath = async (_: Electron.IpcMainInvokeEvent, file: FileMetadata): Promise<void> => {
const filePath = path.join(this.storageDir, file.name)
if (fs.existsSync(filePath)) {
shell.openPath(filePath).catch((err) => logger.error('[IPC - Error] Failed to open file:', err))
} else {
logger.warn('[IPC - Warning] File does not exist:', filePath)
}
}
public save = async (
_: Electron.IpcMainInvokeEvent,
fileName: string,

View File

@@ -1,7 +1,7 @@
import { SELECTION_FINETUNED_LIST, SELECTION_PREDEFINED_BLACKLIST } from '@main/configs/SelectionConfig'
import { isDev, isMac, isWin } from '@main/constant'
import { IpcChannel } from '@shared/IpcChannel'
import { BrowserWindow, ipcMain, screen, systemPreferences } from 'electron'
import { app, BrowserWindow, ipcMain, screen, systemPreferences } from 'electron'
import Logger from 'electron-log'
import { join } from 'path'
import type {
@@ -509,54 +509,55 @@ export class SelectionService {
//should set every time the window is shown
this.toolbarWindow!.setAlwaysOnTop(true, 'screen-saver')
// [macOS] a series of hacky ways only for macOS
if (isMac) {
// [macOS] a hacky way
// when set `skipTransformProcessType: true`, if the selection is in self app, it will make the selection canceled after toolbar showing
// so we just don't set `skipTransformProcessType: true` when in self app
const isSelf = ['com.github.Electron', 'com.kangfenmao.CherryStudio'].includes(programName)
if (!isSelf) {
// [macOS] an ugly hacky way
// `focusable: true` will make mainWindow disappeared when `setVisibleOnAllWorkspaces`
// so we set `focusable: true` before showing, and then set false after showing
this.toolbarWindow!.setFocusable(false)
// [macOS]
// force `setVisibleOnAllWorkspaces: true` to let toolbar show in all workspaces. And we MUST not set it to false again
// set `skipTransformProcessType: true` to avoid dock icon spinning when `setVisibleOnAllWorkspaces`
this.toolbarWindow!.setVisibleOnAllWorkspaces(true, {
visibleOnFullScreen: true,
skipTransformProcessType: true
})
}
// [macOS] MUST use `showInactive()` to prevent other windows bring to front together
// [Windows] is OK for both `show()` and `showInactive()` because of `focusable: false`
this.toolbarWindow!.showInactive()
// [macOS] restore the focusable status
this.toolbarWindow!.setFocusable(true)
if (!isMac) {
this.toolbarWindow!.show()
/**
* [Windows]
* In Windows 10, setOpacity(1) will make the window completely transparent
* It's a strange behavior, so we don't use it for compatibility
*/
// this.toolbarWindow!.setOpacity(1)
this.startHideByMouseKeyListener()
return
}
/**
* The following is for Windows
*/
/************************************************
* [macOS] the following code is only for macOS
*
* WARNING:
* DO NOT MODIFY THESE CODES, UNLESS YOU REALLY KNOW WHAT YOU ARE DOING!!!!
*************************************************/
this.toolbarWindow!.show()
// [macOS] a hacky way
// when set `skipTransformProcessType: true`, if the selection is in self app, it will make the selection canceled after toolbar showing
// so we just don't set `skipTransformProcessType: true` when in self app
const isSelf = ['com.github.Electron', 'com.kangfenmao.CherryStudio'].includes(programName)
/**
* [Windows]
* In Windows 10, setOpacity(1) will make the window completely transparent
* It's a strange behavior, so we don't use it for compatibility
*/
// this.toolbarWindow!.setOpacity(1)
if (!isSelf) {
// [macOS] an ugly hacky way
// `focusable: true` will make mainWindow disappeared when `setVisibleOnAllWorkspaces`
// so we set `focusable: true` before showing, and then set false after showing
this.toolbarWindow!.setFocusable(false)
// [macOS]
// force `setVisibleOnAllWorkspaces: true` to let toolbar show in all workspaces. And we MUST not set it to false again
// set `skipTransformProcessType: true` to avoid dock icon spinning when `setVisibleOnAllWorkspaces`
this.toolbarWindow!.setVisibleOnAllWorkspaces(true, {
visibleOnFullScreen: true,
skipTransformProcessType: true
})
}
// [macOS] MUST use `showInactive()` to prevent other windows bring to front together
// [Windows] is OK for both `show()` and `showInactive()` because of `focusable: false`
this.toolbarWindow!.showInactive()
// [macOS] restore the focusable status
this.toolbarWindow!.setFocusable(true)
this.startHideByMouseKeyListener()
return
}
/**
@@ -911,6 +912,7 @@ export class SelectionService {
refPoint = { x: Math.round(refPoint.x), y: Math.round(refPoint.y) }
}
// [macOS] isFullscreen is only available on macOS
this.showToolbarAtPosition(refPoint, refOrientation, selectionData.programName)
this.toolbarWindow!.webContents.send(IpcChannel.Selection_TextSelected, selectionData)
}
@@ -1218,20 +1220,26 @@ export class SelectionService {
return actionWindow
}
public processAction(actionItem: ActionItem): void {
/**
* Process action item
* @param actionItem Action item to process
* @param isFullScreen [macOS] only macOS has the available isFullscreen mode
*/
public processAction(actionItem: ActionItem, isFullScreen: boolean = false): void {
const actionWindow = this.popActionWindow()
actionWindow.webContents.send(IpcChannel.Selection_UpdateActionData, actionItem)
this.showActionWindow(actionWindow)
this.showActionWindow(actionWindow, isFullScreen)
}
/**
* Show action window with proper positioning relative to toolbar
* Ensures window stays within screen boundaries
* @param actionWindow Window to position and show
* @param isFullScreen [macOS] only macOS has the available isFullscreen mode
*/
private showActionWindow(actionWindow: BrowserWindow): void {
private showActionWindow(actionWindow: BrowserWindow, isFullScreen: boolean = false): void {
let actionWindowWidth = this.ACTION_WINDOW_WIDTH
let actionWindowHeight = this.ACTION_WINDOW_HEIGHT
@@ -1241,11 +1249,14 @@ export class SelectionService {
actionWindowHeight = this.lastActionWindowSize.height
}
//center way
if (!this.isFollowToolbar || !this.toolbarWindow) {
const display = screen.getDisplayNearestPoint(screen.getCursorScreenPoint())
const workArea = display.workArea
/********************************************
* Setting the position of the action window
********************************************/
const display = screen.getDisplayNearestPoint(screen.getCursorScreenPoint())
const workArea = display.workArea
// Center of the screen
if (!this.isFollowToolbar || !this.toolbarWindow) {
const centerX = workArea.x + (workArea.width - actionWindowWidth) / 2
const centerY = workArea.y + (workArea.height - actionWindowHeight) / 2
@@ -1255,54 +1266,107 @@ export class SelectionService {
x: Math.round(centerX),
y: Math.round(centerY)
})
} else {
// Follow toolbar position
const toolbarBounds = this.toolbarWindow!.getBounds()
const GAP = 6 // 6px gap from screen edges
//make sure action window is inside screen
if (actionWindowWidth > workArea.width - 2 * GAP) {
actionWindowWidth = workArea.width - 2 * GAP
}
if (actionWindowHeight > workArea.height - 2 * GAP) {
actionWindowHeight = workArea.height - 2 * GAP
}
// Calculate initial position to center action window horizontally below toolbar
let posX = Math.round(toolbarBounds.x + (toolbarBounds.width - actionWindowWidth) / 2)
let posY = Math.round(toolbarBounds.y)
// Ensure action window stays within screen boundaries with a small gap
if (posX + actionWindowWidth > workArea.x + workArea.width) {
posX = workArea.x + workArea.width - actionWindowWidth - GAP
} else if (posX < workArea.x) {
posX = workArea.x + GAP
}
if (posY + actionWindowHeight > workArea.y + workArea.height) {
// If window would go below screen, try to position it above toolbar
posY = workArea.y + workArea.height - actionWindowHeight - GAP
} else if (posY < workArea.y) {
posY = workArea.y + GAP
}
actionWindow.setPosition(posX, posY, false)
//KEY to make window not resize
actionWindow.setBounds({
width: actionWindowWidth,
height: actionWindowHeight,
x: posX,
y: posY
})
}
if (!isMac) {
actionWindow.show()
return
}
//follow toolbar
const toolbarBounds = this.toolbarWindow!.getBounds()
const display = screen.getDisplayNearestPoint(screen.getCursorScreenPoint())
const workArea = display.workArea
const GAP = 6 // 6px gap from screen edges
/************************************************
* [macOS] the following code is only for macOS
*
* WARNING:
* DO NOT MODIFY THESE CODES, UNLESS YOU REALLY KNOW WHAT YOU ARE DOING!!!!
*************************************************/
//make sure action window is inside screen
if (actionWindowWidth > workArea.width - 2 * GAP) {
actionWindowWidth = workArea.width - 2 * GAP
// act normally when the app is not in fullscreen mode
if (!isFullScreen) {
actionWindow.show()
return
}
if (actionWindowHeight > workArea.height - 2 * GAP) {
actionWindowHeight = workArea.height - 2 * GAP
}
// [macOS] an UGLY HACKY way for fullscreen override settings
// Calculate initial position to center action window horizontally below toolbar
let posX = Math.round(toolbarBounds.x + (toolbarBounds.width - actionWindowWidth) / 2)
let posY = Math.round(toolbarBounds.y)
// FIXME sometimes the dock will be shown when the action window is shown
// FIXME if actionWindow show on the fullscreen app, switch to other space will cause the mainWindow to be shown
// FIXME When setVisibleOnAllWorkspaces is true, docker icon disappeared when the first action window is shown on the fullscreen app
// use app.dock.show() to show the dock again will cause the action window to be closed when auto hide on blur is enabled
// Ensure action window stays within screen boundaries with a small gap
if (posX + actionWindowWidth > workArea.x + workArea.width) {
posX = workArea.x + workArea.width - actionWindowWidth - GAP
} else if (posX < workArea.x) {
posX = workArea.x + GAP
}
if (posY + actionWindowHeight > workArea.y + workArea.height) {
// If window would go below screen, try to position it above toolbar
posY = workArea.y + workArea.height - actionWindowHeight - GAP
} else if (posY < workArea.y) {
posY = workArea.y + GAP
}
// setFocusable(false) to prevent the action window hide when blur (if auto hide on blur is enabled)
actionWindow.setFocusable(false)
actionWindow.setAlwaysOnTop(true, 'floating')
actionWindow.setPosition(posX, posY, false)
//KEY to make window not resize
actionWindow.setBounds({
width: actionWindowWidth,
height: actionWindowHeight,
x: posX,
y: posY
// `setVisibleOnAllWorkspaces(true)` will cause the dock icon disappeared
// just store the dock icon status, and show it again
const isDockShown = app.dock?.isVisible()
// DO NOT set `skipTransformProcessType: true`,
// it will cause the action window to be shown on other space
actionWindow.setVisibleOnAllWorkspaces(true, {
visibleOnFullScreen: true
})
actionWindow.show()
actionWindow.showInactive()
// show the dock again if last time it was shown
// do not put it after `actionWindow.focus()`, will cause the action window to be closed when auto hide on blur is enabled
if (!app.dock?.isVisible() && isDockShown) {
app.dock?.show()
}
// unset everything
setTimeout(() => {
actionWindow.setVisibleOnAllWorkspaces(false, {
visibleOnFullScreen: true,
skipTransformProcessType: true
})
actionWindow.setAlwaysOnTop(false)
actionWindow.setFocusable(true)
// regain the focus when all the works done
actionWindow.focus()
}, 50)
}
public closeActionWindow(actionWindow: BrowserWindow): void {
@@ -1408,8 +1472,9 @@ export class SelectionService {
configManager.setSelectionAssistantFilterList(filterList)
})
ipcMain.handle(IpcChannel.Selection_ProcessAction, (_, actionItem: ActionItem) => {
selectionService?.processAction(actionItem)
// [macOS] only macOS has the available isFullscreen mode
ipcMain.handle(IpcChannel.Selection_ProcessAction, (_, actionItem: ActionItem, isFullScreen: boolean = false) => {
selectionService?.processAction(actionItem, isFullScreen)
})
ipcMain.handle(IpcChannel.Selection_ActionWindowClose, (event) => {

View File

@@ -0,0 +1,149 @@
import { is } from '@electron-toolkit/utils'
import { isLinux, isMac } from '@main/constant'
import { IpcChannel } from '@shared/IpcChannel'
import { BrowserWindow, nativeTheme } from 'electron'
import { join } from 'path'
import icon from '../../../build/icon.png?asset'
import { titleBarOverlayDark, titleBarOverlayLight } from '../config'
export class SettingsWindowService {
private static instance: SettingsWindowService | null = null
private settingsWindow: BrowserWindow | null = null
public static getInstance(): SettingsWindowService {
if (!SettingsWindowService.instance) {
SettingsWindowService.instance = new SettingsWindowService()
}
return SettingsWindowService.instance
}
public createSettingsWindow(defaultTab?: string): BrowserWindow {
if (this.settingsWindow && !this.settingsWindow.isDestroyed()) {
this.settingsWindow.show()
this.settingsWindow.focus()
return this.settingsWindow
}
this.settingsWindow = new BrowserWindow({
width: 1000,
height: 700,
minWidth: 900,
minHeight: 600,
show: false,
autoHideMenuBar: true,
transparent: false,
vibrancy: 'sidebar',
visualEffectState: 'active',
titleBarStyle: 'hidden',
titleBarOverlay: nativeTheme.shouldUseDarkColors ? titleBarOverlayDark : titleBarOverlayLight,
backgroundColor: isMac ? undefined : nativeTheme.shouldUseDarkColors ? '#181818' : '#FFFFFF',
darkTheme: nativeTheme.shouldUseDarkColors,
trafficLightPosition: { x: 12, y: 12 },
...(isLinux ? { icon } : {}),
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
webSecurity: false,
webviewTag: true,
allowRunningInsecureContent: true,
backgroundThrottling: false
}
})
this.setupSettingsWindow()
this.loadSettingsWindowContent(defaultTab)
return this.settingsWindow
}
private setupSettingsWindow() {
if (!this.settingsWindow) return
this.settingsWindow.on('ready-to-show', () => {
this.settingsWindow?.show()
})
this.settingsWindow.on('closed', () => {
this.settingsWindow = null
})
this.settingsWindow.on('close', () => {
// Clean up when window is closed
})
// Handle theme changes
nativeTheme.on('updated', () => {
if (this.settingsWindow && !this.settingsWindow.isDestroyed()) {
this.settingsWindow.setTitleBarOverlay(
nativeTheme.shouldUseDarkColors ? titleBarOverlayDark : titleBarOverlayLight
)
}
})
}
private loadSettingsWindowContent(defaultTab?: string) {
if (!this.settingsWindow) return
const queryParam = defaultTab ? `?tab=${defaultTab}` : ''
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
this.settingsWindow.loadURL(process.env['ELECTRON_RENDERER_URL'] + '/settingsWindow.html' + queryParam)
} else {
this.settingsWindow.loadFile(join(__dirname, '../renderer/settingsWindow.html'))
if (defaultTab) {
this.settingsWindow.webContents.once('did-finish-load', () => {
this.settingsWindow?.webContents.send(IpcChannel.SettingsWindow_Show, { defaultTab })
})
}
}
}
public showSettingsWindow(defaultTab?: string) {
if (this.settingsWindow && !this.settingsWindow.isDestroyed()) {
if (this.settingsWindow.isMinimized()) {
this.settingsWindow.restore()
}
if (!isLinux) {
this.settingsWindow.setVisibleOnAllWorkspaces(true)
}
this.settingsWindow.show()
this.settingsWindow.focus()
if (!isLinux) {
this.settingsWindow.setVisibleOnAllWorkspaces(false)
}
} else {
this.createSettingsWindow(defaultTab)
}
}
public hideSettingsWindow() {
if (this.settingsWindow && !this.settingsWindow.isDestroyed()) {
this.settingsWindow.hide()
}
}
public closeSettingsWindow() {
if (this.settingsWindow && !this.settingsWindow.isDestroyed()) {
this.settingsWindow.close()
}
}
public getSettingsWindow(): BrowserWindow | null {
return this.settingsWindow
}
public static registerIpcHandler() {
const { ipcMain } = require('electron')
const service = SettingsWindowService.getInstance()
ipcMain.handle(IpcChannel.SettingsWindow_Show, (_, options?: { defaultTab?: string }) => {
service.showSettingsWindow(options?.defaultTab)
})
}
}
export const settingsWindowService = SettingsWindowService.getInstance()

View File

@@ -5,10 +5,12 @@ import Logger from 'electron-log'
import { configManager } from './ConfigManager'
import selectionService from './SelectionService'
import { settingsWindowService } from './SettingsWindowService'
import { windowService } from './WindowService'
let showAppAccelerator: string | null = null
let showMiniWindowAccelerator: string | null = null
let showSettingsAccelerator: string | null = null
let selectionAssistantToggleAccelerator: string | null = null
let selectionAssistantSelectTextAccelerator: string | null = null
@@ -26,6 +28,10 @@ function getShortcutHandler(shortcut: Shortcut) {
return (window: BrowserWindow) => handleZoomFactor([window], -0.1)
case 'zoom_reset':
return (window: BrowserWindow) => handleZoomFactor([window], 0, true)
case 'show_settings':
return () => {
settingsWindowService.showSettingsWindow()
}
case 'show_app':
return () => {
windowService.toggleMainWindow()
@@ -146,9 +152,13 @@ export function registerShortcuts(window: BrowserWindow) {
// only register universal shortcuts when needed
if (
onlyUniversalShortcuts &&
!['show_app', 'mini_window', 'selection_assistant_toggle', 'selection_assistant_select_text'].includes(
shortcut.key
)
![
'show_app',
'mini_window',
'show_settings',
'selection_assistant_toggle',
'selection_assistant_select_text'
].includes(shortcut.key)
) {
return
}
@@ -171,6 +181,10 @@ export function registerShortcuts(window: BrowserWindow) {
showMiniWindowAccelerator = formatShortcutKey(shortcut.shortcut)
break
case 'show_settings':
showSettingsAccelerator = formatShortcutKey(shortcut.shortcut)
break
case 'selection_assistant_toggle':
selectionAssistantToggleAccelerator = formatShortcutKey(shortcut.shortcut)
break
@@ -222,6 +236,12 @@ export function registerShortcuts(window: BrowserWindow) {
handler && globalShortcut.register(accelerator, () => handler(window))
}
if (showSettingsAccelerator) {
const handler = getShortcutHandler({ key: 'show_settings' } as Shortcut)
const accelerator = convertShortcutFormat(showSettingsAccelerator)
handler && globalShortcut.register(accelerator, () => handler(window))
}
if (selectionAssistantToggleAccelerator) {
const handler = getShortcutHandler({ key: 'selection_assistant_toggle' } as Shortcut)
const accelerator = convertShortcutFormat(selectionAssistantToggleAccelerator)
@@ -258,6 +278,7 @@ export function unregisterAllShortcuts() {
try {
showAppAccelerator = null
showMiniWindowAccelerator = null
showSettingsAccelerator = null
selectionAssistantToggleAccelerator = null
selectionAssistantSelectTextAccelerator = null
windowOnHandlers.forEach((handlers, window) => {

View File

@@ -12,6 +12,7 @@ import { join } from 'path'
import icon from '../../../build/icon.png?asset'
import { titleBarOverlayDark, titleBarOverlayLight } from '../config'
import { CacheService } from './CacheService'
import { configManager } from './ConfigManager'
import { contextMenu } from './ContextMenu'
import { initSessionUserAgent } from './WebviewService'
@@ -63,7 +64,7 @@ export class WindowService {
titleBarOverlay: nativeTheme.shouldUseDarkColors ? titleBarOverlayDark : titleBarOverlayLight,
backgroundColor: isMac ? undefined : nativeTheme.shouldUseDarkColors ? '#181818' : '#FFFFFF',
darkTheme: nativeTheme.shouldUseDarkColors,
trafficLightPosition: { x: 8, y: 12 },
trafficLightPosition: { x: 12, y: 12 },
...(isLinux ? { icon } : {}),
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
@@ -313,6 +314,11 @@ export class WindowService {
return app.quit()
}
if (CacheService.get('navigation-url') !== '/') {
event.preventDefault()
return mainWindow.webContents.send(IpcChannel.Navigation_Close)
}
// 托盘及关闭行为设置
const isShowTray = configManager.getTray()
const isTrayOnClose = configManager.getTrayOnClose()
@@ -435,8 +441,9 @@ export class WindowService {
show: false,
autoHideMenuBar: true,
transparent: isMac,
vibrancy: 'under-window',
visualEffectState: 'followWindow',
vibrancy: isMac ? 'under-window' : undefined,
visualEffectState: isMac ? 'followWindow' : undefined,
backgroundMaterial: isWin ? 'acrylic' : undefined,
center: true,
frame: false,
alwaysOnTop: true,

View File

@@ -44,7 +44,9 @@ export function handleMcpProtocolUrl(url: URL) {
// }
// }
// cherrystudio://mcp/install?servers={base64Encode(JSON.stringify(jsonConfig))}
const data = params.get('servers')
if (data) {
const stringify = Buffer.from(data, 'base64').toString('utf8')
Logger.info('install MCP servers from urlschema: ', stringify)
@@ -63,10 +65,8 @@ export function handleMcpProtocolUrl(url: URL) {
}
}
const mainWindow = windowService.getMainWindow()
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.executeJavaScript("window.navigate('/settings/mcp')")
}
windowService.getMainWindow()?.show()
break
}
default:

View File

@@ -1,16 +1,19 @@
import * as fs from 'node:fs'
import * as fsPromises from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { FileTypes } from '@types'
import iconv from 'iconv-lite'
import { detectAll as detectEncodingAll } from 'jschardet'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { detectEncoding, readTextFileWithAutoEncoding } from '../file'
import { readTextFileWithAutoEncoding } from '../file'
import { getAllFiles, getAppConfigDir, getConfigDir, getFilesDir, getFileType, getTempDir } from '../file'
// Mock dependencies
vi.mock('node:fs')
vi.mock('node:fs/promises')
vi.mock('node:os')
vi.mock('node:path')
vi.mock('uuid', () => ({
@@ -244,102 +247,52 @@ describe('file', () => {
})
})
// 在 describe('file') 块内部添加新的 describe 块
describe('detectEncoding', () => {
const mockFilePath = '/path/to/mock/file.txt'
beforeEach(() => {
vi.mocked(fs.openSync).mockReturnValue(123)
vi.mocked(fs.closeSync).mockImplementation(() => {})
})
it('should correctly detect UTF-8 encoding', () => {
// 准备UTF-8编码的Buffer
const content = '这是UTF-8测试内容'
const buffer = Buffer.from(content, 'utf-8')
// 模拟文件读取
vi.mocked(fs.readSync).mockImplementation((_, buf) => {
const targetBuffer = new Uint8Array(buf.buffer)
const sourceBuffer = new Uint8Array(buffer)
targetBuffer.set(sourceBuffer)
return 1024
})
const encoding = detectEncoding(mockFilePath)
expect(encoding).toBe('UTF-8')
})
it('should correctly detect GB2312 encoding', () => {
// 使用iconv创建GB2312编码内容
const content = '这是一段GB2312编码的测试内容'
const gb2312Buffer = iconv.encode(content, 'GB2312')
// 模拟文件读取
vi.mocked(fs.readSync).mockImplementation((_, buf) => {
const targetBuffer = new Uint8Array(buf.buffer)
const sourceBuffer = new Uint8Array(gb2312Buffer)
targetBuffer.set(sourceBuffer)
return gb2312Buffer.length
})
const encoding = detectEncoding(mockFilePath)
expect(encoding).toMatch(/GB2312|GB18030/i)
})
it('should correctly detect ASCII encoding', () => {
// 准备ASCII编码内容
const content = 'ASCII content'
const buffer = Buffer.from(content, 'ascii')
// 模拟文件读取
vi.mocked(fs.readSync).mockImplementation((_, buf) => {
const targetBuffer = new Uint8Array(buf.buffer)
const sourceBuffer = new Uint8Array(buffer)
targetBuffer.set(sourceBuffer)
return buffer.length
})
const encoding = detectEncoding(mockFilePath)
expect(encoding.toLowerCase()).toBe('ascii')
})
})
describe('readTextFileWithAutoEncoding', () => {
const mockFilePath = '/path/to/mock/file.txt'
beforeEach(() => {
vi.mocked(fs.openSync).mockReturnValue(123)
vi.mocked(fs.closeSync).mockImplementation(() => {})
})
it('should read file with auto encoding', () => {
it('should read file with auto encoding', async () => {
const content = '这是一段GB2312编码的测试内容'
const buffer = iconv.encode(content, 'GB2312')
vi.mocked(fs.readSync).mockImplementation((_, buf) => {
const targetBuffer = new Uint8Array(buf.buffer)
const sourceBuffer = new Uint8Array(buffer)
targetBuffer.set(sourceBuffer)
return buffer.length
})
vi.mocked(fs.readFileSync).mockReturnValue(buffer)
const result = readTextFileWithAutoEncoding(mockFilePath)
// 创建模拟的 FileHandle 对象
const mockFileHandle = {
read: vi.fn().mockResolvedValue({
bytesRead: buffer.byteLength,
buffer: buffer
}),
close: vi.fn().mockResolvedValue(undefined)
}
// 模拟 open 方法
vi.spyOn(fsPromises, 'open').mockResolvedValue(mockFileHandle as any)
vi.spyOn(fsPromises, 'readFile').mockResolvedValue(buffer)
const result = await readTextFileWithAutoEncoding(mockFilePath)
expect(result).toBe(content)
})
it('should try to fix bad detected encoding', () => {
it('should try to fix bad detected encoding', async () => {
const content = '这是一段GB2312编码的测试内容'
const buffer = iconv.encode(content, 'GB2312')
vi.mocked(fs.readSync).mockImplementation((_, buf) => {
const targetBuffer = new Uint8Array(buf.buffer)
const sourceBuffer = new Uint8Array(buffer)
targetBuffer.set(sourceBuffer)
return buffer.length
})
vi.mocked(fs.readFileSync).mockReturnValue(buffer)
vi.mocked(vi.fn(detectEncoding)).mockReturnValue('UTF-8')
const result = readTextFileWithAutoEncoding(mockFilePath)
// 创建模拟的 FileHandle 对象
const mockFileHandle = {
read: vi.fn().mockResolvedValue({
bytesRead: buffer.byteLength,
buffer: buffer
}),
close: vi.fn().mockResolvedValue(undefined)
}
// 模拟 fs.open 方法
vi.spyOn(fsPromises, 'open').mockResolvedValue(mockFileHandle as any)
vi.spyOn(fsPromises, 'readFile').mockResolvedValue(buffer)
vi.mocked(vi.fn(detectEncodingAll)).mockReturnValue([
{ encoding: 'UTF-8', confidence: 0.9 },
{ encoding: 'GB2312', confidence: 0.8 }
])
const result = await readTextFileWithAutoEncoding(mockFilePath)
expect(result).toBe(content)
})
})

View File

@@ -1,14 +1,15 @@
import * as fs from 'node:fs'
import { open, readFile } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { isLinux, isPortable } from '@main/constant'
import { audioExts, documentExts, imageExts, textExts, videoExts } from '@shared/config/constant'
import { audioExts, documentExts, imageExts, MB, textExts, videoExts } from '@shared/config/constant'
import { FileMetadata, FileTypes } from '@types'
import { app } from 'electron'
import Logger from 'electron-log'
import iconv from 'iconv-lite'
import { detect as detectEncoding_, detectAll as detectEncodingAll } from 'jschardet'
import * as jschardet from 'jschardet'
import { v4 as uuidv4 } from 'uuid'
export function initAppDataDir() {
@@ -206,56 +207,48 @@ export function getAppConfigDir(name: string) {
return path.join(getConfigDir(), name)
}
/**
* 使用 jschardet 库检测文件编码格式
* @param filePath - 文件路径
* @returns 返回文件的编码格式,如 UTF-8, ascii, GB2312 等
*/
export function detectEncoding(filePath: string): string {
// 读取文件前1KB来检测编码
const buffer = Buffer.alloc(1024)
const fd = fs.openSync(filePath, 'r')
fs.readSync(fd, buffer, 0, 1024, 0)
fs.closeSync(fd)
const { encoding } = detectEncoding_(buffer)
return encoding
}
/**
* 读取文件内容并自动检测编码格式进行解码
* @param filePath - 文件路径
* @returns 解码后的文件内容
*/
export function readTextFileWithAutoEncoding(filePath: string) {
const encoding = detectEncoding(filePath)
const data = fs.readFileSync(filePath)
const content = iconv.decode(data, encoding)
export async function readTextFileWithAutoEncoding(filePath: string): Promise<string> {
// 读取前1MB以检测编码
const buffer = Buffer.alloc(1 * MB)
const fh = await open(filePath, 'r')
const { buffer: bufferRead } = await fh.read(buffer, 0, 1 * MB, 0)
await fh.close()
if (content.includes('\uFFFD') && encoding !== 'UTF-8') {
Logger.error(`文件 ${filePath} 自动识别编码为 ${encoding},但包含错误字符。尝试其他编码`)
const buffer = Buffer.alloc(1024)
const fd = fs.openSync(filePath, 'r')
fs.readSync(fd, buffer, 0, 1024, 0)
fs.closeSync(fd)
const encodings = detectEncodingAll(buffer)
if (encodings.length > 0) {
for (const item of encodings) {
if (item.encoding === encoding) {
continue
}
Logger.log(`尝试使用 ${item.encoding} 解码文件 ${filePath}`)
const content = iconv.decode(buffer, item.encoding)
if (!content.includes('\uFFFD')) {
Logger.log(`文件 ${filePath} 解码成功,编码为 ${item.encoding}`)
return content
} else {
Logger.error(`文件 ${filePath} 使用 ${item.encoding} 解码失败,尝试下一个编码`)
}
}
}
Logger.error(`文件 ${filePath} 所有可能的编码均解码失败,尝试使用 UTF-8 解码`)
return iconv.decode(buffer, 'UTF-8')
// 获取文件编码格式,最多取前两个可能的编码
const encodings = jschardet
.detectAll(bufferRead)
.map((item) => ({
...item,
encoding: item.encoding === 'ascii' ? 'UTF-8' : item.encoding
}))
.filter((item, index, array) => array.findIndex((prevItem) => prevItem.encoding === item.encoding) === index)
.slice(0, 2)
if (encodings.length === 0) {
Logger.error('Failed to detect encoding. Use utf-8 to decode.')
const data = await readFile(filePath)
return iconv.decode(data, 'UTF-8')
}
return content
const data = await readFile(filePath)
for (const item of encodings) {
const encoding = item.encoding
const content = iconv.decode(data, encoding)
if (content.includes('\uFFFD')) {
Logger.error(
`File ${filePath} was auto-detected as ${encoding} encoding, but contains invalid characters. Trying other encodings`
)
} else {
return content
}
}
Logger.error(`File ${filePath} failed to decode with all possible encodings, trying UTF-8 encoding`)
return iconv.decode(data, 'UTF-8')
}

View File

@@ -115,7 +115,8 @@ const api = {
upload: (file: FileMetadata) => ipcRenderer.invoke(IpcChannel.File_Upload, file),
delete: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Delete, fileId),
deleteDir: (dirPath: string) => ipcRenderer.invoke(IpcChannel.File_DeleteDir, dirPath),
read: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Read, fileId),
read: (fileId: string, detectEncoding?: boolean) =>
ipcRenderer.invoke(IpcChannel.File_Read, fileId, detectEncoding),
clear: () => ipcRenderer.invoke(IpcChannel.File_Clear),
get: (filePath: string) => ipcRenderer.invoke(IpcChannel.File_Get, filePath),
/**
@@ -146,7 +147,8 @@ const api = {
copy: (fileId: string, destPath: string) => ipcRenderer.invoke(IpcChannel.File_Copy, fileId, destPath),
base64File: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Base64File, fileId),
pdfInfo: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_GetPdfInfo, fileId),
getPathForFile: (file: File) => webUtils.getPathForFile(file)
getPathForFile: (file: File) => webUtils.getPathForFile(file),
openFileWithRelativePath: (file: FileMetadata) => ipcRenderer.invoke(IpcChannel.File_OpenWithRelativePath, file)
},
fs: {
read: (pathOrUrl: string, encoding?: BufferEncoding) => ipcRenderer.invoke(IpcChannel.Fs_Read, pathOrUrl, encoding)
@@ -309,14 +311,32 @@ const api = {
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),
processAction: (actionItem: ActionItem, isFullScreen: boolean = false) =>
ipcRenderer.invoke(IpcChannel.Selection_ProcessAction, actionItem, isFullScreen),
closeActionWindow: () => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowClose),
minimizeActionWindow: () => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowMinimize),
pinActionWindow: (isPinned: boolean) => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowPin, isPinned)
},
quoteToMainWindow: (text: string) => ipcRenderer.invoke(IpcChannel.App_QuoteToMain, text),
navigation: {
url: (url: string) => ipcRenderer.invoke(IpcChannel.Navigation_Url, url)
},
setDisableHardwareAcceleration: (isDisable: boolean) =>
ipcRenderer.invoke(IpcChannel.App_SetDisableHardwareAcceleration, isDisable)
ipcRenderer.invoke(IpcChannel.App_SetDisableHardwareAcceleration, isDisable),
// Settings Window
showSettingsWindow: (options?: { defaultTab?: string }) =>
ipcRenderer.invoke(IpcChannel.SettingsWindow_Show, options),
on: (channel: string, func: any) => {
const listener = (_event: Electron.IpcRendererEvent, ...args: any[]) => {
func(...args)
}
ipcRenderer.on(channel, listener)
return () => {
ipcRenderer.off(channel, listener)
}
}
}
// Use `contextBridge` APIs to expose Electron APIs to

View File

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

View File

@@ -2,10 +2,10 @@ import '@renderer/databases'
import store, { persistor } from '@renderer/store'
import { Provider } from 'react-redux'
import { HashRouter, Route, Routes } from 'react-router-dom'
import { HashRouter } from 'react-router-dom'
import { PersistGate } from 'redux-persist/integration/react'
import Sidebar from './components/app/Sidebar'
import AppLayout from './components/Layout/AppLayout'
import TopViewContainer from './components/TopView'
import AntdProvider from './context/AntdProvider'
import { CodeStyleProvider } from './context/CodeStyleProvider'
@@ -13,14 +13,8 @@ import { NotificationProvider } from './context/NotificationProvider'
import StyleSheetManager from './context/StyleSheetManager'
import { ThemeProvider } from './context/ThemeProvider'
import NavigationHandler from './handler/NavigationHandler'
import AgentsPage from './pages/agents/AgentsPage'
import AppsPage from './pages/apps/AppsPage'
import FilesPage from './pages/files/FilesPage'
import HomePage from './pages/home/HomePage'
import KnowledgePage from './pages/knowledge/KnowledgePage'
import PaintingsRoutePage from './pages/paintings/PaintingsRoutePage'
import SettingsPage from './pages/settings/SettingsPage'
import TranslatePage from './pages/translate/TranslatePage'
import { ChatProvider } from './hooks/useChat'
import Routes from './Routes'
function App(): React.ReactElement {
return (
@@ -31,22 +25,16 @@ function App(): React.ReactElement {
<NotificationProvider>
<CodeStyleProvider>
<PersistGate loading={null} persistor={persistor}>
<TopViewContainer>
<HashRouter>
<HashRouter>
<TopViewContainer>
<NavigationHandler />
<Sidebar />
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/agents" element={<AgentsPage />} />
<Route path="/paintings/*" element={<PaintingsRoutePage />} />
<Route path="/translate" element={<TranslatePage />} />
<Route path="/files" element={<FilesPage />} />
<Route path="/knowledge" element={<KnowledgePage />} />
<Route path="/apps" element={<AppsPage />} />
<Route path="/settings/*" element={<SettingsPage />} />
</Routes>
</HashRouter>
</TopViewContainer>
<ChatProvider>
<AppLayout>
<Routes />
</AppLayout>
</ChatProvider>
</TopViewContainer>
</HashRouter>
</PersistGate>
</CodeStyleProvider>
</NotificationProvider>

View File

@@ -0,0 +1,29 @@
import { Route, Routes } from 'react-router-dom'
import AgentsPage from './pages/agents/AgentsPage'
import AppsPage from './pages/apps/AppsPage'
import FilesPage from './pages/files/FilesPage'
import HomePage from './pages/home/HomePage'
import KnowledgePage from './pages/knowledge/KnowledgePage'
import McpServersPage from './pages/mcp-servers'
import PaintingsRoutePage from './pages/paintings/PaintingsRoutePage'
import TranslatePage from './pages/translate/TranslatePage'
const RouteContainer = () => {
return (
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/agents" element={<AgentsPage />} />
<Route path="/paintings/*" element={<PaintingsRoutePage />} />
<Route path="/translate" element={<TranslatePage />} />
<Route path="/files" element={<FilesPage />} />
<Route path="/knowledge" element={<KnowledgePage />} />
<Route path="/apps" element={<AppsPage />} />
<Route path="/mcp-servers/*" element={<McpServersPage />} />
{/* <Route path="/settings/*" element={<SettingsPage />} />
<Route path="/launchpad" element={<LaunchpadPage />} /> */}
</Routes>
)
}
export default RouteContainer

View File

@@ -254,7 +254,7 @@ export abstract class BaseApiClient<
for (const fileBlock of textFileBlocks) {
const file = fileBlock.file
const fileContent = (await window.api.file.read(file.id + file.ext)).trim()
const fileContent = (await window.api.file.read(file.id + file.ext, true)).trim()
const fileNameRow = 'file: ' + file.origin_name + '\n\n'
text = text + fileNameRow + fileContent + divider
}

View File

@@ -49,10 +49,10 @@ import {
LLMWebSearchCompleteChunk,
LLMWebSearchInProgressChunk,
MCPToolCreatedChunk,
TextCompleteChunk,
TextDeltaChunk,
ThinkingCompleteChunk,
ThinkingDeltaChunk
TextStartChunk,
ThinkingDeltaChunk,
ThinkingStartChunk
} from '@renderer/types/chunk'
import { type Message } from '@renderer/types/newMessage'
import {
@@ -231,7 +231,7 @@ export class AnthropicAPIClient extends BaseApiClient<
}
})
} else {
const fileContent = await (await window.api.file.read(file.id + file.ext)).trim()
const fileContent = await (await window.api.file.read(file.id + file.ext, true)).trim()
parts.push({
type: 'text',
text: file.origin_name + '\n' + fileContent
@@ -519,7 +519,6 @@ export class AnthropicAPIClient extends BaseApiClient<
return () => {
let accumulatedJson = ''
const toolCalls: Record<number, ToolUseBlock> = {}
const ChunkIdTypeMap: Record<number, ChunkType> = {}
return {
async transform(rawChunk: AnthropicSdkRawChunk, controller: TransformStreamDefaultController<GenericChunk>) {
switch (rawChunk.type) {
@@ -615,16 +614,16 @@ export class AnthropicAPIClient extends BaseApiClient<
break
}
case 'text': {
if (!ChunkIdTypeMap[rawChunk.index]) {
ChunkIdTypeMap[rawChunk.index] = ChunkType.TEXT_DELTA // 用textdelta代表文本块
}
controller.enqueue({
type: ChunkType.TEXT_START
} as TextStartChunk)
break
}
case 'thinking':
case 'redacted_thinking': {
if (!ChunkIdTypeMap[rawChunk.index]) {
ChunkIdTypeMap[rawChunk.index] = ChunkType.THINKING_DELTA // 用thinkingdelta代表思考块
}
controller.enqueue({
type: ChunkType.THINKING_START
} as ThinkingStartChunk)
break
}
}
@@ -661,15 +660,6 @@ export class AnthropicAPIClient extends BaseApiClient<
break
}
case 'content_block_stop': {
if (ChunkIdTypeMap[rawChunk.index] === ChunkType.TEXT_DELTA) {
controller.enqueue({
type: ChunkType.TEXT_COMPLETE
} as TextCompleteChunk)
} else if (ChunkIdTypeMap[rawChunk.index] === ChunkType.THINKING_DELTA) {
controller.enqueue({
type: ChunkType.THINKING_COMPLETE
} as ThinkingCompleteChunk)
}
const toolCall = toolCalls[rawChunk.index]
if (toolCall) {
try {

View File

@@ -41,7 +41,7 @@ import {
ToolCallResponse,
WebSearchSource
} from '@renderer/types'
import { ChunkType, LLMWebSearchCompleteChunk } from '@renderer/types/chunk'
import { ChunkType, LLMWebSearchCompleteChunk, TextStartChunk, ThinkingStartChunk } from '@renderer/types/chunk'
import { Message } from '@renderer/types/newMessage'
import {
GeminiOptions,
@@ -288,7 +288,7 @@ export class GeminiAPIClient extends BaseApiClient<
continue
}
if ([FileTypes.TEXT, FileTypes.DOCUMENT].includes(file.type)) {
const fileContent = await (await window.api.file.read(file.id + file.ext)).trim()
const fileContent = await (await window.api.file.read(file.id + file.ext, true)).trim()
parts.push({
text: file.origin_name + '\n' + fileContent
})
@@ -547,20 +547,34 @@ export class GeminiAPIClient extends BaseApiClient<
}
getResponseChunkTransformer(): ResponseChunkTransformer<GeminiSdkRawChunk> {
const toolCalls: FunctionCall[] = []
let isFirstTextChunk = true
let isFirstThinkingChunk = true
return () => ({
async transform(chunk: GeminiSdkRawChunk, controller: TransformStreamDefaultController<GenericChunk>) {
const toolCalls: FunctionCall[] = []
if (chunk.candidates && chunk.candidates.length > 0) {
for (const candidate of chunk.candidates) {
if (candidate.content) {
candidate.content.parts?.forEach((part) => {
const text = part.text || ''
if (part.thought) {
if (isFirstThinkingChunk) {
controller.enqueue({
type: ChunkType.THINKING_START
} as ThinkingStartChunk)
isFirstThinkingChunk = false
}
controller.enqueue({
type: ChunkType.THINKING_DELTA,
text: text
})
} else if (part.text) {
if (isFirstTextChunk) {
controller.enqueue({
type: ChunkType.TEXT_START
} as TextStartChunk)
isFirstTextChunk = false
}
controller.enqueue({
type: ChunkType.TEXT_DELTA,
text: text
@@ -593,6 +607,13 @@ export class GeminiAPIClient extends BaseApiClient<
}
} as LLMWebSearchCompleteChunk)
}
if (toolCalls.length > 0) {
controller.enqueue({
type: ChunkType.MCP_TOOL_CREATED,
tool_calls: [...toolCalls]
})
toolCalls.length = 0
}
controller.enqueue({
type: ChunkType.LLM_RESPONSE_COMPLETE,
response: {

View File

@@ -31,7 +31,7 @@ import {
ToolCallResponse,
WebSearchSource
} from '@renderer/types'
import { ChunkType } from '@renderer/types/chunk'
import { ChunkType, TextStartChunk, ThinkingStartChunk } from '@renderer/types/chunk'
import { Message } from '@renderer/types/newMessage'
import {
OpenAISdkMessageParam,
@@ -307,7 +307,7 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
}
if ([FileTypes.TEXT, FileTypes.DOCUMENT].includes(file.type)) {
const fileContent = await (await window.api.file.read(file.id + file.ext)).trim()
const fileContent = await (await window.api.file.read(file.id + file.ext, true)).trim()
parts.push({
type: 'text',
text: file.origin_name + '\n' + fileContent
@@ -659,6 +659,8 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
isFinished = true
}
let isFirstThinkingChunk = true
let isFirstTextChunk = true
return (context: ResponseChunkTransformerContext) => ({
async transform(chunk: OpenAISdkRawChunk, controller: TransformStreamDefaultController<GenericChunk>) {
// 持续更新usage信息
@@ -699,6 +701,12 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
// @ts-ignore - reasoning_content is not in standard OpenAI types but some providers use it
const reasoningText = contentSource.reasoning_content || contentSource.reasoning
if (reasoningText) {
if (isFirstThinkingChunk) {
controller.enqueue({
type: ChunkType.THINKING_START
} as ThinkingStartChunk)
isFirstThinkingChunk = false
}
controller.enqueue({
type: ChunkType.THINKING_DELTA,
text: reasoningText
@@ -707,6 +715,12 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
// 处理文本内容
if (contentSource.content) {
if (isFirstTextChunk) {
controller.enqueue({
type: ChunkType.TEXT_START
} as TextStartChunk)
isFirstTextChunk = false
}
controller.enqueue({
type: ChunkType.TEXT_DELTA,
text: contentSource.content

View File

@@ -89,7 +89,7 @@ export abstract class OpenAIBaseClient<
const data = await sdk.embeddings.create({
model: model.id,
input: model?.provider === 'baidu-cloud' ? ['hi'] : 'hi',
encoding_format: 'float'
encoding_format: this.provider.id === 'voyageai' ? undefined : 'float'
})
return data.data[0].embedding.length
}

View File

@@ -39,7 +39,7 @@ import { findFileBlocks, findImageBlocks } from '@renderer/utils/messageUtils/fi
import { buildSystemPrompt } from '@renderer/utils/prompt'
import { MB } from '@shared/config/constant'
import { isEmpty } from 'lodash'
import OpenAI from 'openai'
import OpenAI, { AzureOpenAI } from 'openai'
import { ResponseInput } from 'openai/resources/responses/responses'
import { RequestTransformer, ResponseChunkTransformer } from '../types'
@@ -66,6 +66,9 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
*/
public getClient(model: Model) {
if (isOpenAILLMModel(model) && !isOpenAIChatCompletionOnlyModel(model)) {
if (this.provider.id === 'azure-openai' || this.provider.type === 'azure-openai') {
this.provider = { ...this.provider, apiVersion: 'preview' }
}
return this
} else {
return this.client
@@ -77,15 +80,25 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
return this.sdkInstance
}
return new OpenAI({
dangerouslyAllowBrowser: true,
apiKey: this.apiKey,
baseURL: this.getBaseURL(),
defaultHeaders: {
...this.defaultHeaders(),
...this.provider.extra_headers
}
})
if (this.provider.id === 'azure-openai' || this.provider.type === 'azure-openai') {
this.provider = { ...this.provider, apiHost: `${this.provider.apiHost}/openai/v1` }
return new AzureOpenAI({
dangerouslyAllowBrowser: true,
apiKey: this.apiKey,
apiVersion: this.provider.apiVersion,
baseURL: this.provider.apiHost
})
} else {
return new OpenAI({
dangerouslyAllowBrowser: true,
apiKey: this.apiKey,
baseURL: this.getBaseURL(),
defaultHeaders: {
...this.defaultHeaders(),
...this.provider.extra_headers
}
})
}
}
override async createCompletions(
@@ -173,7 +186,7 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
}
if ([FileTypes.TEXT, FileTypes.DOCUMENT].includes(file.type)) {
const fileContent = (await window.api.file.read(file.id + file.ext)).trim()
const fileContent = (await window.api.file.read(file.id + file.ext, true)).trim()
parts.push({
type: 'input_text',
text: file.origin_name + '\n' + fileContent
@@ -354,16 +367,15 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
(m) => (m as OpenAI.Responses.EasyInputMessage).role === 'assistant'
) as OpenAI.Responses.EasyInputMessage
const finalUserMessage = userMessage.pop() as OpenAI.Responses.EasyInputMessage
if (
finalAssistantMessage &&
Array.isArray(finalAssistantMessage.content) &&
finalUserMessage &&
Array.isArray(finalUserMessage.content)
) {
finalAssistantMessage.content = [...finalAssistantMessage.content, ...finalUserMessage.content]
if (finalUserMessage && Array.isArray(finalUserMessage.content)) {
if (finalAssistantMessage && Array.isArray(finalAssistantMessage.content)) {
finalAssistantMessage.content = [...finalAssistantMessage.content, ...finalUserMessage.content]
// 这里是故意将上条助手消息的内容(包含图片和文件)作为用户消息发送
userMessage = [{ ...finalAssistantMessage, role: 'user' } as OpenAI.Responses.EasyInputMessage]
} else {
userMessage.push(finalUserMessage)
}
}
// 这里是故意将上条助手消息的内容(包含图片和文件)作为用户消息发送
userMessage = [{ ...finalAssistantMessage, role: 'user' } as OpenAI.Responses.EasyInputMessage]
}
// 4. 最终请求消息
@@ -424,6 +436,8 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
const outputItems: OpenAI.Responses.ResponseOutputItem[] = []
let hasBeenCollectedToolCalls = false
let hasReasoningSummary = false
let isFirstThinkingChunk = true
let isFirstTextChunk = true
return () => ({
async transform(chunk: OpenAIResponseSdkRawChunk, controller: TransformStreamDefaultController<GenericChunk>) {
// 处理chunk
@@ -435,6 +449,12 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
switch (output.type) {
case 'message':
if (output.content[0].type === 'output_text') {
if (isFirstTextChunk) {
controller.enqueue({
type: ChunkType.TEXT_START
})
isFirstTextChunk = false
}
controller.enqueue({
type: ChunkType.TEXT_DELTA,
text: output.content[0].text
@@ -451,6 +471,12 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
}
break
case 'reasoning':
if (isFirstThinkingChunk) {
controller.enqueue({
type: ChunkType.THINKING_START
})
isFirstThinkingChunk = false
}
controller.enqueue({
type: ChunkType.THINKING_DELTA,
text: output.summary.map((s) => s.text).join('\n')
@@ -510,6 +536,12 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
hasReasoningSummary = true
break
case 'response.reasoning_summary_text.delta':
if (isFirstThinkingChunk) {
controller.enqueue({
type: ChunkType.THINKING_START
})
isFirstThinkingChunk = false
}
controller.enqueue({
type: ChunkType.THINKING_DELTA,
text: chunk.delta
@@ -535,6 +567,12 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
})
break
case 'response.output_text.delta': {
if (isFirstTextChunk) {
controller.enqueue({
type: ChunkType.TEXT_START
})
isFirstTextChunk = false
}
controller.enqueue({
type: ChunkType.TEXT_DELTA,
text: chunk.delta

View File

@@ -75,11 +75,12 @@ export default class AiProvider {
} else {
// Existing logic for other models
if (!params.enableReasoning) {
builder.remove(ThinkingTagExtractionMiddlewareName)
// 这里注释掉不会影响正常的关闭思考,可忽略不计的性能下降
// builder.remove(ThinkingTagExtractionMiddlewareName)
builder.remove(ThinkChunkMiddlewareName)
}
// 注意用client判断会导致typescript类型收窄
if (!(this.apiClient instanceof OpenAIAPIClient)) {
if (!(this.apiClient instanceof OpenAIAPIClient) && !(this.apiClient instanceof OpenAIResponseAPIClient)) {
builder.remove(ThinkingTagExtractionMiddlewareName)
}
if (!(this.apiClient instanceof AnthropicAPIClient) && !(this.apiClient instanceof OpenAIResponseAPIClient)) {

View File

@@ -252,7 +252,9 @@ async function executeToolCalls(
('name' in toolCall &&
(toolCall.name?.includes(confirmed.tool.name) || toolCall.name?.includes(confirmed.tool.id))) ||
confirmed.tool.name === toolCall.id ||
confirmed.tool.id === toolCall.id
confirmed.tool.id === toolCall.id ||
('toolCallId' in confirmed && confirmed.toolCallId === toolCall.id) ||
('function' in toolCall && toolCall.function.name.toLowerCase().includes(confirmed.tool.name.toLowerCase()))
)
})
})

View File

@@ -1,5 +1,5 @@
import Logger from '@renderer/config/logger'
import { ChunkType, TextCompleteChunk, TextDeltaChunk } from '@renderer/types/chunk'
import { ChunkType, TextDeltaChunk } from '@renderer/types/chunk'
import { CompletionsParams, CompletionsResult, GenericChunk } from '../schemas'
import { CompletionsContext, CompletionsMiddleware } from '../types'
@@ -38,7 +38,6 @@ export const TextChunkMiddleware: CompletionsMiddleware =
// 用于跨chunk的状态管理
let accumulatedTextContent = ''
let hasTextCompleteEventEnqueue = false
const enhancedTextStream = resultFromUpstream.pipeThrough(
new TransformStream<GenericChunk, GenericChunk>({
transform(chunk: GenericChunk, controller) {
@@ -53,18 +52,7 @@ export const TextChunkMiddleware: CompletionsMiddleware =
// 创建新的chunk包含处理后的文本
controller.enqueue(chunk)
} else if (chunk.type === ChunkType.TEXT_COMPLETE) {
const textChunk = chunk as TextCompleteChunk
controller.enqueue({
...textChunk,
text: accumulatedTextContent
})
if (params.onResponse) {
params.onResponse(accumulatedTextContent, true)
}
hasTextCompleteEventEnqueue = true
accumulatedTextContent = ''
} else if (accumulatedTextContent && !hasTextCompleteEventEnqueue) {
} else if (accumulatedTextContent && chunk.type !== ChunkType.TEXT_START) {
if (chunk.type === ChunkType.LLM_RESPONSE_COMPLETE) {
const finalText = accumulatedTextContent
ctx._internal.customState!.accumulatedText = finalText
@@ -89,7 +77,6 @@ export const TextChunkMiddleware: CompletionsMiddleware =
})
controller.enqueue(chunk)
}
hasTextCompleteEventEnqueue = true
accumulatedTextContent = ''
} else {
// 其他类型的chunk直接传递

View File

@@ -65,17 +65,7 @@ export const ThinkChunkMiddleware: CompletionsMiddleware =
thinking_millsec: thinkingStartTime > 0 ? Date.now() - thinkingStartTime : 0
}
controller.enqueue(enhancedChunk)
} else if (chunk.type === ChunkType.THINKING_COMPLETE) {
const thinkingCompleteChunk = chunk as ThinkingCompleteChunk
controller.enqueue({
...thinkingCompleteChunk,
text: accumulatedThinkingContent,
thinking_millsec: thinkingStartTime > 0 ? Date.now() - thinkingStartTime : 0
})
hasThinkingContent = false
accumulatedThinkingContent = ''
thinkingStartTime = 0
} else if (hasThinkingContent && thinkingStartTime > 0) {
} else if (hasThinkingContent && thinkingStartTime > 0 && chunk.type !== ChunkType.THINKING_START) {
// 收到任何非THINKING_DELTA的chunk时如果有累积的思考内容生成THINKING_COMPLETE
const thinkingCompleteChunk: ThinkingCompleteChunk = {
type: ChunkType.THINKING_COMPLETE,

View File

@@ -1,5 +1,11 @@
import { Model } from '@renderer/types'
import { ChunkType, TextDeltaChunk, ThinkingCompleteChunk, ThinkingDeltaChunk } from '@renderer/types/chunk'
import {
ChunkType,
TextDeltaChunk,
ThinkingCompleteChunk,
ThinkingDeltaChunk,
ThinkingStartChunk
} from '@renderer/types/chunk'
import { TagConfig, TagExtractor } from '@renderer/utils/tagExtraction'
import Logger from 'electron-log/renderer'
@@ -59,6 +65,8 @@ export const ThinkingTagExtractionMiddleware: CompletionsMiddleware =
let hasThinkingContent = false
let thinkingStartTime = 0
let isFirstTextChunk = true
const processedStream = resultFromUpstream.pipeThrough(
new TransformStream<GenericChunk, GenericChunk>({
transform(chunk: GenericChunk, controller) {
@@ -87,6 +95,9 @@ export const ThinkingTagExtractionMiddleware: CompletionsMiddleware =
if (!hasThinkingContent) {
hasThinkingContent = true
thinkingStartTime = Date.now()
controller.enqueue({
type: ChunkType.THINKING_START
} as ThinkingStartChunk)
}
if (extractionResult.content?.trim()) {
@@ -98,6 +109,12 @@ export const ThinkingTagExtractionMiddleware: CompletionsMiddleware =
controller.enqueue(thinkingDeltaChunk)
}
} else {
if (isFirstTextChunk) {
controller.enqueue({
type: ChunkType.TEXT_START
})
isFirstTextChunk = false
}
// 发送清理后的文本内容
const cleanTextChunk: TextDeltaChunk = {
...textChunk,
@@ -107,7 +124,7 @@ export const ThinkingTagExtractionMiddleware: CompletionsMiddleware =
}
}
}
} else {
} else if (chunk.type !== ChunkType.TEXT_START) {
// 其他类型的chunk直接传递包括 THINKING_DELTA, THINKING_COMPLETE 等)
controller.enqueue(chunk)
}

View File

@@ -79,6 +79,7 @@ function createToolUseExtractionTransform(
toolCounter += toolUseResponses.length
if (toolUseResponses.length > 0) {
controller.enqueue({ type: ChunkType.TEXT_COMPLETE, text: '' })
// 生成 MCP_TOOL_CREATED chunk
const mcpToolCreatedChunk: MCPToolCreatedChunk = {
type: ChunkType.MCP_TOOL_CREATED,

View File

@@ -25,7 +25,6 @@
}
.minapp-drawer {
max-width: calc(100vw - var(--sidebar-width));
.ant-drawer-content-wrapper {
box-shadow: none;
}
@@ -33,7 +32,7 @@
position: absolute;
-webkit-app-region: drag;
min-height: calc(var(--navbar-height) + 0.5px);
width: calc(100vw - var(--sidebar-width));
width: 100%;
margin-top: -0.5px;
border-bottom: none;
}

View File

@@ -29,7 +29,7 @@
--color-text-secondary: rgba(235, 235, 245, 0.7);
--color-icon: #ffffff99;
--color-icon-white: #ffffff;
--color-border: #ffffff19;
--color-border: #383838;
--color-border-soft: #ffffff10;
--color-border-mute: #ffffff05;
--color-error: #f44336;
@@ -44,7 +44,7 @@
--color-reference-text: #ffffff;
--color-reference-background: #0b0e12;
--color-list-item: #222;
--color-list-item: #252525;
--color-list-item-hover: #1e1e1e;
--modal-background: #111111;
@@ -54,25 +54,14 @@
--color-background-highlight-accent: rgba(255, 150, 50, 0.9);
--navbar-background-mac: rgba(20, 20, 20, 0.55);
--navbar-background-win: rgba(20, 20, 20, 0.75);
--navbar-background: #1f1f1f;
--navbar-height: 40px;
--sidebar-width: 50px;
--status-bar-height: 40px;
--input-bar-height: 100px;
--assistants-width: 275px;
--topic-list-width: 275px;
--settings-width: 250px;
--scrollbar-width: 5px;
--chat-background: transparent;
--chat-background-user: rgba(255, 255, 255, 0.08);
--chat-background-assistant: transparent;
--chat-text-user: var(--color-black);
--list-item-border-radius: 20px;
--color-status-success: #52c41a;
--color-status-error: #ff4d4f;
--color-status-warning: #faad14;
@@ -124,8 +113,8 @@
--color-reference-text: #000000;
--color-reference-background: #f1f7ff;
--color-list-item: #eee;
--color-list-item-hover: #f5f5f5;
--color-list-item: rgba(0, 0, 0, 0.05);
--color-list-item-hover: rgba(0, 0, 0, 0.03);
--modal-background: var(--color-white);
@@ -134,6 +123,7 @@
--color-background-highlight-accent: rgba(255, 150, 50, 0.5);
--navbar-background-mac: rgba(255, 255, 255, 0.55);
--navbar-background-win: rgba(255, 255, 255, 0.75);
--navbar-background: rgba(244, 244, 244);
--chat-background: transparent;

View File

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

View File

@@ -1,3 +1,4 @@
@use './variables.scss';
@use './color.scss';
@use './font.scss';
@use './markdown.scss';
@@ -139,7 +140,7 @@ ul {
}
}
.message-content-container {
border-radius: 10px 0 10px 10px;
border-radius: 10px;
padding: 10px 16px 10px 16px;
background-color: var(--chat-background-user);
align-self: self-end;

View File

@@ -19,12 +19,14 @@
h4,
h5,
h6 {
margin: 1em 0 1em 0;
margin: 1.5em 0 1em 0;
line-height: 1.3;
font-weight: bold;
font-family: var(--font-family);
}
h1 {
margin-top: 0;
font-size: 2em;
border-bottom: 0.5px solid var(--color-border);
padding-bottom: 0.3em;
@@ -53,8 +55,9 @@
}
p {
margin: 1em 0;
margin: 1.3em 0;
white-space: pre-wrap;
line-height: 1.6;
&:last-child {
margin-bottom: 5px;
@@ -82,7 +85,7 @@
li {
margin-bottom: 0.5em;
pre {
margin: 1.5em 0;
margin: 1.5em 0 !important;
}
&::marker {
color: var(--color-text-3);
@@ -108,6 +111,7 @@
li code {
background: var(--color-background-mute);
padding: 3px 5px;
margin: 0 2px;
border-radius: 5px;
word-break: keep-all;
white-space: pre;
@@ -122,9 +126,7 @@
overflow-x: auto;
font-family: 'Fira Code', 'Courier New', Courier, monospace;
background-color: var(--color-background-mute);
&:has(.mermaid),
&:has(.plantuml-preview),
&:has(.svg-preview) {
&:has(.special-preview) {
background-color: transparent;
}
&:not(pre pre) {
@@ -148,16 +150,19 @@
}
blockquote {
margin: 1em 0;
padding-left: 1em;
color: var(--color-text-light);
border-left: 4px solid var(--color-border);
font-family: var(--font-family);
margin: 1.5em 0;
padding: 1em 1.5em;
background-color: var(--color-background-soft);
border-left: 4px solid var(--color-primary);
border-radius: 0 8px 8px 0;
font-style: italic;
position: relative;
}
table {
--table-border-radius: 8px;
margin: 1em 0;
margin: 2em 0;
font-size: 0.9em;
width: 100%;
border-radius: var(--table-border-radius);
overflow: hidden;
@@ -182,13 +187,19 @@
th {
background-color: var(--color-background-mute);
font-weight: bold;
font-weight: 600;
font-family: var(--font-family);
text-align: left;
}
tr:hover {
background-color: var(--color-background-soft);
}
img {
max-width: 100%;
height: auto;
margin: 10px 0;
}
a,

View File

@@ -0,0 +1,18 @@
:root {
--navbar-height: 42px;
--sidebar-width: 50px;
--status-bar-height: 40px;
--input-bar-height: 100px;
--assistants-width: 275px;
--topic-list-width: 275px;
--settings-width: 250px;
--scrollbar-width: 5px;
--list-item-border-radius: 15px;
--border-width: 0.5px;
--main-height: 100vh;
--border-width: 0.5px;
}

View File

@@ -1,24 +1,28 @@
import { Alert } from 'antd'
import { t } from 'i18next'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
const LOCALSTORAGE_KEY = 'openai_alert_closed'
const OpenAIAlert = () => {
const { t } = useTranslation()
interface Props {
message?: string
key?: string
}
const OpenAIAlert = ({ message = t('settings.provider.openai.alert'), key = LOCALSTORAGE_KEY }: Props) => {
const [visible, setVisible] = useState(false)
useEffect(() => {
const closed = localStorage.getItem(LOCALSTORAGE_KEY)
const closed = localStorage.getItem(key)
setVisible(!closed)
}, [])
}, [key])
if (!visible) return null
return (
<Alert
style={{ width: '100%', marginTop: 5, marginBottom: 5 }}
message={t('settings.provider.openai.alert')}
message={message}
closable
afterClose={() => {
localStorage.setItem(LOCALSTORAGE_KEY, '1')

View File

@@ -1,4 +1,4 @@
import { CodeTool, TOOL_SPECS, useCodeTool } from '@renderer/components/CodeToolbar'
import { TOOL_SPECS, useCodeTool } from '@renderer/components/CodeToolbar'
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
import { useCodeHighlight } from '@renderer/hooks/useCodeHighlight'
import { useSettings } from '@renderer/hooks/useSettings'
@@ -12,10 +12,10 @@ import { useTranslation } from 'react-i18next'
import { ThemedToken } from 'shiki/core'
import styled from 'styled-components'
interface CodePreviewProps {
children: string
import { BasicPreviewProps } from './types'
interface CodePreviewProps extends BasicPreviewProps {
language: string
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
}
const MAX_COLLAPSE_HEIGHT = 350
@@ -164,19 +164,11 @@ const CodePreview = ({ children, language, setTools }: CodePreviewProps) => {
}}>
<div
style={{
/*
* FIXME: @tanstack/react-virtual 使用绝对定位,但是会导致
* 有气泡样式 `self-end` 并且气泡中只有代码块时整个代码块收缩
* 到最小宽度(目前应该是工具栏的宽度)。改为相对定位可以保证宽
* 度足够,目前没有发现其他副作用。
* 如果发现破坏虚拟列表功能,或者将来有更好的解决方案,再调整。
*/
position: 'relative',
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItems[0]?.start ?? 0}px)`,
willChange: 'transform'
transform: `translateY(${virtualItems[0]?.start ?? 0}px)`
}}>
{virtualizer.getVirtualItems().map((virtualItem) => (
<div key={virtualItem.key} data-index={virtualItem.index} ref={virtualizer.measureElement}>

View File

@@ -0,0 +1,102 @@
import { usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
import SvgSpinners180Ring from '@renderer/components/Icons/SvgSpinners180Ring'
import { AsyncInitializer } from '@renderer/utils/asyncInitializer'
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'
import PreviewError from './PreviewError'
import { BasicPreviewProps } from './types'
// 管理 viz 实例
const vizInitializer = new AsyncInitializer(async () => {
const module = await import('@viz-js/viz')
return await module.instance()
})
/** 预览 Graphviz 图表
* 通过防抖渲染提供比较统一的体验,减少闪烁。
*/
const GraphvizPreview: React.FC<BasicPreviewProps> = ({ children, setTools }) => {
const graphvizRef = useRef<HTMLDivElement>(null)
const [error, setError] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false)
// 使用通用图像工具
const { handleZoom, handleCopyImage, handleDownload } = usePreviewToolHandlers(graphvizRef, {
imgSelector: 'svg',
prefix: 'graphviz',
enableWheelZoom: true
})
// 使用工具栏
usePreviewTools({
setTools,
handleZoom,
handleCopyImage,
handleDownload
})
// 实际的渲染函数
const renderGraphviz = useCallback(async (content: string) => {
if (!content || !graphvizRef.current) return
try {
setIsLoading(true)
const viz = await vizInitializer.get()
const svgElement = viz.renderSVGElement(content)
// 清空容器并添加新的 SVG
graphvizRef.current.innerHTML = ''
graphvizRef.current.appendChild(svgElement)
// 渲染成功,清除错误记录
setError(null)
} catch (error) {
setError((error as Error).message || 'DOT syntax error or rendering failed')
} finally {
setIsLoading(false)
}
}, [])
// debounce 渲染
const debouncedRender = useMemo(
() =>
debounce((content: string) => {
startTransition(() => renderGraphviz(content))
}, 300),
[renderGraphviz]
)
// 触发渲染
useEffect(() => {
if (children) {
setIsLoading(true)
debouncedRender(children)
} else {
debouncedRender.cancel()
setIsLoading(false)
}
return () => {
debouncedRender.cancel()
}
}, [children, debouncedRender])
return (
<Spin spinning={isLoading} indicator={<SvgSpinners180Ring color="var(--color-text-2)" />}>
<Flex vertical style={{ minHeight: isLoading ? '2rem' : 'auto' }}>
{error && <PreviewError>{error}</PreviewError>}
<StyledGraphviz ref={graphvizRef} className="graphviz special-preview" />
</Flex>
</Spin>
)
}
const StyledGraphviz = styled.div`
overflow: auto;
`
export default memo(GraphvizPreview)

View File

@@ -1,70 +0,0 @@
import { ExpandOutlined, LinkOutlined } from '@ant-design/icons'
import { AppLogo } from '@renderer/config/env'
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
import { extractTitle } from '@renderer/utils/formats'
import { Button } from 'antd'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface Props {
html: string
}
const Artifacts: FC<Props> = ({ html }) => {
const { t } = useTranslation()
const { openMinapp } = useMinappPopup()
/**
* 在应用内打开
*/
const handleOpenInApp = async () => {
const path = await window.api.file.createTempFile('artifacts-preview.html')
await window.api.file.write(path, html)
const filePath = `file://${path}`
const title = extractTitle(html) || 'Artifacts ' + t('chat.artifacts.button.preview')
openMinapp({
id: 'artifacts-preview',
name: title,
logo: AppLogo,
url: filePath
})
}
/**
* 外部链接打开
*/
const handleOpenExternal = async () => {
const path = await window.api.file.createTempFile('artifacts-preview.html')
await window.api.file.write(path, html)
const filePath = `file://${path}`
if (window.api.shell && window.api.shell.openExternal) {
window.api.shell.openExternal(filePath)
} else {
console.error(t('artifacts.preview.openExternal.error.content'))
}
}
return (
<Container>
<Button icon={<ExpandOutlined />} onClick={handleOpenInApp}>
{t('chat.artifacts.button.preview')}
</Button>
<Button icon={<LinkOutlined />} onClick={handleOpenExternal}>
{t('chat.artifacts.button.openExternal')}
</Button>
</Container>
)
}
const Container = styled.div`
margin: 10px;
display: flex;
flex-direction: row;
gap: 8px;
padding-bottom: 10px;
`
export default Artifacts

View File

@@ -0,0 +1,404 @@
import { CodeOutlined, LinkOutlined } from '@ant-design/icons'
import { useTheme } from '@renderer/context/ThemeProvider'
import { ThemeMode } from '@renderer/types'
import { extractTitle } from '@renderer/utils/formats'
import { Button } from 'antd'
import { Code, Download, Globe, Sparkles } from 'lucide-react'
import { FC, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ClipLoader } from 'react-spinners'
import styled, { keyframes } from 'styled-components'
import HtmlArtifactsPopup from './HtmlArtifactsPopup'
interface Props {
html: string
}
const HtmlArtifactsCard: FC<Props> = ({ html }) => {
const { t } = useTranslation()
const title = extractTitle(html) || 'HTML Artifacts'
const [isPopupOpen, setIsPopupOpen] = useState(false)
const { theme } = useTheme()
const htmlContent = html || ''
const hasContent = htmlContent.trim().length > 0
// 判断是否正在流式生成的逻辑
const isStreaming = useMemo(() => {
if (!hasContent) return false
const trimmedHtml = htmlContent.trim()
// 提前检查:如果包含关键的结束标签,直接判断为完整文档
if (/<\/html\s*>/i.test(trimmedHtml)) {
return false
}
// 如果同时包含 DOCTYPE 和 </body>,通常也是完整文档
if (/<!DOCTYPE\s+html/i.test(trimmedHtml) && /<\/body\s*>/i.test(trimmedHtml)) {
return false
}
// 检查 HTML 是否看起来是完整的
const indicators = {
// 1. 检查常见的 HTML 结构完整性
hasHtmlTag: /<html[^>]*>/i.test(trimmedHtml),
hasClosingHtmlTag: /<\/html\s*>$/i.test(trimmedHtml),
// 2. 检查 body 标签完整性
hasBodyTag: /<body[^>]*>/i.test(trimmedHtml),
hasClosingBodyTag: /<\/body\s*>/i.test(trimmedHtml),
// 3. 检查是否以未闭合的标签结尾
endsWithIncompleteTag: /<[^>]*$/.test(trimmedHtml),
// 4. 检查是否有未配对的标签
hasUnmatchedTags: checkUnmatchedTags(trimmedHtml),
// 5. 检查是否以常见的"流式结束"模式结尾
endsWithTypicalCompletion: /(<\/html>\s*|<\/body>\s*|<\/div>\s*|<\/script>\s*|<\/style>\s*)$/i.test(trimmedHtml)
}
// 如果有明显的未完成标志,则认为正在生成
if (indicators.endsWithIncompleteTag || indicators.hasUnmatchedTags) {
return true
}
// 如果有 HTML 结构但不完整
if (indicators.hasHtmlTag && !indicators.hasClosingHtmlTag) {
return true
}
// 如果有 body 结构但不完整
if (indicators.hasBodyTag && !indicators.hasClosingBodyTag) {
return true
}
// 对于简单的 HTML 片段,检查是否看起来是完整的
if (!indicators.hasHtmlTag && !indicators.hasBodyTag) {
// 如果是简单片段且没有明显的结束标志,可能还在生成
return !indicators.endsWithTypicalCompletion && trimmedHtml.length < 500
}
return false
}, [htmlContent, hasContent])
// 检查未配对标签的辅助函数
function checkUnmatchedTags(html: string): boolean {
const stack: string[] = []
const tagRegex = /<\/?([a-zA-Z][a-zA-Z0-9]*)[^>]*>/g
// HTML5 void 元素(自闭合元素)的完整列表
const voidElements = [
'area',
'base',
'br',
'col',
'embed',
'hr',
'img',
'input',
'link',
'meta',
'param',
'source',
'track',
'wbr'
]
let match
while ((match = tagRegex.exec(html)) !== null) {
const [fullTag, tagName] = match
const isClosing = fullTag.startsWith('</')
const isSelfClosing = fullTag.endsWith('/>') || voidElements.includes(tagName.toLowerCase())
if (isSelfClosing) continue
if (isClosing) {
if (stack.length === 0 || stack.pop() !== tagName.toLowerCase()) {
return true // 找到不匹配的闭合标签
}
} else {
stack.push(tagName.toLowerCase())
}
}
return stack.length > 0 // 还有未闭合的标签
}
// 获取格式化的代码预览
function getFormattedCodePreview(html: string): string {
const trimmed = html.trim()
const lines = trimmed.split('\n')
const lastFewLines = lines.slice(-3) // 显示最后3行
return lastFewLines.join('\n')
}
/**
* 在编辑器中打开
*/
const handleOpenInEditor = () => {
setIsPopupOpen(true)
}
/**
* 关闭弹窗
*/
const handleClosePopup = () => {
setIsPopupOpen(false)
}
/**
* 外部链接打开
*/
const handleOpenExternal = async () => {
const path = await window.api.file.createTempFile('artifacts-preview.html')
await window.api.file.write(path, htmlContent)
const filePath = `file://${path}`
if (window.api.shell && window.api.shell.openExternal) {
window.api.shell.openExternal(filePath)
} else {
console.error(t('artifacts.preview.openExternal.error.content'))
}
}
/**
* 下载到本地
*/
const handleDownload = async () => {
const fileName = `${title.replace(/[^a-zA-Z0-9\s]/g, '').replace(/\s+/g, '-') || 'html-artifact'}.html`
await window.api.file.save(fileName, htmlContent)
window.message.success({ content: t('message.download.success'), key: 'download' })
}
return (
<>
<Container $isStreaming={isStreaming}>
<Header>
<IconWrapper $isStreaming={isStreaming}>
{isStreaming ? <Sparkles size={20} color="white" /> : <Globe size={20} color="white" />}
</IconWrapper>
<TitleSection>
<Title>{title}</Title>
<TypeBadge>
<Code size={12} />
<span>HTML</span>
</TypeBadge>
</TitleSection>
</Header>
<Content>
{isStreaming && !hasContent ? (
<GeneratingContainer>
<ClipLoader size={20} color="var(--color-primary)" />
<GeneratingText>{t('html_artifacts.generating_content', 'Generating content...')}</GeneratingText>
</GeneratingContainer>
) : isStreaming && hasContent ? (
<>
<TerminalPreview $theme={theme}>
<TerminalContent $theme={theme}>
<TerminalLine>
<TerminalPrompt $theme={theme}>$</TerminalPrompt>
<TerminalCodeLine $theme={theme}>
{getFormattedCodePreview(htmlContent)}
<TerminalCursor $theme={theme} />
</TerminalCodeLine>
</TerminalLine>
</TerminalContent>
</TerminalPreview>
<ButtonContainer>
<Button icon={<CodeOutlined />} onClick={handleOpenInEditor} type="primary">
{t('chat.artifacts.button.preview')}
</Button>
</ButtonContainer>
</>
) : (
<ButtonContainer>
<Button icon={<CodeOutlined />} onClick={handleOpenInEditor} type="primary" disabled={!hasContent}>
{t('chat.artifacts.button.preview')}
</Button>
<Button icon={<LinkOutlined />} onClick={handleOpenExternal} disabled={!hasContent}>
{t('chat.artifacts.button.openExternal')}
</Button>
<Button icon={<Download size={16} />} onClick={handleDownload} disabled={!hasContent}>
{t('code_block.download')}
</Button>
</ButtonContainer>
)}
</Content>
</Container>
{/* 弹窗组件 */}
<HtmlArtifactsPopup open={isPopupOpen} title={title} html={htmlContent} onClose={handleClosePopup} />
</>
)
}
const shimmer = keyframes`
0% {
background-position: -200px 0;
}
100% {
background-position: calc(200px + 100%) 0;
}
`
const Container = styled.div<{ $isStreaming: boolean }>`
background: var(--color-background);
border: 1px solid var(--color-border);
border-radius: 8px;
overflow: hidden;
margin: 16px 0;
`
const GeneratingContainer = styled.div`
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
padding: 20px;
min-height: 78px;
`
const GeneratingText = styled.div`
font-size: 14px;
color: var(--color-text-secondary);
`
const Header = styled.div`
display: flex;
align-items: center;
gap: 12px;
padding: 20px 24px 16px;
background: var(--color-background-soft);
border-bottom: 1px solid var(--color-border);
position: relative;
border-radius: 8px 8px 0 0;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, #3b82f6, #8b5cf6, #06b6d4);
background-size: 200% 100%;
animation: ${shimmer} 3s ease-in-out infinite;
border-radius: 8px 8px 0 0;
}
`
const IconWrapper = styled.div<{ $isStreaming: boolean }>`
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
border-radius: 12px;
color: white;
box-shadow: 0 4px 6px -1px rgba(59, 130, 246, 0.3);
transition: background 0.3s ease;
${(props) =>
props.$isStreaming &&
`
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); /* Darker orange for loading */
box-shadow: 0 4px 6px -1px rgba(245, 158, 11, 0.3);
`}
`
const TitleSection = styled.div`
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
`
const Title = styled.h3`
margin: 0 !important;
font-size: 16px;
font-weight: 600;
color: var(--color-text);
line-height: 1.4;
`
const TypeBadge = styled.div`
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 6px;
background: var(--color-background-mute);
border: 1px solid var(--color-border);
border-radius: 6px;
font-size: 10px;
font-weight: 500;
color: var(--color-text-secondary);
width: fit-content;
`
const Content = styled.div`
padding: 0;
background: var(--color-background);
`
const ButtonContainer = styled.div`
margin: 16px !important;
display: flex;
flex-direction: row;
gap: 8px;
`
const TerminalPreview = styled.div<{ $theme: ThemeMode }>`
margin: 16px;
background: ${(props) => (props.$theme === 'dark' ? '#1e1e1e' : '#f0f0f0')};
border-radius: 8px;
overflow: hidden;
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace;
`
const TerminalContent = styled.div<{ $theme: ThemeMode }>`
padding: 12px;
background: ${(props) => (props.$theme === 'dark' ? '#1e1e1e' : '#f0f0f0')};
color: ${(props) => (props.$theme === 'dark' ? '#cccccc' : '#333333')};
font-size: 13px;
line-height: 1.4;
min-height: 80px;
`
const TerminalLine = styled.div`
display: flex;
align-items: flex-start;
gap: 8px;
`
const TerminalCodeLine = styled.span<{ $theme: ThemeMode }>`
flex: 1;
white-space: pre-wrap;
word-break: break-word;
color: ${(props) => (props.$theme === 'dark' ? '#cccccc' : '#333333')};
background-color: transparent !important;
`
const TerminalPrompt = styled.span<{ $theme: ThemeMode }>`
color: ${(props) => (props.$theme === 'dark' ? '#00ff00' : '#007700')};
font-weight: bold;
flex-shrink: 0;
`
const TerminalCursor = styled.span<{ $theme: ThemeMode }>`
display: inline-block;
width: 2px;
height: 16px;
background: ${(props) => (props.$theme === 'dark' ? '#00ff00' : '#007700')};
animation: ${keyframes`
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
`} 1s infinite;
margin-left: 2px;
`
export default HtmlArtifactsCard

View File

@@ -0,0 +1,459 @@
import CodeEditor from '@renderer/components/CodeEditor'
import { isMac } from '@renderer/config/constant'
import { classNames } from '@renderer/utils'
import { Button, Modal } from 'antd'
import { Code, Maximize2, Minimize2, Monitor, MonitorSpeaker, X } from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface HtmlArtifactsPopupProps {
open: boolean
title: string
html: string
onClose: () => void
}
type ViewMode = 'split' | 'code' | 'preview'
// 视图模式配置
const VIEW_MODE_CONFIG = {
split: {
key: 'split' as const,
icon: MonitorSpeaker,
i18nKey: 'html_artifacts.split'
},
code: {
key: 'code' as const,
icon: Code,
i18nKey: 'html_artifacts.code'
},
preview: {
key: 'preview' as const,
icon: Monitor,
i18nKey: 'html_artifacts.preview'
}
} as const
// 抽取头部组件
interface ModalHeaderProps {
title: string
isFullscreen: boolean
viewMode: ViewMode
onViewModeChange: (mode: ViewMode) => void
onToggleFullscreen: () => void
onCancel: () => void
}
const ModalHeaderComponent: React.FC<ModalHeaderProps> = ({
title,
isFullscreen,
viewMode,
onViewModeChange,
onToggleFullscreen,
onCancel
}) => {
const { t } = useTranslation()
const viewButtons = useMemo(() => {
return Object.values(VIEW_MODE_CONFIG).map(({ key, icon: Icon, i18nKey }) => (
<ViewButton
key={key}
size="small"
type={viewMode === key ? 'primary' : 'default'}
icon={<Icon size={14} />}
onClick={() => onViewModeChange(key)}>
{t(i18nKey)}
</ViewButton>
))
}, [viewMode, onViewModeChange, t])
return (
<ModalHeader onDoubleClick={onToggleFullscreen} className={classNames({ drag: isFullscreen })}>
<HeaderLeft $isFullscreen={isFullscreen}>
<TitleText>{title}</TitleText>
</HeaderLeft>
<HeaderCenter>
<ViewControls>{viewButtons}</ViewControls>
</HeaderCenter>
<HeaderRight>
<Button
onClick={onToggleFullscreen}
type="text"
icon={isFullscreen ? <Minimize2 size={16} /> : <Maximize2 size={16} />}
className="nodrag"
/>
<Button onClick={onCancel} type="text" icon={<X size={16} />} className="nodrag" />
</HeaderRight>
</ModalHeader>
)
}
// 抽取代码编辑器组件
interface CodeSectionProps {
html: string
visible: boolean
onCodeChange: (code: string) => void
}
const CodeSectionComponent: React.FC<CodeSectionProps> = ({ html, visible, onCodeChange }) => {
if (!visible) return null
return (
<CodeSection $visible={visible}>
<CodeEditorWrapper>
<CodeEditor
value={html}
language="html"
editable={true}
onSave={onCodeChange}
style={{ height: '100%' }}
options={{
stream: false,
collapsible: false
}}
/>
</CodeEditorWrapper>
</CodeSection>
)
}
// 抽取预览组件
interface PreviewSectionProps {
html: string
visible: boolean
}
const PreviewSectionComponent: React.FC<PreviewSectionProps> = ({ html, visible }) => {
const htmlContent = html || ''
const [debouncedHtml, setDebouncedHtml] = useState(htmlContent)
const intervalRef = useRef<NodeJS.Timeout | null>(null)
const latestHtmlRef = useRef(htmlContent)
const currentRenderedHtmlRef = useRef(htmlContent)
const { t } = useTranslation()
// 更新最新的HTML内容引用
useEffect(() => {
latestHtmlRef.current = htmlContent
}, [htmlContent])
// 固定频率渲染 HTML 内容每2秒钟检查并更新一次
useEffect(() => {
// 立即设置初始内容
setDebouncedHtml(htmlContent)
currentRenderedHtmlRef.current = htmlContent
// 设置定时器每2秒检查一次内容是否有变化
intervalRef.current = setInterval(() => {
if (latestHtmlRef.current !== currentRenderedHtmlRef.current) {
setDebouncedHtml(latestHtmlRef.current)
currentRenderedHtmlRef.current = latestHtmlRef.current
}
}, 2000) // 2秒固定频率
// 清理函数
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
}
}
}, []) // 只在组件挂载时执行一次
if (!visible) return null
const isHtmlEmpty = !debouncedHtml.trim()
return (
<PreviewSection $visible={visible}>
{isHtmlEmpty ? (
<EmptyPreview>
<p>{t('html_artifacts.empty_preview', 'No content to preview')}</p>
</EmptyPreview>
) : (
<PreviewFrame
key={debouncedHtml} // 强制重新创建iframe当内容变化时
srcDoc={debouncedHtml}
title="HTML Preview"
sandbox="allow-scripts allow-same-origin allow-forms"
/>
)}
</PreviewSection>
)
}
// 主弹窗组件
const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, html, onClose }) => {
const [viewMode, setViewMode] = useState<ViewMode>('split')
const [currentHtml, setCurrentHtml] = useState(html)
const [isFullscreen, setIsFullscreen] = useState(false)
// 当外部html更新时同步更新内部状态
useEffect(() => {
setCurrentHtml(html)
}, [html])
// 计算视图可见性
const viewVisibility = useMemo(
() => ({
code: viewMode === 'split' || viewMode === 'code',
preview: viewMode === 'split' || viewMode === 'preview'
}),
[viewMode]
)
// 计算Modal属性
const modalProps = useMemo(
() => ({
width: isFullscreen ? '100vw' : '90vw',
height: isFullscreen ? '100vh' : 'auto',
style: { maxWidth: isFullscreen ? '100vw' : '1400px' }
}),
[isFullscreen]
)
const handleOk = useCallback(() => {
onClose()
}, [onClose])
const handleCancel = useCallback(() => {
onClose()
}, [onClose])
const handleClose = useCallback(() => {
onClose()
}, [onClose])
const handleCodeChange = useCallback((newCode: string) => {
setCurrentHtml(newCode)
}, [])
const toggleFullscreen = useCallback(() => {
setIsFullscreen((prev) => !prev)
}, [])
const handleViewModeChange = useCallback((mode: ViewMode) => {
setViewMode(mode)
}, [])
return (
<StyledModal
$isFullscreen={isFullscreen}
title={
<ModalHeaderComponent
title={title}
isFullscreen={isFullscreen}
viewMode={viewMode}
onViewModeChange={handleViewModeChange}
onToggleFullscreen={toggleFullscreen}
onCancel={handleCancel}
/>
}
open={open}
onOk={handleOk}
onCancel={handleCancel}
afterClose={handleClose}
centered
destroyOnClose
{...modalProps}
footer={null}
closable={false}>
<Container>
<CodeSectionComponent html={currentHtml} visible={viewVisibility.code} onCodeChange={handleCodeChange} />
<PreviewSectionComponent html={currentHtml} visible={viewVisibility.preview} />
</Container>
</StyledModal>
)
}
// 样式组件保持不变
const commonModalBodyStyles = `
padding: 0 !important;
display: flex !important;
flex-direction: column !important;
`
const StyledModal = styled(Modal)<{ $isFullscreen?: boolean }>`
${(props) =>
props.$isFullscreen
? `
.ant-modal-wrap {
padding: 0 !important;
}
.ant-modal {
margin: 0 !important;
padding: 0 !important;
max-width: none !important;
}
.ant-modal-body {
height: calc(100vh - 45px) !important;
${commonModalBodyStyles}
max-height: initial !important;
}
`
: `
.ant-modal-body {
height: 80vh !important;
${commonModalBodyStyles}
min-height: 600px !important;
}
`}
.ant-modal-body {
${commonModalBodyStyles}
}
.ant-modal-content {
border-radius: ${(props) => (props.$isFullscreen ? '0px' : '12px')};
overflow: hidden;
height: ${(props) => (props.$isFullscreen ? '100vh' : 'auto')};
padding: 0 !important;
}
.ant-modal-header {
padding: 10px 12px !important;
border-bottom: 1px solid var(--color-border);
background: var(--color-background);
border-radius: 0 !important;
margin-bottom: 0 !important;
}
.ant-modal-title {
margin: 0;
width: 100%;
}
`
const ModalHeader = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
position: relative;
`
const HeaderLeft = styled.div<{ $isFullscreen?: boolean }>`
flex: 1;
min-width: 0;
padding-left: ${(props) => (props.$isFullscreen && isMac ? '65px' : '12px')};
`
const HeaderCenter = styled.div`
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
display: flex;
justify-content: center;
z-index: 1;
`
const HeaderRight = styled.div`
flex: 1;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
`
const TitleText = styled.span`
font-size: 16px;
font-weight: 600;
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`
const ViewControls = styled.div`
display: flex;
width: auto;
gap: 8px;
padding: 4px;
background: var(--color-background-mute);
border-radius: 8px;
border: 1px solid var(--color-border);
-webkit-app-region: no-drag;
`
const ViewButton = styled(Button)`
border: none;
box-shadow: none;
&.ant-btn-primary {
background: var(--color-primary);
color: white;
}
&.ant-btn-default {
background: transparent;
color: var(--color-text-secondary);
&:hover {
background: var(--color-background);
color: var(--color-text);
}
}
`
const Container = styled.div`
display: flex;
height: 100%;
width: 100%;
flex: 1;
background: var(--color-background);
`
const CodeSection = styled.div<{ $visible: boolean }>`
flex: ${(props) => (props.$visible ? '1' : '0')};
min-width: ${(props) => (props.$visible ? '300px' : '0')};
border-right: ${(props) => (props.$visible ? '1px solid var(--color-border)' : 'none')};
overflow: hidden;
display: ${(props) => (props.$visible ? 'flex' : 'none')};
flex-direction: column;
`
const CodeEditorWrapper = styled.div`
flex: 1;
height: 100%;
overflow: hidden;
.monaco-editor {
height: 100% !important;
}
.cm-editor {
height: 100% !important;
}
.cm-scroller {
height: 100% !important;
}
`
const PreviewSection = styled.div<{ $visible: boolean }>`
flex: ${(props) => (props.$visible ? '1' : '0')};
min-width: ${(props) => (props.$visible ? '300px' : '0')};
background: white;
overflow: hidden;
display: ${(props) => (props.$visible ? 'block' : 'none')};
`
const PreviewFrame = styled.iframe`
width: 100%;
height: 100%;
border: none;
background: white;
`
const EmptyPreview = styled.div`
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
background: var(--color-background-soft);
color: var(--color-text-secondary);
font-size: 14px;
`
export default HtmlArtifactsPopup

View File

@@ -1,5 +1,5 @@
import { nanoid } from '@reduxjs/toolkit'
import { CodeTool, usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
import { usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
import SvgSpinners180Ring from '@renderer/components/Icons/SvgSpinners180Ring'
import { useMermaid } from '@renderer/hooks/useMermaid'
import { Flex, Spin } from 'antd'
@@ -7,16 +7,14 @@ import { debounce } from 'lodash'
import React, { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import styled from 'styled-components'
interface Props {
children: string
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
}
import PreviewError from './PreviewError'
import { BasicPreviewProps } from './types'
/** 预览 Mermaid 图表
* 通过防抖渲染提供比较统一的体验,减少闪烁。
* FIXME: 等将来容易判断代码块结束位置时再重构。
*/
const MermaidPreview: React.FC<Props> = ({ children, setTools }) => {
const MermaidPreview: React.FC<BasicPreviewProps> = ({ children, setTools }) => {
const { mermaid, isLoading: isLoadingMermaid, error: mermaidError } = useMermaid()
const mermaidRef = useRef<HTMLDivElement>(null)
const diagramId = useRef<string>(`mermaid-${nanoid(6)}`).current
@@ -143,7 +141,7 @@ const MermaidPreview: React.FC<Props> = ({ children, setTools }) => {
return (
<Spin spinning={isLoading} indicator={<SvgSpinners180Ring color="var(--color-text-2)" />}>
<Flex vertical style={{ minHeight: isLoading ? '2rem' : 'auto' }}>
{(mermaidError || error) && <StyledError>{mermaidError || error}</StyledError>}
{(mermaidError || error) && <PreviewError>{mermaidError || error}</PreviewError>}
<StyledMermaid ref={mermaidRef} className="mermaid" />
</Flex>
</Spin>
@@ -154,14 +152,4 @@ const StyledMermaid = styled.div`
overflow: auto;
`
const StyledError = styled.div`
overflow: auto;
padding: 16px;
color: #ff4d4f;
border: 1px solid #ff4d4f;
border-radius: 4px;
word-wrap: break-word;
white-space: pre-wrap;
`
export default memo(MermaidPreview)

View File

@@ -1,11 +1,13 @@
import { LoadingOutlined } from '@ant-design/icons'
import { CodeTool, usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
import { usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
import { Spin } from 'antd'
import pako from 'pako'
import React, { memo, useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import { BasicPreviewProps } from './types'
const PlantUMLServer = 'https://www.plantuml.com/plantuml'
function encode64(data: Uint8Array) {
let r = ''
@@ -132,12 +134,7 @@ const PlantUMLServerImage: React.FC<PlantUMLServerImageProps> = ({ format, diagr
)
}
interface PlantUMLProps {
children: string
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
}
const PlantUmlPreview: React.FC<PlantUMLProps> = ({ children, setTools }) => {
const PlantUmlPreview: React.FC<BasicPreviewProps> = ({ children, setTools }) => {
const { t } = useTranslation()
const containerRef = useRef<HTMLDivElement>(null)
@@ -174,7 +171,7 @@ const PlantUmlPreview: React.FC<PlantUMLProps> = ({ children, setTools }) => {
return (
<div ref={containerRef}>
<PlantUMLServerImage format="svg" diagram={children} className="plantuml-preview" />
<PlantUMLServerImage format="svg" diagram={children} className="plantuml-preview special-preview" />
</div>
)
}

View File

@@ -0,0 +1,14 @@
import { memo } from 'react'
import { styled } from 'styled-components'
const PreviewError = styled.div`
overflow: auto;
padding: 16px;
color: #ff4d4f;
border: 1px solid #ff4d4f;
border-radius: 4px;
word-wrap: break-word;
white-space: pre-wrap;
`
export default memo(PreviewError)

View File

@@ -1,15 +1,12 @@
import { CodeTool, usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
import { usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
import { memo, useEffect, useRef } from 'react'
interface Props {
children: string
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
}
import { BasicPreviewProps } from './types'
/**
* 使用 Shadow DOM 渲染 SVG
*/
const SvgPreview: React.FC<Props> = ({ children, setTools }) => {
const SvgPreview: React.FC<BasicPreviewProps> = ({ children, setTools }) => {
const svgContainerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
@@ -58,7 +55,7 @@ const SvgPreview: React.FC<Props> = ({ children, setTools }) => {
handleDownload
})
return <div ref={svgContainerRef} className="svg-preview" />
return <div ref={svgContainerRef} className="svg-preview special-preview" />
}
export default memo(SvgPreview)

View File

@@ -0,0 +1,20 @@
import GraphvizPreview from './GraphvizPreview'
import MermaidPreview from './MermaidPreview'
import PlantUmlPreview from './PlantUmlPreview'
import SvgPreview from './SvgPreview'
/**
* 特殊视图语言列表
*/
export const SPECIAL_VIEWS = ['mermaid', 'plantuml', 'svg', 'dot', 'graphviz']
/**
* 特殊视图组件映射表
*/
export const SPECIAL_VIEW_COMPONENTS = {
mermaid: MermaidPreview,
plantuml: PlantUmlPreview,
svg: SvgPreview,
dot: GraphvizPreview,
graphviz: GraphvizPreview
} as const

View File

@@ -0,0 +1,2 @@
export * from './types'
export * from './view'

View File

@@ -0,0 +1,14 @@
import { CodeTool } from '@renderer/components/CodeToolbar'
/**
* 预览组件的基本 props
*/
export interface BasicPreviewProps {
children: string
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
}
/**
* 视图模式
*/
export type ViewMode = 'source' | 'special' | 'split'

View File

@@ -12,13 +12,10 @@ import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import CodePreview from './CodePreview'
import HtmlArtifacts from './HtmlArtifacts'
import MermaidPreview from './MermaidPreview'
import PlantUmlPreview from './PlantUmlPreview'
import { SPECIAL_VIEW_COMPONENTS, SPECIAL_VIEWS } from './constants'
import HtmlArtifactsCard from './HtmlArtifactsCard'
import StatusBar from './StatusBar'
import SvgPreview from './SvgPreview'
type ViewMode = 'source' | 'special' | 'split'
import { ViewMode } from './types'
interface Props {
children: string
@@ -42,9 +39,10 @@ interface Props {
* - quick
* - core
*/
const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave }) => {
const { t } = useTranslation()
const { codeEditor, codeExecution } = useSettings()
const [viewMode, setViewMode] = useState<ViewMode>('special')
const [isRunning, setIsRunning] = useState(false)
const [output, setOutput] = useState('')
@@ -56,7 +54,7 @@ const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
return codeExecution.enabled && language === 'python'
}, [codeExecution.enabled, language])
const hasSpecialView = useMemo(() => ['mermaid', 'plantuml', 'svg'].includes(language), [language])
const hasSpecialView = useMemo(() => SPECIAL_VIEWS.includes(language), [language])
const isInSpecialView = useMemo(() => {
return hasSpecialView && viewMode === 'special'
@@ -200,14 +198,16 @@ const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
// 特殊视图组件映射
const specialView = useMemo(() => {
if (language === 'mermaid') {
return <MermaidPreview setTools={setTools}>{children}</MermaidPreview>
} else if (language === 'plantuml' && isValidPlantUML(children)) {
return <PlantUmlPreview setTools={setTools}>{children}</PlantUmlPreview>
} else if (language === 'svg') {
return <SvgPreview setTools={setTools}>{children}</SvgPreview>
const SpecialView = SPECIAL_VIEW_COMPONENTS[language as keyof typeof SPECIAL_VIEW_COMPONENTS]
if (!SpecialView) return null
// PlantUML 语法验证
if (language === 'plantuml' && !isValidPlantUML(children)) {
return null
}
return null
return <SpecialView setTools={setTools}>{children}</SpecialView>
}, [children, language])
const renderHeader = useMemo(() => {
@@ -228,27 +228,29 @@ const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
)
}, [specialView, sourceView, viewMode])
const renderArtifacts = useMemo(() => {
if (language === 'html') {
return <HtmlArtifacts html={children} />
}
return null
}, [children, language])
// HTML 代码块特殊处理 - 在所有 hooks 调用之后
if (language === 'html') {
return <HtmlArtifactsCard html={children} />
}
return (
<CodeBlockWrapper className="code-block" $isInSpecialView={isInSpecialView}>
{renderHeader}
<CodeToolbar tools={tools} />
{renderContent}
{renderArtifacts}
{isExecutable && output && <StatusBar>{output}</StatusBar>}
</CodeBlockWrapper>
)
}
})
const CodeBlockWrapper = styled.div<{ $isInSpecialView: boolean }>`
position: relative;
width: 100%;
/* FIXME:
* CodePreview
* toolbar title
*/
min-width: 45ch;
.code-toolbar {
background-color: ${(props) => (props.$isInSpecialView ? 'transparent' : 'var(--color-background-mute)')};
@@ -290,10 +292,12 @@ const SplitViewWrapper = styled.div`
width: 100%;
}
&:not(:has(+ .html-artifacts)) {
border-radius: 0 0 8px 8px;
}
&:not(:has(+ [class*='Container'])) {
border-radius: 0 0 8px 8px;
overflow: hidden;
}
`
export default memo(CodeBlockView)

View File

@@ -12,45 +12,111 @@ const linterLoaders: Record<string, () => Promise<any>> = {
}
}
/**
* 特殊语言加载器
*/
const specialLanguageLoaders: Record<string, () => Promise<Extension>> = {
dot: async () => {
const mod = await import('@viz-js/lang-dot')
return mod.dot()
}
}
/**
* 加载语言扩展
*/
async function loadLanguageExtension(language: string, languageMap: Record<string, string>): Promise<Extension | null> {
let normalizedLang = languageMap[language as keyof typeof languageMap] || language.toLowerCase()
// 如果语言名包含 `-`,转换为驼峰命名法
if (normalizedLang.includes('-')) {
normalizedLang = normalizedLang.replace(/-([a-z])/g, (_, char) => char.toUpperCase())
}
// 尝试加载特殊语言
const specialLoader = specialLanguageLoaders[normalizedLang]
if (specialLoader) {
try {
return await specialLoader()
} catch (error) {
console.debug(`Failed to load language ${normalizedLang}`, error)
return null
}
}
// 回退到 uiw/codemirror 包含的语言
try {
const { loadLanguage } = await import('@uiw/codemirror-extensions-langs')
const extension = loadLanguage(normalizedLang as any)
return extension || null
} catch (error) {
console.debug(`Failed to load language ${normalizedLang}`, error)
return null
}
}
/**
* 加载 linter 扩展
*/
async function loadLinterExtension(language: string): Promise<Extension | null> {
const loader = linterLoaders[language]
if (!loader) return null
try {
return await loader()
} catch (error) {
console.debug(`Failed to load linter for ${language}`, error)
return null
}
}
/**
* 加载语言相关扩展
*/
export const useLanguageExtensions = (language: string, lint?: boolean) => {
const { languageMap } = useCodeStyle()
const [extensions, setExtensions] = useState<Extension[]>([])
// 加载语言
useEffect(() => {
let normalizedLang = languageMap[language as keyof typeof languageMap] || language.toLowerCase()
let cancelled = false
// 如果语言名包含 `-`,转换为驼峰命名法
if (normalizedLang.includes('-')) {
normalizedLang = normalizedLang.replace(/-([a-z])/g, (_, char) => char.toUpperCase())
}
const loadAllExtensions = async () => {
try {
// 加载所有扩展
const [languageResult, linterResult] = await Promise.allSettled([
loadLanguageExtension(language, languageMap),
lint ? loadLinterExtension(language) : Promise.resolve(null)
])
import('@uiw/codemirror-extensions-langs')
.then(({ loadLanguage }) => {
const extension = loadLanguage(normalizedLang as any)
if (extension) {
setExtensions((prev) => [...prev, extension])
if (cancelled) return
const results: Extension[] = []
// 语言扩展
if (languageResult.status === 'fulfilled' && languageResult.value) {
results.push(languageResult.value)
}
})
.catch((error) => {
console.debug(`Failed to load language: ${normalizedLang}`, error)
})
}, [language, languageMap])
useEffect(() => {
if (!lint) return
// linter 扩展
if (linterResult.status === 'fulfilled' && linterResult.value) {
results.push(linterResult.value)
}
const loader = linterLoaders[language]
if (loader) {
loader()
.then((extension) => {
setExtensions((prev) => [...prev, extension])
})
.catch((error) => {
console.error(`Failed to load linter for ${language}`, error)
})
setExtensions(results)
} catch (error) {
if (!cancelled) {
console.debug('Failed to load language extensions:', error)
setExtensions([])
}
}
}
}, [language, lint])
loadAllExtensions()
return () => {
cancelled = true
}
}, [language, lint, languageMap])
return extensions
}

View File

@@ -10,8 +10,7 @@ import {
Text as UnWrapIcon,
WrapText as WrapIcon
} from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { memo } from 'react'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useBlurHandler, useLanguageExtensions, useSaveKeymap } from './hooks'
@@ -42,6 +41,7 @@ interface Props {
extensions?: Extension[]
/** 用于覆写编辑器的样式,会直接传给 CodeMirror 的 style 属性 */
style?: React.CSSProperties
editable?: boolean
}
/**
@@ -62,7 +62,8 @@ const CodeEditor = ({
maxHeight,
options,
extensions,
style
style,
editable = true
}: Props) => {
const {
fontSize,
@@ -190,7 +191,7 @@ const CodeEditor = ({
height={height}
minHeight={minHeight}
maxHeight={collapsible && !isExpanded ? (maxHeight ?? '350px') : 'none'}
editable={true}
editable={editable}
// @ts-ignore 强制使用,见 react-codemirror 的 Example.tsx
theme={activeCmTheme}
extensions={customExtensions}

View File

@@ -140,7 +140,7 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
const [isCaseSensitive, setIsCaseSensitive] = useState(false)
const [isWholeWord, setIsWholeWord] = useState(false)
const [allRanges, setAllRanges] = useState<Range[]>([])
const [currentIndex, setCurrentIndex] = useState(0)
const [currentIndex, setCurrentIndex] = useState(-1)
const prevSearchText = useRef('')
const { t } = useTranslation()
@@ -182,15 +182,18 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
[allRanges, currentIndex]
)
const search = useCallback(() => {
const searchText = searchInputRef.current?.value.trim() ?? null
setSearchCompleted(SearchCompletedState.Searched)
if (target && searchText !== null && searchText !== '') {
const ranges = findRangesInTarget(target, filter, searchText, isCaseSensitive, isWholeWord)
setAllRanges(ranges)
setCurrentIndex(0)
}
}, [target, filter, isCaseSensitive, isWholeWord])
const search = useCallback(
(jump = false) => {
const searchText = searchInputRef.current?.value.trim() ?? null
setSearchCompleted(SearchCompletedState.Searched)
if (target && searchText !== null && searchText !== '') {
const ranges = findRangesInTarget(target, filter, searchText, isCaseSensitive, isWholeWord)
setAllRanges(ranges)
setCurrentIndex(jump && ranges.length > 0 ? 0 : -1)
}
},
[target, filter, isCaseSensitive, isWholeWord]
)
const implementation = useMemo(
() => ({
@@ -207,7 +210,7 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
requestAnimationFrame(() => {
inputEl.focus()
inputEl.select()
search()
search(false)
})
} else {
requestAnimationFrame(() => {
@@ -231,11 +234,11 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
setSearchCompleted(SearchCompletedState.NotSearched)
},
search: () => {
search()
search(true)
locateByIndex(true)
},
silentSearch: () => {
search()
search(false)
locateByIndex(false)
},
focus: () => {
@@ -302,7 +305,7 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
useEffect(() => {
if (enableContentSearch && searchInputRef.current?.value.trim()) {
search()
search(true)
}
}, [isCaseSensitive, isWholeWord, enableContentSearch, search])
@@ -345,36 +348,35 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
<ToolBar>
<Tooltip title={t('button.includes_user_questions')} mouseEnterDelay={0.8} placement="bottom">
<ToolbarButton type="text" onClick={userOutlinedButtonOnClick}>
<User size={18} style={{ color: includeUser ? 'var(--color-link)' : 'var(--color-icon)' }} />
<User size={18} style={{ color: includeUser ? 'var(--color-primary)' : 'var(--color-icon)' }} />
</ToolbarButton>
</Tooltip>
<Tooltip title={t('button.case_sensitive')} mouseEnterDelay={0.8} placement="bottom">
<ToolbarButton type="text" onClick={caseSensitiveButtonOnClick}>
<CaseSensitive
size={18}
style={{ color: isCaseSensitive ? 'var(--color-link)' : 'var(--color-icon)' }}
style={{ color: isCaseSensitive ? 'var(--color-primary)' : 'var(--color-icon)' }}
/>
</ToolbarButton>
</Tooltip>
<Tooltip title={t('button.whole_word')} mouseEnterDelay={0.8} placement="bottom">
<ToolbarButton type="text" onClick={wholeWordButtonOnClick}>
<WholeWord size={18} style={{ color: isWholeWord ? 'var(--color-link)' : 'var(--color-icon)' }} />
<WholeWord
size={18}
style={{ color: isWholeWord ? 'var(--color-primary)' : 'var(--color-icon)' }}
/>
</ToolbarButton>
</Tooltip>
</ToolBar>
</InputWrapper>
<Separator></Separator>
<SearchResults>
{searchCompleted !== SearchCompletedState.NotSearched ? (
allRanges.length > 0 ? (
<>
<SearchResultCount>{currentIndex + 1}</SearchResultCount>
<SearchResultSeparator>/</SearchResultSeparator>
<SearchResultTotalCount>{allRanges.length}</SearchResultTotalCount>
</>
) : (
<NoResults>{t('common.no_results')}</NoResults>
)
{searchCompleted !== SearchCompletedState.NotSearched && allRanges.length > 0 ? (
<>
<SearchResultCount>{currentIndex + 1}</SearchResultCount>
<SearchResultSeparator>/</SearchResultSeparator>
<SearchResultTotalCount>{allRanges.length}</SearchResultTotalCount>
</>
) : (
<SearchResultsPlaceholder>0/0</SearchResultsPlaceholder>
)}
@@ -407,7 +409,6 @@ const Container = styled.div`
`
const SearchBarContainer = styled.div`
border: 1px solid var(--color-primary);
border-radius: 10px;
transition: all 0.2s ease;
position: fixed;
@@ -421,6 +422,7 @@ const SearchBarContainer = styled.div`
justify-content: center;
background-color: var(--color-background);
flex: 1 1 auto; /* Take up input's previous space */
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
`
const Placeholder = styled.div`
@@ -477,10 +479,6 @@ const SearchResultsPlaceholder = styled.span`
opacity: 0.5;
`
const NoResults = styled.span`
color: var(--color-text-1);
`
const SearchResultCount = styled.span`
color: var(--color-text);
`

View File

@@ -0,0 +1,17 @@
import { includeKeywords } from '@renderer/utils/search'
import { Select, SelectProps } from 'antd'
/**
* 自定义 Select使用增强的搜索 filter
*/
const CustomSelect = ({ ref, ...props }: SelectProps & { ref?: React.RefObject<any | null> }) => {
return <Select ref={ref} filterOption={enhancedFilterOption} {...props} />
}
CustomSelect.displayName = 'CustomSelect'
function enhancedFilterOption(input: string, option: any) {
return includeKeywords(option.label, input)
}
export default CustomSelect

View File

@@ -51,23 +51,27 @@ const DraggableList: FC<Props<any>> = ({
<VirtualList data={list} itemKey="id">
{(item, index) => {
const id = item.id || item
return (
<Draggable key={`draggable_${id}_${index}`} draggableId={id} index={index}>
{(provided) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
style={{
...listStyle,
...provided.draggableProps.style,
marginBottom: 8
}}>
{children(item, index)}
</div>
)}
</Draggable>
)
if (!item.disabled) {
return (
<Draggable key={`draggable_${id}_${index}`} draggableId={id} index={index}>
{(provided) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
style={{
marginBottom: 8,
...listStyle,
...provided.draggableProps.style
}}>
{children(item, index)}
</div>
)}
</Draggable>
)
} else {
return <div> {children(item, index)}</div>
}
}}
</VirtualList>
{provided.placeholder}

View File

@@ -0,0 +1,35 @@
import { FC } from 'react'
import styled from 'styled-components'
interface Props {
isNarrowMode: boolean
}
const NarrowModeIcon: FC<Props> = ({ isNarrowMode }) => {
return (
<Container $isNarrowMode={isNarrowMode}>
<Line />
<Line />
</Container>
)
}
const Container = styled.div<{ $isNarrowMode: boolean }>`
width: 16px;
height: 16px;
border: 1.5px solid var(--color-text-2);
border-radius: 3px;
display: flex;
align-items: center;
justify-content: ${({ $isNarrowMode }) => ($isNarrowMode ? 'space-evenly' : 'space-between')};
padding: 2px;
`
const Line = styled.div`
width: 2px;
height: 10px;
background-color: var(--color-text-2);
border-radius: 5px;
`
export default NarrowModeIcon

View File

@@ -0,0 +1,42 @@
import { SVGProps } from 'react'
interface PanelIconProps extends Omit<SVGProps<SVGSVGElement>, 'width' | 'height'> {
size?: number | string
expanded?: boolean
}
export const PanelLeftIcon = ({ size = 18, expanded = false, ...props }: PanelIconProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
className="lucide lucide-panel-left-icon lucide-panel-left"
{...props}>
<rect width="18" height="18" x="3" y="3" rx="2" />
{expanded ? <path d="M10 7v10" strokeWidth={4} /> : <path d="M9 6v12" strokeWidth={2} />}
</svg>
)
export const PanelRightIcon = ({ size = 18, expanded = false, ...props }: PanelIconProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
className="lucide lucide-panel-right-icon lucide-panel-right"
{...props}>
<rect width="18" height="18" x="3" y="3" rx="2" />
{expanded ? <path d="M14 7v10" strokeWidth={4} /> : <path d="M15 6v12" strokeWidth={2} />}
</svg>
)

View File

@@ -22,7 +22,7 @@ const Container = styled.div`
`
const Icon = styled.i`
color: var(--color-link);
color: var(--color-primary);
font-size: 16px;
margin-right: 6px;
`

View File

@@ -77,3 +77,18 @@ export function MdiLightbulbOn90(props: SVGProps<SVGSVGElement>) {
</svg>
)
}
export function ExpandWidth(props: SVGProps<SVGSVGElement>) {
return (
<svg width="1em" height="1em" viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<g>
<path
id="path"
d="M0 25L0 175C0 179.41 3.58 183 8 183L12 183C16.41 183 20 179.41 20 175L20 25C20 20.58 16.41 17 12 17L8 17C3.58 17 0 20.58 0 25ZM60.41 58.43L29.42 94.81C26.87 97.8 26.87 102.19 29.42 105.18L60.38 141.53C65.2 147.19 74.47 143.78 74.47 136.34L74.47 121.83C74.47 117.41 78.06 113.83 82.47 113.83L117.5 113.83C121.91 113.83 125.5 117.41 125.5 121.83L125.5 136.35C125.5 143.78 134.76 147.2 139.58 141.54L170.57 105.18C173.12 102.19 173.12 97.8 170.57 94.81L139.59 58.43C134.76 52.77 125.5 56.18 125.5 63.62L125.5 78.16C125.5 82.58 121.91 86.16 117.5 86.16L82.5 86.16C78.08 86.16 74.5 82.58 74.5 78.16L74.5 63.62C74.5 56.18 65.23 52.77 60.41 58.43ZM188 17L192 17C196.41 17 200 20.58 200 25L200 175C200 179.41 196.41 183 192 183L188 183C183.58 183 180 179.41 180 175L180 25C180 20.58 183.58 17 188 17Z"
fill="currentColor"
fillRule="nonzero"
/>
</g>
</svg>
)
}

View File

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

View File

@@ -0,0 +1,27 @@
import { HStack } from '@renderer/components/Layout'
import MainSidebar from '@renderer/pages/home/MainSidebar/MainSidebar'
import { FC } from 'react'
import styled from 'styled-components'
interface AppLayoutProps {
children: React.ReactNode
}
const AppLayout: FC<AppLayoutProps> = ({ children }) => {
return (
<HStack style={{ display: 'flex', flex: 1 }} id="app-layout">
<MainSidebar />
<ContentArea>{children}</ContentArea>
</HStack>
)
}
const ContentArea = styled.div`
min-width: 0;
display: flex;
flex: 1;
flex-direction: column;
height: 100vh;
`
export default AppLayout

View File

@@ -0,0 +1,17 @@
import { MAX_CONTEXT_COUNT } from '@renderer/config/constant'
import { Infinity as InfinityIcon } from 'lucide-react'
import { CSSProperties } from 'react'
type Props = {
maxContext: number
style?: CSSProperties
size?: number
}
export default function MaxContextCount({ maxContext, style, size = 14 }: Props) {
return maxContext === MAX_CONTEXT_COUNT ? (
<InfinityIcon size={size} style={style} aria-label="infinity" />
) : (
<span style={style}>{maxContext.toString()}</span>
)
}

View File

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

View File

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

View File

@@ -1,5 +1,4 @@
import { TopView } from '@renderer/components/TopView'
import { useAgents } from '@renderer/hooks/useAgents'
import { useAssistants, useDefaultAssistant } from '@renderer/hooks/useAssistant'
import { useSystemAgents } from '@renderer/pages/agents'
import { createAssistantFromAgent } from '@renderer/services/AssistantService'
@@ -24,7 +23,7 @@ interface Props {
const PopupContainer: React.FC<Props> = ({ resolve }) => {
const [open, setOpen] = useState(true)
const { t } = useTranslation()
const { agents: userAgents } = useAgents()
const { assistants: userAgents } = useAssistants()
const [searchText, setSearchText] = useState('')
const { defaultAssistant } = useDefaultAssistant()
const { assistants, addAssistant } = useAssistants()

View File

@@ -10,6 +10,7 @@ import { Button, Card, Flex, List, Popconfirm, Space, Tooltip, Typography } from
import { Trash } from 'lucide-react'
import { FC, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import { isLlmProvider, useApiKeys } from './hook'
import ApiKeyItem from './item'
@@ -87,7 +88,7 @@ export const ApiKeyList: FC<ApiKeyListProps> = ({ provider, updateProvider, prov
: keys
return (
<>
<ListContainer>
{/* Keys 列表 */}
<Card
size="small"
@@ -122,7 +123,7 @@ export const ApiKeyList: FC<ApiKeyListProps> = ({ provider, updateProvider, prov
)}
</Card>
<Flex align="center" justify="space-between" style={{ marginTop: '0.5rem' }}>
<Flex dir="row" align="center" justify="space-between" style={{ marginTop: 15 }}>
{/* 帮助文本 */}
<SettingHelpText>{t('settings.provider.api_key.tip')}</SettingHelpText>
@@ -166,7 +167,7 @@ export const ApiKeyList: FC<ApiKeyListProps> = ({ provider, updateProvider, prov
</Button>
</Space>
</Flex>
</>
</ListContainer>
)
}
@@ -222,3 +223,8 @@ export const DocPreprocessApiKeyList: FC<SpecificApiKeyListProps> = ({
/>
)
}
const ListContainer = styled.div`
padding-top: 15px;
padding-bottom: 15px;
`

View File

@@ -14,14 +14,7 @@ interface Props {
position: 'left' | 'right'
}
const FloatingSidebar: FC<Props> = ({
children,
activeAssistant,
setActiveAssistant,
activeTopic,
setActiveTopic,
position = 'left'
}) => {
const FloatingSidebar: FC<Props> = ({ children, activeAssistant, setActiveAssistant, activeTopic, setActiveTopic }) => {
const [open, setOpen] = useState(false)
useHotkeys('esc', () => {
@@ -45,16 +38,15 @@ const FloatingSidebar: FC<Props> = ({
const content = (
<PopoverContent maxHeight={maxHeight}>
<HomeTabs
tab="assistants"
activeAssistant={activeAssistant}
activeTopic={activeTopic}
setActiveAssistant={setActiveAssistant}
setActiveTopic={setActiveTopic}
position={position}
forceToSeeAllTab={true}
style={{
background: 'transparent',
border: 'none',
maxHeight: maxHeight
height: '100%'
}}
/>
</PopoverContent>
@@ -82,6 +74,9 @@ const FloatingSidebar: FC<Props> = ({
const PopoverContent = styled.div<{ maxHeight: number }>`
max-height: ${(props) => props.maxHeight}px;
&.ant-popover-inner-content {
overflow-y: hidden;
}
`
export default FloatingSidebar

View File

@@ -0,0 +1,375 @@
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useSettings } from '@renderer/hooks/useSettings'
import { SettingDivider, SettingRow, SettingRowTitle } from '@renderer/pages/settings'
import { useAppDispatch } from '@renderer/store'
import {
setCodeCollapsible,
setCodeEditor,
setCodeExecution,
setCodePreview,
setCodeShowLineNumbers,
setCodeWrappable,
setFontSize,
setMathEngine,
setMessageFont,
setMessageNavigation,
setMessageStyle,
setMultiModelMessageStyle,
setShowMessageDivider,
setThoughtAutoCollapse
} from '@renderer/store/settings'
import { CodeStyleVarious, MathEngine, ThemeMode } from '@renderer/types'
import { Col, InputNumber, Modal, Row, Slider, Switch, Tooltip } from 'antd'
import { CircleHelp } from 'lucide-react'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import Selector from '../Selector'
import { TopView } from '../TopView'
interface ShowParams {
title: string
}
interface Props extends ShowParams {
resolve: (data: any) => void
}
const PopupContainer: React.FC<Props> = ({ title, resolve }) => {
const [open, setOpen] = useState(true)
const { messageStyle, fontSize } = useSettings()
const { theme } = useTheme()
const { themeNames } = useCodeStyle()
const [fontSizeValue, setFontSizeValue] = useState(fontSize)
const { t } = useTranslation()
const dispatch = useAppDispatch()
const {
showMessageDivider,
messageFont,
codeShowLineNumbers,
codeCollapsible,
codeWrappable,
codeEditor,
codePreview,
codeExecution,
mathEngine,
multiModelMessageStyle,
thoughtAutoCollapse,
messageNavigation
} = useSettings()
const codeStyle = useMemo(() => {
return codeEditor.enabled
? theme === ThemeMode.light
? codeEditor.themeLight
: codeEditor.themeDark
: theme === ThemeMode.light
? codePreview.themeLight
: codePreview.themeDark
}, [
codeEditor.enabled,
codeEditor.themeLight,
codeEditor.themeDark,
theme,
codePreview.themeLight,
codePreview.themeDark
])
const onCodeStyleChange = useCallback(
(value: CodeStyleVarious) => {
const field = theme === ThemeMode.light ? 'themeLight' : 'themeDark'
const action = codeEditor.enabled ? setCodeEditor : setCodePreview
dispatch(action({ [field]: value }))
},
[dispatch, theme, codeEditor.enabled]
)
const onOk = () => {
resolve(true)
setOpen(false)
}
const onCancel = () => {
resolve(false)
setOpen(false)
}
const onClose = () => {
TopView.hide(TopViewKey)
}
MessageSettingsPopup.hide = onCancel
return (
<Modal
title={title}
open={open}
onOk={onOk}
onCancel={onCancel}
afterClose={onClose}
transitionName="animation-move-down"
footer={null}
centered
styles={{ body: { maxHeight: '70vh', overflowY: 'auto' } }}>
<SettingGroup>
<SettingRow>
<SettingRowTitleSmall>{t('settings.messages.divider')}</SettingRowTitleSmall>
<Switch
size="small"
checked={showMessageDivider}
onChange={(checked) => dispatch(setShowMessageDivider(checked))}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>{t('settings.messages.use_serif_font')}</SettingRowTitleSmall>
<Switch
size="small"
checked={messageFont === 'serif'}
onChange={(checked) => dispatch(setMessageFont(checked ? 'serif' : 'system'))}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>
{t('chat.settings.thought_auto_collapse')}
<Tooltip title={t('chat.settings.thought_auto_collapse.tip')}>
<CircleHelp size={14} style={{ marginLeft: 4 }} color="var(--color-text-2)" />
</Tooltip>
</SettingRowTitleSmall>
<Switch
size="small"
checked={thoughtAutoCollapse}
onChange={(checked) => dispatch(setThoughtAutoCollapse(checked))}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>{t('message.message.style')}</SettingRowTitleSmall>
<Selector
value={messageStyle}
onChange={(value) => dispatch(setMessageStyle(value as 'plain' | 'bubble'))}
options={[
{ label: t('message.message.style.plain'), value: 'plain' },
{ label: t('message.message.style.bubble'), value: 'bubble' }
]}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>{t('message.message.multi_model_style')}</SettingRowTitleSmall>
<Selector
value={multiModelMessageStyle}
onChange={(value) =>
dispatch(setMultiModelMessageStyle(value as 'fold' | 'vertical' | 'horizontal' | 'grid'))
}
options={[
{ label: t('message.message.multi_model_style.fold'), value: 'fold' },
{ label: t('message.message.multi_model_style.vertical'), value: 'vertical' },
{ label: t('message.message.multi_model_style.horizontal'), value: 'horizontal' },
{ label: t('message.message.multi_model_style.grid'), value: 'grid' }
]}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>{t('settings.messages.navigation')}</SettingRowTitleSmall>
<Selector
value={messageNavigation}
onChange={(value) => dispatch(setMessageNavigation(value as 'none' | 'buttons' | 'anchor'))}
options={[
{ label: t('settings.messages.navigation.none'), value: 'none' },
{ label: t('settings.messages.navigation.buttons'), value: 'buttons' },
{ label: t('settings.messages.navigation.anchor'), value: 'anchor' }
]}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>{t('settings.messages.math_engine')}</SettingRowTitleSmall>
<Selector
value={mathEngine}
onChange={(value) => dispatch(setMathEngine(value as MathEngine))}
options={[
{ label: 'KaTeX', value: 'KaTeX' },
{ label: 'MathJax', value: 'MathJax' },
{ label: t('settings.messages.math_engine.none'), value: 'none' }
]}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>{t('settings.font_size.title')}</SettingRowTitleSmall>
</SettingRow>
<Row align="middle" gutter={10}>
<Col span={24}>
<Slider
value={fontSizeValue}
onChange={(value) => setFontSizeValue(value)}
onChangeComplete={(value) => dispatch(setFontSize(value))}
min={12}
max={22}
step={1}
marks={{
12: <span style={{ fontSize: '12px' }}>A</span>,
14: <span style={{ fontSize: '14px' }}>{t('common.default')}</span>,
22: <span style={{ fontSize: '18px' }}>A</span>
}}
/>
</Col>
</Row>
</SettingGroup>
<SettingGroup>
<SettingRow>
<SettingRowTitleSmall>{t('message.message.code_style')}</SettingRowTitleSmall>
<Selector
value={codeStyle}
onChange={(value) => onCodeStyleChange(value as CodeStyleVarious)}
options={themeNames.map((theme) => ({ label: theme, value: theme }))}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>
{t('chat.settings.code_execution.title')}
<Tooltip title={t('chat.settings.code_execution.tip')}>
<CircleHelp size={14} style={{ marginLeft: 4 }} color="var(--color-text-2)" />
</Tooltip>
</SettingRowTitleSmall>
<Switch
size="small"
checked={codeExecution.enabled}
onChange={(checked) => dispatch(setCodeExecution({ enabled: checked }))}
/>
</SettingRow>
{codeExecution.enabled && (
<>
<SettingDivider />
<SettingRow style={{ paddingLeft: 8 }}>
<SettingRowTitleSmall>
{t('chat.settings.code_execution.timeout_minutes')}
<Tooltip title={t('chat.settings.code_execution.timeout_minutes.tip')}>
<CircleHelp size={14} style={{ marginLeft: 4 }} color="var(--color-text-2)" />
</Tooltip>
</SettingRowTitleSmall>
<InputNumber
size="small"
min={1}
max={60}
step={1}
value={codeExecution.timeoutMinutes}
onChange={(value) => dispatch(setCodeExecution({ timeoutMinutes: value ?? 1 }))}
style={{ width: 80 }}
/>
</SettingRow>
</>
)}
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>{t('chat.settings.code_editor.title')}</SettingRowTitleSmall>
<Switch
size="small"
checked={codeEditor.enabled}
onChange={(checked) => dispatch(setCodeEditor({ enabled: checked }))}
/>
</SettingRow>
{codeEditor.enabled && (
<>
<SettingDivider />
<SettingRow style={{ paddingLeft: 8 }}>
<SettingRowTitleSmall>{t('chat.settings.code_editor.highlight_active_line')}</SettingRowTitleSmall>
<Switch
size="small"
checked={codeEditor.highlightActiveLine}
onChange={(checked) => dispatch(setCodeEditor({ highlightActiveLine: checked }))}
/>
</SettingRow>
<SettingDivider />
<SettingRow style={{ paddingLeft: 8 }}>
<SettingRowTitleSmall>{t('chat.settings.code_editor.fold_gutter')}</SettingRowTitleSmall>
<Switch
size="small"
checked={codeEditor.foldGutter}
onChange={(checked) => dispatch(setCodeEditor({ foldGutter: checked }))}
/>
</SettingRow>
<SettingDivider />
<SettingRow style={{ paddingLeft: 8 }}>
<SettingRowTitleSmall>{t('chat.settings.code_editor.autocompletion')}</SettingRowTitleSmall>
<Switch
size="small"
checked={codeEditor.autocompletion}
onChange={(checked) => dispatch(setCodeEditor({ autocompletion: checked }))}
/>
</SettingRow>
<SettingDivider />
<SettingRow style={{ paddingLeft: 8 }}>
<SettingRowTitleSmall>{t('chat.settings.code_editor.keymap')}</SettingRowTitleSmall>
<Switch
size="small"
checked={codeEditor.keymap}
onChange={(checked) => dispatch(setCodeEditor({ keymap: checked }))}
/>
</SettingRow>
</>
)}
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>{t('chat.settings.show_line_numbers')}</SettingRowTitleSmall>
<Switch
size="small"
checked={codeShowLineNumbers}
onChange={(checked) => dispatch(setCodeShowLineNumbers(checked))}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>{t('chat.settings.code_collapsible')}</SettingRowTitleSmall>
<Switch
size="small"
checked={codeCollapsible}
onChange={(checked) => dispatch(setCodeCollapsible(checked))}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>{t('chat.settings.code_wrappable')}</SettingRowTitleSmall>
<Switch size="small" checked={codeWrappable} onChange={(checked) => dispatch(setCodeWrappable(checked))} />
</SettingRow>
</SettingGroup>
</Modal>
)
}
const SettingRowTitleSmall = styled(SettingRowTitle)`
font-size: 13px;
`
const SettingGroup = styled.div<{ theme?: ThemeMode }>`
padding: 0;
width: 100%;
margin-top: 0;
border-radius: 8px;
margin-bottom: 10px;
margin-top: 10px;
`
const TopViewKey = 'MessageSettingsPopup'
export default class MessageSettingsPopup {
static topviewId = 0
static hide() {
TopView.hide(TopViewKey)
}
static show(props: ShowParams) {
return new Promise<any>((resolve) => {
TopView.show(<PopupContainer {...props} resolve={resolve} />, TopViewKey)
})
}
}

View File

@@ -1,82 +0,0 @@
import { Center } from '@renderer/components/Layout'
import { useMinapps } from '@renderer/hooks/useMinapps'
import App from '@renderer/pages/apps/App'
import { Popover } from 'antd'
import { Empty } from 'antd'
import { isEmpty } from 'lodash'
import { FC, useEffect, useState } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import styled from 'styled-components'
import Scrollbar from '../Scrollbar'
interface Props {
children: React.ReactNode
}
const MinAppsPopover: FC<Props> = ({ children }) => {
const [open, setOpen] = useState(false)
const { minapps } = useMinapps()
useHotkeys('esc', () => {
setOpen(false)
})
const handleClose = () => {
setOpen(false)
}
const [maxHeight, setMaxHeight] = useState(window.innerHeight - 100)
useEffect(() => {
const handleResize = () => {
setMaxHeight(window.innerHeight - 100)
}
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
}
}, [])
const content = (
<PopoverContent maxHeight={maxHeight}>
<AppsContainer>
{minapps.map((app) => (
<App key={app.id} app={app} onClick={handleClose} size={50} />
))}
{isEmpty(minapps) && (
<Center>
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
</Center>
)}
</AppsContainer>
</PopoverContent>
)
return (
<Popover
open={open}
onOpenChange={setOpen}
content={content}
trigger="click"
placement="bottomRight"
styles={{ body: { padding: 25 } }}>
{children}
</Popover>
)
}
const PopoverContent = styled(Scrollbar)<{ maxHeight: number }>`
max-height: ${(props) => props.maxHeight}px;
overflow-y: auto;
`
const AppsContainer = styled.div`
display: grid;
grid-template-columns: repeat(8, minmax(90px, 1fr));
gap: 18px;
`
export default MinAppsPopover

View File

@@ -0,0 +1,353 @@
import CustomTag from '@renderer/components/CustomTag'
import { TopView } from '@renderer/components/TopView'
import Logger from '@renderer/config/logger'
import { useKnowledge, useKnowledgeBases } from '@renderer/hooks/useKnowledge'
import { Message } from '@renderer/types/newMessage'
import {
analyzeMessageContent,
CONTENT_TYPES,
ContentType,
MessageContentStats,
processMessageContent
} from '@renderer/utils/knowledge'
import { Flex, Form, Modal, Select, Tooltip, Typography } from 'antd'
import { Check, CircleHelp } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
const { Text } = Typography
// 内容类型配置
const CONTENT_TYPE_CONFIG = {
[CONTENT_TYPES.TEXT]: {
label: 'chat.save.knowledge.content.maintext.title',
description: 'chat.save.knowledge.content.maintext.description'
},
[CONTENT_TYPES.CODE]: {
label: 'chat.save.knowledge.content.code.title',
description: 'chat.save.knowledge.content.code.description'
},
[CONTENT_TYPES.THINKING]: {
label: 'chat.save.knowledge.content.thinking.title',
description: 'chat.save.knowledge.content.thinking.description'
},
[CONTENT_TYPES.TOOL_USE]: {
label: 'chat.save.knowledge.content.tool_use.title',
description: 'chat.save.knowledge.content.tool_use.description'
},
[CONTENT_TYPES.CITATION]: {
label: 'chat.save.knowledge.content.citation.title',
description: 'chat.save.knowledge.content.citation.description'
},
[CONTENT_TYPES.TRANSLATION]: {
label: 'chat.save.knowledge.content.translation.title',
description: 'chat.save.knowledge.content.translation.description'
},
[CONTENT_TYPES.ERROR]: {
label: 'chat.save.knowledge.content.error.title',
description: 'chat.save.knowledge.content.error.description'
},
[CONTENT_TYPES.FILE]: {
label: 'chat.save.knowledge.content.file.title',
description: 'chat.save.knowledge.content.file.description'
}
} as const
// Tag 颜色常量
const TAG_COLORS = {
SELECTED: '#008001',
UNSELECTED: '#8c8c8c'
} as const
interface ContentTypeOption {
type: ContentType
label: string
count: number
enabled: boolean
description?: string
}
interface ShowParams {
message: Message
title?: string
}
interface SaveResult {
success: boolean
savedCount: number
}
interface Props extends ShowParams {
resolve: (data: SaveResult | null) => void
}
const PopupContainer: React.FC<Props> = ({ message, title, resolve }) => {
const [open, setOpen] = useState(true)
const [loading, setLoading] = useState(false)
const [selectedBaseId, setSelectedBaseId] = useState<string>()
const [selectedTypes, setSelectedTypes] = useState<ContentType[]>([])
const [hasInitialized, setHasInitialized] = useState(false)
const { bases } = useKnowledgeBases()
const { addNote, addFiles } = useKnowledge(selectedBaseId || '')
const { t } = useTranslation()
// 分析消息内容统计
const contentStats = useMemo(() => analyzeMessageContent(message), [message])
// 生成内容类型选项(只显示有内容的类型)
const contentTypeOptions: ContentTypeOption[] = useMemo(() => {
return Object.entries(CONTENT_TYPE_CONFIG)
.map(([type, config]) => {
const contentType = type as ContentType
const count = contentStats[contentType as keyof MessageContentStats] || 0
return {
type: contentType,
count,
enabled: count > 0,
label: t(config.label),
description: t(config.description)
}
})
.filter((option) => option.enabled) // 只显示有内容的类型
}, [contentStats, t])
// 知识库选项
const knowledgeBaseOptions = useMemo(
() =>
bases.map((base) => ({
label: base.name,
value: base.id,
disabled: !base.version // 如果知识库没有配置好就禁用
})),
[bases]
)
// 合并状态计算
const formState = useMemo(() => {
const hasValidBase = selectedBaseId && bases.find((base) => base.id === selectedBaseId)?.version
const hasContent = contentTypeOptions.length > 0
const selectedCount = contentTypeOptions
.filter((option) => selectedTypes.includes(option.type))
.reduce((sum, option) => sum + option.count, 0)
return {
hasValidBase,
hasContent,
canSubmit: hasValidBase && selectedTypes.length > 0 && hasContent,
selectedCount,
hasNoSelection: selectedTypes.length === 0 && hasContent
}
}, [selectedBaseId, bases, contentTypeOptions, selectedTypes])
// 默认选择第一个可用的知识库
useEffect(() => {
if (!selectedBaseId) {
const firstAvailableBase = bases.find((base) => base.version)
if (firstAvailableBase) {
setSelectedBaseId(firstAvailableBase.id)
}
}
}, [bases, selectedBaseId])
// 默认选择所有可用的内容类型(仅在初始化时)
useEffect(() => {
if (!hasInitialized && contentTypeOptions.length > 0) {
const availableTypes = contentTypeOptions.map((option) => option.type)
setSelectedTypes(availableTypes)
setHasInitialized(true)
}
}, [contentTypeOptions, hasInitialized])
// 计算UI状态
const uiState = useMemo(() => {
if (!formState.hasContent) {
return { type: 'empty', message: t('chat.save.knowledge.empty.no_content') }
}
if (bases.length === 0) {
return { type: 'empty', message: t('chat.save.knowledge.empty.no_knowledge_base') }
}
return { type: 'form' }
}, [formState.hasContent, bases.length, t])
// 处理内容类型选择切换
const handleContentTypeToggle = (type: ContentType) => {
setSelectedTypes((prev) => (prev.includes(type) ? prev.filter((t) => t !== type) : [...prev, type]))
}
const onOk = async () => {
if (!formState.canSubmit) {
return
}
setLoading(true)
let savedCount = 0
try {
const result = processMessageContent(message, selectedTypes)
// 保存文本内容
if (result.text.trim() && selectedTypes.some((type) => type !== CONTENT_TYPES.FILE)) {
await addNote(result.text)
savedCount++
}
// 保存文件
if (result.files.length > 0 && selectedTypes.includes(CONTENT_TYPES.FILE)) {
addFiles(result.files)
savedCount += result.files.length
}
setOpen(false)
resolve({ success: true, savedCount })
} catch (error) {
Logger.error('[SaveToKnowledgePopup] save failed:', error)
window.message.error(t('chat.save.knowledge.error.save_failed'))
setLoading(false)
}
}
const onCancel = () => {
setOpen(false)
}
const onClose = () => {
resolve(null)
}
// 渲染空状态
const renderEmptyState = () => (
<EmptyContainer>
<Text type="secondary">{uiState.message}</Text>
</EmptyContainer>
)
// 渲染表单内容
const renderFormContent = () => (
<>
<Form layout="vertical">
<Form.Item
label={t('chat.save.knowledge.select.base.title')}
help={!formState.hasValidBase && selectedBaseId ? t('chat.save.knowledge.error.invalid_base') : undefined}
validateStatus={!formState.hasValidBase && selectedBaseId ? 'error' : undefined}>
<Select
value={selectedBaseId}
onChange={setSelectedBaseId}
options={knowledgeBaseOptions}
placeholder={t('chat.save.knowledge.select.base.placeholder')}
showSearch
/>
</Form.Item>
<Form.Item label={t('chat.save.knowledge.select.content.title')}>
<Flex gap={8} style={{ flexDirection: 'column' }}>
{contentTypeOptions.map((option) => (
<ContentTypeItem
key={option.type}
align="center"
justify="space-between"
onClick={() => handleContentTypeToggle(option.type)}>
<Flex align="center" gap={8}>
<CustomTag
color={selectedTypes.includes(option.type) ? TAG_COLORS.SELECTED : TAG_COLORS.UNSELECTED}
size={12}>
{option.count}
</CustomTag>
<span>{option.label}</span>
<Tooltip title={option.description} mouseLeaveDelay={0}>
<CircleHelp size={16} style={{ cursor: 'help' }} />
</Tooltip>
</Flex>
{selectedTypes.includes(option.type) && <Check size={16} color={TAG_COLORS.SELECTED} />}
</ContentTypeItem>
))}
</Flex>
</Form.Item>
</Form>
{formState.selectedCount > 0 && (
<InfoContainer>
<Text type="secondary" style={{ fontSize: '12px' }}>
{t('chat.save.knowledge.select.content.tip', { count: formState.selectedCount })}
</Text>
</InfoContainer>
)}
{formState.hasNoSelection && (
<InfoContainer>
<Text type="warning" style={{ fontSize: '12px' }}>
{t('chat.save.knowledge.error.no_content_selected')}
</Text>
</InfoContainer>
)}
</>
)
return (
<Modal
title={title || t('chat.save.knowledge.title')}
open={open}
onOk={onOk}
onCancel={onCancel}
afterClose={onClose}
destroyOnClose
centered
width={500}
okText={t('common.save')}
cancelText={t('common.cancel')}
okButtonProps={{
loading,
disabled: !formState.canSubmit
}}>
{uiState.type === 'empty' ? renderEmptyState() : renderFormContent()}
</Modal>
)
}
const TopViewKey = 'SaveToKnowledgePopup'
export default class SaveToKnowledgePopup {
static hide() {
TopView.hide(TopViewKey)
}
static show(props: ShowParams): Promise<SaveResult | null> {
return new Promise<SaveResult | null>((resolve) => {
TopView.show(
<PopupContainer
{...props}
resolve={(result) => {
resolve(result)
this.hide()
}}
/>,
TopViewKey
)
})
}
}
const EmptyContainer = styled.div`
text-align: center;
padding: 40px 20px;
`
const ContentTypeItem = styled(Flex)`
padding: 12px;
border: 1px solid var(--color-border);
border-radius: 6px;
cursor: pointer;
transition: border-color 0.2s;
position: relative;
&:hover {
border-color: var(--color-primary);
}
`
const InfoContainer = styled.div`
background: var(--color-background-soft);
padding: 12px;
border-radius: 6px;
margin-top: 16px;
`

View File

@@ -0,0 +1,24 @@
export interface SettingsPopupShowParams {
defaultTab?:
| 'provider'
| 'model'
| 'tool'
| 'general'
| 'display'
| 'shortcut'
| 'quickAssistant'
| 'selectionAssistant'
| 'data'
| 'about'
| 'quickPhrase'
}
export default class SettingsPopup {
static hide() {
// Settings window is now independent, user can close it manually
}
static show(props: SettingsPopupShowParams = {}) {
return window.api.showSettingsWindow({ defaultTab: props.defaultTab })
}
}

View File

@@ -15,15 +15,17 @@ const PopupContainer: React.FC<Props> = ({ title, resolve }) => {
const [open, setOpen] = useState(true)
const onOk = () => {
resolve(true)
setOpen(false)
}
const onCancel = () => {
resolve(false)
setOpen(false)
}
const onClose = () => {
resolve({})
TopView.hide(TopViewKey)
}
TemplatePopup.hide = onCancel
@@ -51,16 +53,7 @@ export default class TemplatePopup {
}
static show(props: ShowParams) {
return new Promise<any>((resolve) => {
TopView.show(
<PopupContainer
{...props}
resolve={(v) => {
resolve(v)
TopView.hide(TopViewKey)
}}
/>,
TopViewKey
)
TopView.show(<PopupContainer {...props} resolve={resolve} />, TopViewKey)
})
}
}

View File

@@ -27,6 +27,11 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
const clearTimer = useRef<NodeJS.Timeout | null>(null)
// 添加更新item选中状态的方法
const updateItemSelection = useCallback((targetItem: QuickPanelListItem, isSelected: boolean) => {
setList((prevList) => prevList.map((item) => (item === targetItem ? { ...item, isSelected } : item)))
}, [])
const open = useCallback((options: QuickPanelOpenOptions) => {
if (clearTimer.current) {
clearTimeout(clearTimer.current)
@@ -77,6 +82,7 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
() => ({
open,
close,
updateItemSelection,
isVisible,
symbol,
@@ -90,7 +96,21 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
beforeAction,
afterAction
}),
[open, close, isVisible, symbol, list, title, defaultIndex, pageSize, multiple, onClose, beforeAction, afterAction]
[
open,
close,
updateItemSelection,
isVisible,
symbol,
list,
title,
defaultIndex,
pageSize,
multiple,
onClose,
beforeAction,
afterAction
]
)
return <QuickPanelContext value={value}>{children}</QuickPanelContext>

View File

@@ -52,6 +52,7 @@ export type QuickPanelListItem = {
export interface QuickPanelContextType {
readonly open: (options: QuickPanelOpenOptions) => void
readonly close: (action?: QuickPanelCloseAction) => void
readonly updateItemSelection: (targetItem: QuickPanelListItem, isSelected: boolean) => void
readonly isVisible: boolean
readonly symbol: string
readonly list: QuickPanelListItem[]

View File

@@ -50,7 +50,7 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
const [isMouseOver, setIsMouseOver] = useState(false)
const scrollTriggerRef = useRef<QuickPanelScrollTrigger>('initial')
const [_index, setIndex] = useState(ctx.defaultIndex)
const [_index, setIndex] = useState(-1)
const index = useDeferredValue(_index)
const [historyPanel, setHistoryPanel] = useState<QuickPanelOpenOptions[]>([])
@@ -62,6 +62,10 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
const searchText = useDeferredValue(_searchText)
const searchTextRef = useRef('')
// 跟踪上一次的搜索文本和符号用于判断是否需要重置index
const prevSearchTextRef = useRef('')
const prevSymbolRef = useRef('')
// 处理搜索,过滤列表
const list = useMemo(() => {
if (!ctx.isVisible && !ctx.symbol) return []
@@ -104,7 +108,24 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
}
})
setIndex(newList.length > 0 ? ctx.defaultIndex || 0 : -1)
// 只有在搜索文本变化或面板符号变化时才重置index
const isSearchChanged = prevSearchTextRef.current !== searchText
const isSymbolChanged = prevSymbolRef.current !== ctx.symbol
if (isSearchChanged || isSymbolChanged) {
setIndex(-1) // 不默认高亮任何项,让用户主动选择
} else {
// 如果当前index超出范围调整到有效范围内
setIndex((prevIndex) => {
if (prevIndex >= newList.length) {
return newList.length > 0 ? newList.length - 1 : -1
}
return prevIndex
})
}
prevSearchTextRef.current = searchText
prevSymbolRef.current = ctx.symbol
return newList
}, [ctx.defaultIndex, ctx.isVisible, ctx.list, ctx.symbol, searchText])
@@ -168,12 +189,33 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
(item: QuickPanelListItem, action?: QuickPanelCloseAction) => {
if (item.disabled) return
// 在多选模式下,先更新选中状态
if (ctx.multiple && !item.isMenu) {
const newSelectedState = !item.isSelected
ctx.updateItemSelection(item, newSelectedState)
// 创建更新后的item对象用于回调
const updatedItem = { ...item, isSelected: newSelectedState }
const quickPanelCallBackOptions: QuickPanelCallBackOptions = {
symbol: ctx.symbol,
action,
item: updatedItem,
searchText: searchText,
multiple: ctx.multiple
}
ctx.beforeAction?.(quickPanelCallBackOptions)
item?.action?.(quickPanelCallBackOptions)
ctx.afterAction?.(quickPanelCallBackOptions)
return
}
const quickPanelCallBackOptions: QuickPanelCallBackOptions = {
symbol: ctx.symbol,
action,
item,
searchText: searchText,
multiple: isAssistiveKeyPressed
multiple: ctx.multiple
}
ctx.beforeAction?.(quickPanelCallBackOptions)
@@ -200,11 +242,12 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
return
}
if (ctx.multiple && isAssistiveKeyPressed) return
// 多选模式下不关闭面板
if (ctx.multiple) return
handleClose(action)
},
[ctx, searchText, isAssistiveKeyPressed, handleClose, clearSearchText, index]
[ctx, searchText, handleClose, clearSearchText, index]
)
useEffect(() => {
@@ -294,12 +337,16 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
scrollTriggerRef.current = 'keyboard'
if (isAssistiveKeyPressed) {
setIndex((prev) => {
if (prev === -1) return list.length > 0 ? list.length - 1 : -1
const newIndex = prev - ctx.pageSize
if (prev === 0) return list.length - 1
return newIndex < 0 ? 0 : newIndex
})
} else {
setIndex((prev) => (prev > 0 ? prev - 1 : list.length - 1))
setIndex((prev) => {
if (prev === -1) return list.length > 0 ? list.length - 1 : -1
return prev > 0 ? prev - 1 : list.length - 1
})
}
break
@@ -307,18 +354,23 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
scrollTriggerRef.current = 'keyboard'
if (isAssistiveKeyPressed) {
setIndex((prev) => {
if (prev === -1) return list.length > 0 ? 0 : -1
const newIndex = prev + ctx.pageSize
if (prev + 1 === list.length) return 0
return newIndex >= list.length ? list.length - 1 : newIndex
})
} else {
setIndex((prev) => (prev < list.length - 1 ? prev + 1 : 0))
setIndex((prev) => {
if (prev === -1) return list.length > 0 ? 0 : -1
return prev < list.length - 1 ? prev + 1 : 0
})
}
break
case 'PageUp':
scrollTriggerRef.current = 'keyboard'
setIndex((prev) => {
if (prev === -1) return list.length > 0 ? Math.max(0, list.length - ctx.pageSize) : -1
const newIndex = prev - ctx.pageSize
return newIndex < 0 ? 0 : newIndex
})
@@ -327,6 +379,7 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
case 'PageDown':
scrollTriggerRef.current = 'keyboard'
setIndex((prev) => {
if (prev === -1) return list.length > 0 ? Math.min(ctx.pageSize - 1, list.length - 1) : -1
const newIndex = prev + ctx.pageSize
return newIndex >= list.length ? list.length - 1 : newIndex
})
@@ -421,10 +474,9 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
(): VirtualizedRowData => ({
list,
focusedIndex: index,
handleItemAction,
setIndex
handleItemAction
}),
[list, index, handleItemAction, setIndex]
[list, index, handleItemAction]
)
return (
@@ -487,15 +539,6 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
<Flex align="center" gap={4}>
{t('settings.quickPanel.confirm')}
</Flex>
{ctx.multiple && (
<Flex align="center" gap={4}>
<span style={{ color: isAssistiveKeyPressed ? 'var(--color-primary)' : 'var(--color-text-3)' }}>
{ASSISTIVE_KEY}
</span>
+ {t('settings.quickPanel.multiple')}
</Flex>
)}
</QuickPanelFooterTips>
</QuickPanelFooter>
</QuickPanelBody>
@@ -507,7 +550,6 @@ interface VirtualizedRowData {
list: QuickPanelListItem[]
focusedIndex: number
handleItemAction: (item: QuickPanelListItem, action?: QuickPanelCloseAction) => void
setIndex: (index: number) => void
}
/**
@@ -515,7 +557,7 @@ interface VirtualizedRowData {
*/
const VirtualizedRow = React.memo(
({ data, index, style }: { data: VirtualizedRowData; index: number; style: React.CSSProperties }) => {
const { list, focusedIndex, handleItemAction, setIndex } = data
const { list, focusedIndex, handleItemAction } = data
const item = list[index]
if (!item) return null
@@ -531,8 +573,7 @@ const VirtualizedRow = React.memo(
onClick={(e) => {
e.stopPropagation()
handleItemAction(item, 'click')
}}
onMouseEnter={() => setIndex(index)}>
}}>
<QuickPanelItemLeft>
<QuickPanelItemIcon>{item.icon}</QuickPanelItemIcon>
<QuickPanelItemLabel>{item.label}</QuickPanelItemLabel>
@@ -651,11 +692,19 @@ const QuickPanelItem = styled.div`
border-radius: 6px;
cursor: pointer;
transition: background-color 0.1s ease;
&:hover:not(.disabled) {
background-color: var(--focused-color);
}
&.selected {
background-color: var(--selected-color);
&.focused {
background-color: var(--selected-color-dark);
}
&:hover:not(.disabled) {
background-color: var(--selected-color-dark);
}
}
&.focused {
background-color: var(--focused-color);

View File

@@ -0,0 +1,237 @@
import { PlusOutlined } from '@ant-design/icons'
import { isMac } from '@renderer/config/constant'
import { useFullscreen } from '@renderer/hooks/useFullscreen'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import type { Tab } from '@renderer/store/tabs'
import { addTab, removeTab, setActiveTab } from '@renderer/store/tabs'
import {
FileSearch,
Folder,
Home,
Languages,
LayoutGrid,
Palette,
Settings,
Sparkle,
SquareTerminal,
X
} from 'lucide-react'
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useLocation, useNavigate } from 'react-router-dom'
import styled from 'styled-components'
interface TabsContainerProps {
children: React.ReactNode
}
const getTabIcon = (tabId: string): React.ReactNode | undefined => {
switch (tabId) {
case 'home':
return <Home size={14} />
case 'agents':
return <Sparkle size={14} />
case 'translate':
return <Languages size={14} />
case 'paintings':
return <Palette size={14} />
case 'apps':
return <LayoutGrid size={14} />
case 'knowledge':
return <FileSearch size={14} />
case 'mcp':
return <SquareTerminal size={14} />
case 'files':
return <Folder size={14} />
case 'settings':
return <Settings size={14} />
default:
return null
}
}
const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
const { t } = useTranslation()
const location = useLocation()
const navigate = useNavigate()
const dispatch = useAppDispatch()
const tabs = useAppSelector((state) => state.tabs.tabs)
const activeTabId = useAppSelector((state) => state.tabs.activeTabId)
const isFullscreen = useFullscreen()
const getTabId = (path: string): string => {
if (path === '/') return 'home'
const segments = path.split('/')
return segments[1] // 获取第一个路径段作为 id
}
const shouldCreateTab = (path: string) => {
if (path === '/') return false
return !tabs.some((tab) => tab.id === getTabId(path))
}
useEffect(() => {
const tabId = getTabId(location.pathname)
const currentTab = tabs.find((tab) => tab.id === tabId)
if (!currentTab && shouldCreateTab(location.pathname)) {
dispatch(
addTab({
id: tabId,
path: location.pathname
})
)
} else if (currentTab) {
dispatch(setActiveTab(currentTab.id))
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dispatch, location.pathname])
const closeTab = (tabId: string) => {
const tabToClose = tabs.find((tab) => tab.id === tabId)
if (!tabToClose) return
if (tabs.length === 1) return
if (tabId === activeTabId) {
const remainingTabs = tabs.filter((tab) => tab.id !== tabId)
const lastTab = remainingTabs[remainingTabs.length - 1]
navigate(lastTab.path)
}
dispatch(removeTab(tabId))
}
const handleAddTab = () => {
navigate('/launchpad')
}
return (
<Container>
<TabsBar $isFullscreen={isFullscreen}>
{tabs.map((tab) => (
<Tab key={tab.id} active={tab.id === activeTabId} onClick={() => navigate(tab.path)}>
<TabHeader>
{tab.id && <TabIcon>{getTabIcon(tab.id)}</TabIcon>}
<TabTitle>{t(`title.${tab.id}`)}</TabTitle>
</TabHeader>
{tab.id !== 'home' && (
<CloseButton
className="close-button"
onClick={(e) => {
e.stopPropagation()
closeTab(tab.id)
}}>
<X size={12} />
</CloseButton>
)}
</Tab>
))}
<AddTabButton onClick={handleAddTab}>
<PlusOutlined />
</AddTabButton>
</TabsBar>
<TabContent>{children}</TabContent>
</Container>
)
}
const Container = styled.div`
display: flex;
flex-direction: column;
height: 100%;
`
const TabsBar = styled.div<{ $isFullscreen: boolean }>`
display: flex;
flex-direction: row;
align-items: center;
gap: 5px;
padding-left: ${({ $isFullscreen }) => (!$isFullscreen && isMac ? '80px' : '8px')};
-webkit-app-region: drag;
height: var(--navbar-height);
`
const Tab = styled.div<{ active?: boolean }>`
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 10px;
background: ${(props) => (props.active ? 'var(--color-background)' : 'transparent')};
border-radius: 8px;
cursor: pointer;
user-select: none;
-webkit-app-region: none;
min-width: 100px;
transition: background 0.2s;
.close-button {
opacity: 0;
transition: opacity 0.2s;
margin-right: -2px;
}
&:hover {
background: ${(props) => (props.active ? 'var(--color-background)' : 'var(--color-background-soft)')};
.close-button {
opacity: 1;
}
}
`
const TabHeader = styled.div`
display: flex;
align-items: center;
gap: 6px;
`
const TabIcon = styled.span`
display: flex;
align-items: center;
margin-right: 6px;
color: var(--color-text-2);
`
const TabTitle = styled.span`
color: var(--color-text);
font-size: 13px;
margin-right: 8px;
display: flex;
align-items: center;
`
const CloseButton = styled.span`
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
`
const AddTabButton = styled.div`
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
cursor: pointer;
color: var(--color-text);
-webkit-app-region: none;
&:hover {
background: var(--color-background-soft);
border-radius: 8px;
}
`
const TabContent = styled.div`
display: flex;
flex: 1;
overflow: hidden;
width: calc(100vw - 12px);
margin: 6px;
margin-top: 0;
border-radius: 8px;
overflow: hidden;
`
export default TabsContainer

View File

@@ -77,9 +77,10 @@ const TranslateButton: FC<Props> = ({ text, onTranslated, disabled, style, isLoa
<Tooltip
placement="top"
title={t('chat.input.translate', { target_language: getLanguageByLangcode(targetLanguage).label() })}
mouseLeaveDelay={0}
arrow>
<ToolbarButton onClick={handleTranslate} disabled={disabled || isTranslating} style={style} type="text">
{isTranslating ? <LoadingOutlined spin /> : <Languages size={18} />}
{isTranslating ? <LoadingOutlined spin /> : <Languages size={16} />}
</ToolbarButton>
</Tooltip>
)

View File

@@ -122,7 +122,7 @@ describe('QuickPanelView', () => {
}
}
it('should focus on the first item after panel open', () => {
it('should not focus on any item after panel open by default', () => {
const list = createList(100)
render(
@@ -134,11 +134,16 @@ describe('QuickPanelView', () => {
)
)
// 检查第一个 item 是否有 focused
// 检查是否没有任何 focused item
const panel = screen.getByTestId('quick-panel')
const focused = panel.querySelectorAll('.focused')
expect(focused.length).toBe(0)
// 检查第一个 item 存在但没有 focused 类
const item1 = screen.getByText('Item 1')
const focused = item1.closest('.focused')
expect(focused).not.toBeNull()
expect(item1).toBeInTheDocument()
const focusedItem1 = item1.closest('.focused')
expect(focusedItem1).toBeNull()
})
it('should focus on the right item using ArrowUp, ArrowDown', async () => {
@@ -154,10 +159,11 @@ describe('QuickPanelView', () => {
)
const keySequence = [
{ key: 'ArrowUp', expected: 'Item 100' },
{ key: 'ArrowDown', expected: 'Item 1' }, // 从未选中状态按 ArrowDown 会选中第一个
{ key: 'ArrowUp', expected: 'Item 100' }, // 从第一个按 ArrowUp 会循环到最后一个
{ key: 'ArrowUp', expected: 'Item 99' },
{ key: 'ArrowDown', expected: 'Item 100' },
{ key: 'ArrowDown', expected: 'Item 1' }
{ key: 'ArrowDown', expected: 'Item 1' } // 从最后一个按 ArrowDown 会循环到第一个
]
await runKeySequenceAndCheck(screen.getByTestId('quick-panel'), keySequence)
@@ -176,11 +182,11 @@ describe('QuickPanelView', () => {
)
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' }
{ key: 'PageDown', expected: `Item ${PAGE_SIZE}` }, // 从未选中状态按 PageDown 会选中第 pageSize 个项目
{ key: 'PageUp', expected: 'Item 1' }, // PageUp 会选中第一个
{ key: 'ArrowUp', expected: 'Item 100' }, // 从第一个按 ArrowUp 会到最后一个
{ key: 'PageDown', expected: 'Item 100' }, // 从最后一个按 PageDown 仍然是最后一个
{ key: 'PageUp', expected: `Item ${100 - PAGE_SIZE}` } // PageUp 会向上翻页从索引99到92对应Item 93
]
await runKeySequenceAndCheck(screen.getByTestId('quick-panel'), keySequence)
@@ -199,10 +205,11 @@ describe('QuickPanelView', () => {
)
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' }
{ key: 'ArrowDown', ctrlKey: true, expected: 'Item 1' }, // 从未选中状态按 Ctrl+ArrowDown 会选中第一个
{ key: 'ArrowDown', ctrlKey: true, expected: `Item ${PAGE_SIZE + 1}` }, // Ctrl+ArrowDown 会跳转 pageSize 个位置
{ key: 'ArrowUp', ctrlKey: true, expected: 'Item 1' }, // Ctrl+ArrowUp 会跳转回去
{ key: 'ArrowUp', ctrlKey: true, expected: 'Item 100' }, // 从第一个位置再按 Ctrl+ArrowUp 会循环到最后
{ key: 'ArrowDown', ctrlKey: true, expected: 'Item 1' } // 从最后位置按 Ctrl+ArrowDown 会循环到第一个
]
await runKeySequenceAndCheck(screen.getByTestId('quick-panel'), keySequence)

View File

@@ -1,24 +1,13 @@
import { isLinux, isMac, isWin } from '@renderer/config/constant'
import { useFullscreen } from '@renderer/hooks/useFullscreen'
import useNavBackgroundColor from '@renderer/hooks/useNavBackgroundColor'
import type { FC, PropsWithChildren } from 'react'
import type { HTMLAttributes } from 'react'
import { useShowAssistants } from '@renderer/hooks/useStore'
import type { FC, HTMLAttributes, PropsWithChildren } from 'react'
import styled from 'styled-components'
type Props = PropsWithChildren & HTMLAttributes<HTMLDivElement>
export const Navbar: FC<Props> = ({ children, ...props }) => {
const backgroundColor = useNavBackgroundColor()
return (
<NavbarContainer {...props} style={{ backgroundColor }}>
{children}
</NavbarContainer>
)
}
export const NavbarLeft: FC<Props> = ({ children, ...props }) => {
return <NavbarLeftContainer {...props}>{children}</NavbarLeftContainer>
return <NavbarContainer {...props}>{children}</NavbarContainer>
}
export const NavbarCenter: FC<Props> = ({ children, ...props }) => {
@@ -36,8 +25,10 @@ export const NavbarRight: FC<Props> = ({ children, ...props }) => {
export const NavbarMain: FC<Props> = ({ children, ...props }) => {
const isFullscreen = useFullscreen()
const { showAssistants } = useShowAssistants()
return (
<NavbarMainContainer {...props} $isFullscreen={isFullscreen}>
<NavbarMainContainer {...props} $isFullscreen={isFullscreen} $showAssistants={showAssistants}>
{children}
</NavbarMainContainer>
)
@@ -49,28 +40,8 @@ const NavbarContainer = styled.div`
flex-direction: row;
min-height: var(--navbar-height);
max-height: var(--navbar-height);
margin-left: ${isMac ? 'calc(var(--sidebar-width) * -1)' : 0};
padding-left: ${isMac ? 'var(--sidebar-width)' : 0};
-webkit-app-region: drag;
`
const NavbarLeftContainer = styled.div`
min-width: var(--assistants-width);
padding: 0 10px;
display: flex;
flex-direction: row;
align-items: center;
font-weight: bold;
color: var(--color-text-1);
`
const NavbarCenterContainer = styled.div`
flex: 1;
display: flex;
align-items: center;
padding: 0 ${isMac ? '20px' : 0};
font-weight: bold;
color: var(--color-text-1);
background-color: var(--color-background);
`
const NavbarRightContainer = styled.div<{ $isFullscreen: boolean }>`
@@ -82,14 +53,45 @@ const NavbarRightContainer = styled.div<{ $isFullscreen: boolean }>`
justify-content: flex-end;
`
const NavbarMainContainer = styled.div<{ $isFullscreen: boolean }>`
const NavbarMainContainer = styled.div<{ $isFullscreen: boolean; $showAssistants: boolean }>`
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
background-color: var(--color-background);
height: var(--navbar-height);
max-height: var(--navbar-height);
min-height: var(--navbar-height);
justify-content: space-between;
padding: 0 ${isMac ? '20px' : 0};
font-weight: bold;
color: var(--color-text-1);
padding-right: ${({ $isFullscreen }) => ($isFullscreen ? '12px' : isWin ? '140px' : isLinux ? '120px' : '12px')};
-webkit-app-region: drag;
padding: 0 12px;
padding-left: ${({ $showAssistants }) => (isMac && !$showAssistants ? '70px' : '10px')};
`
const NavbarCenterContainer = styled.div`
flex: 1;
display: flex;
align-items: center;
height: var(--navbar-height);
max-height: var(--navbar-height);
min-height: var(--navbar-height);
padding: 0 8px;
font-weight: bold;
justify-content: space-between;
color: var(--color-text-1);
`
// const rotateAnimation = keyframes`
// from {
// transform: rotate(-180deg);
// }
// to {
// transform: rotate(0);
// }
// `
// const AnimatedButton = styled(Button)`
// animation: ${rotateAnimation} 0.4s ease-out;
// `

View File

@@ -1,8 +1,6 @@
import EmojiAvatar from '@renderer/components/Avatar/EmojiAvatar'
import { isMac } from '@renderer/config/constant'
import { AppLogo, UserAvatar } from '@renderer/config/env'
import { AppLogo } from '@renderer/config/env'
import { useTheme } from '@renderer/context/ThemeProvider'
import useAvatar from '@renderer/hooks/useAvatar'
import { useFullscreen } from '@renderer/hooks/useFullscreen'
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
import { useMinapps } from '@renderer/hooks/useMinapps'
@@ -11,9 +9,8 @@ import { modelGenerating, useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings'
import i18n from '@renderer/i18n'
import { ThemeMode } from '@renderer/types'
import { isEmoji } from '@renderer/utils'
import type { MenuProps } from 'antd'
import { Avatar, Dropdown, Tooltip } from 'antd'
import { Dropdown, Tooltip } from 'antd'
import {
CircleHelp,
FileSearch,
@@ -35,7 +32,6 @@ import styled from 'styled-components'
import { DraggableList } from '../DraggableList'
import MinAppIcon from '../Icons/MinAppIcon'
import UserPopup from '../Popups/UserPopup'
const Sidebar: FC = () => {
const { hideMinappPopup, openMinapp } = useMinappPopup()
@@ -47,11 +43,8 @@ const Sidebar: FC = () => {
const navigate = useNavigate()
const { theme, settedTheme, toggleTheme } = useTheme()
const avatar = useAvatar()
const { t } = useTranslation()
const onEditUser = () => UserPopup.show()
const backgroundColor = useNavBackgroundColor()
const showPinnedApps = pinned.length > 0 && sidebarIcons.visible.includes('minapp')
@@ -79,13 +72,6 @@ const Sidebar: FC = () => {
$isFullscreen={isFullscreen}
id="app-sidebar"
style={{ backgroundColor, zIndex: minappShow ? 10000 : 'initial' }}>
{isEmoji(avatar) ? (
<EmojiAvatar onClick={onEditUser} className="sidebar-avatar" size={31} fontSize={18}>
{avatar}
</EmojiAvatar>
) : (
<AvatarImg src={avatar || UserAvatar} draggable={false} className="nodrag" onClick={onEditUser} />
)}
<MainMenusContainer>
<Menus onClick={hideMinappPopup}>
<MainMenus />
@@ -339,16 +325,6 @@ const Container = styled.div<{ $isFullscreen: boolean }>`
}
`
const AvatarImg = styled(Avatar)`
width: 31px;
height: 31px;
background-color: var(--color-background-soft);
margin-bottom: ${isMac ? '12px' : '12px'};
margin-top: ${isMac ? '0px' : '2px'};
border: none;
cursor: pointer;
`
const MainMenusContainer = styled.div`
display: flex;
flex: 1;

View File

@@ -33,3 +33,6 @@ export const THEME_COLOR_PRESETS = [
'#0EA5E9', // Sky Blue
'#0284C7' // Light Blue
]
export const MAX_CONTEXT_COUNT = 100
export const UNLIMITED_CONTEXT_COUNT = 100000

View File

@@ -2487,7 +2487,7 @@ export function isGrokModel(model?: Model): boolean {
return model.id.includes('grok')
}
export function isGrokReasoningModel(model?: Model): boolean {
export function isSupportedReasoningEffortGrokModel(model?: Model): boolean {
if (!model) {
return false
}
@@ -2499,7 +2499,16 @@ export function isGrokReasoningModel(model?: Model): boolean {
return false
}
export const isSupportedReasoningEffortGrokModel = isGrokReasoningModel
export function isGrokReasoningModel(model?: Model): boolean {
if (!model) {
return false
}
if (isSupportedReasoningEffortGrokModel(model) || model.id.includes('grok-4')) {
return true
}
return false
}
export function isGeminiReasoningModel(model?: Model): boolean {
if (!model) {

View File

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

View File

@@ -99,7 +99,8 @@ export const CodeStyleProvider: React.FC<PropsWithChildren> = ({ children }) =>
bash: 'shell',
'objective-c++': 'objective-cpp',
svg: 'xml',
vab: 'vb'
vab: 'vb',
graphviz: 'dot'
} as Record<string, string>
}, [])

View File

@@ -23,7 +23,7 @@ interface ThemeProviderProps extends PropsWithChildren {
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
// 用户设置的主题
const { theme: settedTheme, setTheme: setSettedTheme } = useSettings()
const { theme: settedTheme, setTheme: setSettedTheme, transparentWindow } = useSettings()
const [actualTheme, setActualTheme] = useState<ThemeMode>(
window.matchMedia('(prefers-color-scheme: dark)').matches ? ThemeMode.dark : ThemeMode.light
)
@@ -56,7 +56,7 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
document.body.setAttribute('theme-mode', actualTheme)
setActualTheme(actualTheme)
})
}, [actualTheme, initUserTheme, setSettedTheme, settedTheme])
}, [actualTheme, initUserTheme, setSettedTheme, settedTheme, transparentWindow])
useEffect(() => {
window.api.setTheme(settedTheme)

View File

@@ -1,21 +1,25 @@
import SettingsPopup from '@renderer/components/Popups/SettingsPopup'
import NavigationService from '@renderer/services/NavigationService'
import { useAppSelector } from '@renderer/store'
import { useEffect } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import { useLocation, useNavigate } from 'react-router-dom'
import { useNavigate } from 'react-router-dom'
const NavigationHandler: React.FC = () => {
const location = useLocation()
const navigate = useNavigate()
const showSettingsShortcutEnabled = useAppSelector(
(state) => state.shortcuts.shortcuts.find((s) => s.key === 'show_settings')?.enabled
)
useEffect(() => {
NavigationService.setNavigate(navigate)
}, [navigate])
useHotkeys(
'meta+, ! ctrl+,',
function () {
if (location.pathname.startsWith('/settings')) {
return
}
navigate('/settings/provider')
SettingsPopup.show({ defaultTab: 'provider' })
},
{
splitKey: '!',

View File

@@ -1,28 +0,0 @@
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { addAgent, removeAgent, updateAgent, updateAgents, updateAgentSettings } from '@renderer/store/agents'
import { Agent, AssistantSettings } from '@renderer/types'
export function useAgents() {
const agents = useAppSelector((state) => state.agents.agents)
const dispatch = useAppDispatch()
return {
agents,
updateAgents: (agents: Agent[]) => dispatch(updateAgents(agents)),
addAgent: (agent: Agent) => dispatch(addAgent(agent)),
removeAgent: (id: string) => dispatch(removeAgent({ id }))
}
}
export function useAgent(id: string) {
const agent = useAppSelector((state) => state.agents.agents.find((a) => a.id === id) as Agent)
const dispatch = useAppDispatch()
return {
agent,
updateAgent: (agent: Agent) => dispatch(updateAgent(agent)),
updateAgentSettings: (settings: Partial<AssistantSettings>) => {
dispatch(updateAgentSettings({ assistantId: agent.id, settings }))
}
}
}

View File

@@ -1,6 +1,4 @@
import { isMac } from '@renderer/config/constant'
import { isLocalAi } from '@renderer/config/env'
import { useTheme } from '@renderer/context/ThemeProvider'
import db from '@renderer/databases'
import i18n from '@renderer/i18n'
import KnowledgeQueue from '@renderer/queue/KnowledgeQueue'
@@ -13,17 +11,14 @@ import { useEffect } from 'react'
import { useDefaultModel } from './useAssistant'
import useFullScreenNotice from './useFullScreenNotice'
import { useRuntime } from './useRuntime'
import { useSettings } from './useSettings'
import useUpdateHandler from './useUpdateHandler'
export function useAppInit() {
const dispatch = useAppDispatch()
const { proxyUrl, language, windowStyle, autoCheckUpdate, proxyMode, customCss, enableDataCollection } = useSettings()
const { minappShow } = useRuntime()
const { proxyUrl, language, autoCheckUpdate, proxyMode, customCss, enableDataCollection } = useSettings()
const { setDefaultModel, setTopicNamingModel, setTranslateModel } = useDefaultModel()
const avatar = useLiveQuery(() => db.settings.get('image://avatar'))
const { theme } = useTheme()
useEffect(() => {
document.getElementById('spinner')?.remove()
@@ -70,18 +65,6 @@ export function useAppInit() {
i18n.changeLanguage(language || navigator.language || defaultLanguage)
}, [language])
useEffect(() => {
const transparentWindow = windowStyle === 'transparent' && isMac && !minappShow
if (minappShow) {
window.root.style.background =
windowStyle === 'transparent' && isMac ? 'var(--color-background)' : 'var(--navbar-background)'
return
}
window.root.style.background = transparentWindow ? 'var(--navbar-background-mac)' : 'var(--navbar-background)'
}, [windowStyle, minappShow, theme])
useEffect(() => {
if (isLocalAi) {
const model = JSON.parse(import.meta.env.VITE_RENDERER_INTEGRATED_MODEL)

View File

@@ -1,45 +1,56 @@
import { db } from '@renderer/databases'
import { getDefaultTopic } from '@renderer/services/AssistantService'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import {
addAssistant,
addTopic,
removeAllTopics,
createAssistantFromTemplate,
removeAssistant,
removeTopic,
selectActiveAssistants,
selectTemplates,
setModel,
updateAssistant,
updateAssistants,
updateAssistantSettings,
updateDefaultAssistant,
updateTopic,
updateTopics
updateDefaultAssistant
} from '@renderer/store/assistants'
import { setDefaultModel, setTopicNamingModel, setTranslateModel } from '@renderer/store/llm'
import { selectTopicsForAssistant, topicsActions } from '@renderer/store/topics'
import { Assistant, AssistantSettings, Model, Topic } from '@renderer/types'
import { useCallback, useMemo } from 'react'
import { TopicManager } from './useTopic'
export function useAssistants() {
const { assistants } = useAppSelector((state) => state.assistants)
const assistants = useAppSelector(selectActiveAssistants)
const templates = useAppSelector(selectTemplates)
const dispatch = useAppDispatch()
const getAssistantById = useCallback((id: string) => assistants.find((a) => a.id === id), [assistants])
return {
assistants,
templates,
getAssistantById,
updateAssistants: (assistants: Assistant[]) => dispatch(updateAssistants(assistants)),
addAssistant: (assistant: Assistant) => dispatch(addAssistant(assistant)),
addAssistant: (assistant: Assistant) => {
dispatch(addAssistant({ ...assistant, isTemplate: false }))
dispatch(topicsActions.addDefaultTopic({ assistantId: assistant.id }))
},
addTemplate: (template: Assistant) => {
dispatch(addAssistant({ ...template, isTemplate: true }))
},
removeAssistant: (id: string) => {
dispatch(removeAssistant({ id }))
const assistant = assistants.find((a) => a.id === id)
const topics = assistant?.topics || []
topics.forEach(({ id }) => TopicManager.removeTopic(id))
// Remove all topics for this assistant
dispatch(topicsActions.removeAllTopics({ assistantId: id }))
},
createAssistantFromTemplate: (templateId: string, assistantId: string) => {
dispatch(createAssistantFromTemplate({ templateId, assistantId }))
dispatch(topicsActions.addDefaultTopic({ assistantId }))
}
}
}
export function useAssistant(id: string) {
const assistant = useAppSelector((state) => state.assistants.assistants.find((a) => a.id === id) as Assistant)
const topics = useTopicsForAssistant(id)
const dispatch = useAppDispatch()
const { defaultModel } = useDefaultModel()
@@ -48,19 +59,18 @@ export function useAssistant(id: string) {
throw new Error(`Assistant model is not set for assistant with name: ${assistant?.name ?? 'unknown'}`)
}
const assistantWithModel = useMemo(() => ({ ...assistant, model }), [assistant, model])
const assistantWithModel = useMemo(() => ({ ...assistant, model, topics }), [assistant, model, topics])
return {
assistant: assistantWithModel,
model,
addTopic: (topic: Topic) => dispatch(addTopic({ assistantId: assistant.id, topic })),
topics,
addTopic: (topic: Topic) => dispatch(topicsActions.addTopic({ assistantId: id, topic })),
removeTopic: (topic: Topic) => {
TopicManager.removeTopic(topic.id)
dispatch(removeTopic({ assistantId: assistant.id, topic }))
dispatch(topicsActions.removeTopic({ assistantId: id, topicId: topic.id }))
},
moveTopic: (topic: Topic, toAssistant: Assistant) => {
dispatch(addTopic({ assistantId: toAssistant.id, topic: { ...topic, assistantId: toAssistant.id } }))
dispatch(removeTopic({ assistantId: assistant.id, topic }))
dispatch(topicsActions.moveTopic({ fromAssistantId: id, toAssistantId: toAssistant.id, topicId: topic.id }))
// update topic messages in database
db.topics
.where('id')
@@ -74,9 +84,9 @@ export function useAssistant(id: string) {
}
})
},
updateTopic: (topic: Topic) => dispatch(updateTopic({ assistantId: assistant.id, topic })),
updateTopics: (topics: Topic[]) => dispatch(updateTopics({ assistantId: assistant.id, topics })),
removeAllTopics: () => dispatch(removeAllTopics({ assistantId: assistant.id })),
updateTopic: (topic: Topic) => dispatch(topicsActions.updateTopic({ assistantId: id, topic })),
updateTopics: (topics: Topic[]) => dispatch(topicsActions.updateTopics({ assistantId: id, topics })),
removeAllTopics: () => dispatch(topicsActions.removeAllTopics({ assistantId: id })),
setModel: useCallback(
(model: Model) => assistant && dispatch(setModel({ assistantId: assistant?.id, model })),
[assistant, dispatch]
@@ -88,16 +98,19 @@ export function useAssistant(id: string) {
}
}
export function useTopicsForAssistant(assistantId: string) {
return useAppSelector((state) => selectTopicsForAssistant(state, assistantId))
}
/**
* 默认助手模板
*/
export function useDefaultAssistant() {
const defaultAssistant = useAppSelector((state) => state.assistants.defaultAssistant)
const dispatch = useAppDispatch()
const memoizedTopics = useMemo(() => [getDefaultTopic(defaultAssistant.id)], [defaultAssistant.id])
return {
defaultAssistant: {
...defaultAssistant,
topics: memoizedTopics
},
defaultAssistant,
updateDefaultAssistant: (assistant: Assistant) => dispatch(updateDefaultAssistant({ assistant }))
}
}

View File

@@ -0,0 +1,92 @@
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { loadTopicMessagesThunk } from '@renderer/store/thunk/messageThunk'
import { Assistant, Topic } from '@renderer/types'
import { createContext, use, useCallback, useEffect, useMemo, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { useTopicsForAssistant } from './useAssistant'
import { useSettings } from './useSettings'
interface ChatContextType {
activeAssistant: Assistant
activeTopic: Topic
setActiveAssistant: (assistant: Assistant) => void
setActiveTopic: (topic: Topic) => void
}
const ChatContext = createContext<ChatContextType | null>(null)
export const ChatProvider = ({ children }) => {
const assistants = useAppSelector((state) => state.assistants.assistants)
const [activeAssistant, setActiveAssistantBase] = useState<Assistant>(assistants[0])
const topics = useTopicsForAssistant(activeAssistant.id)
const [activeTopic, setActiveTopic] = useState<Topic>(topics[0])
const { clickAssistantToShowTopic } = useSettings()
const dispatch = useAppDispatch()
const navigate = useNavigate()
const location = useLocation()
// 包装setActiveAssistant以添加导航逻辑
const setActiveAssistant = useCallback(
(assistant: Assistant) => {
setActiveAssistantBase(assistant)
// 如果当前不在聊天页面,导航到聊天页面
if (location.pathname !== '/') {
navigate('/')
}
},
[setActiveAssistantBase, location.pathname, navigate]
)
// 当 topics 变化时,如果当前 activeTopic 不在 topics 中,设置第一个 topic
useEffect(() => {
if (!topics.find((topic) => topic.id === activeTopic?.id)) {
const firstTopic = topics[0]
firstTopic && setActiveTopic(firstTopic)
}
}, [topics, activeTopic?.id])
// 当 activeTopic 变化时加载消息
useEffect(() => {
if (activeTopic) {
dispatch(loadTopicMessagesThunk(activeTopic.id))
EventEmitter.emit(EVENT_NAMES.CHANGE_TOPIC, activeTopic)
}
}, [activeTopic, dispatch])
// 处理点击助手显示话题侧边栏
useEffect(() => {
if (clickAssistantToShowTopic) {
EventEmitter.emit(EVENT_NAMES.SHOW_TOPIC_SIDEBAR)
}
}, [clickAssistantToShowTopic, activeAssistant])
useEffect(() => {
const subscriptions = [
EventEmitter.on(EVENT_NAMES.SET_ASSISTANT, setActiveAssistant),
EventEmitter.on(EVENT_NAMES.SET_TOPIC, setActiveTopic)
]
return () => subscriptions.forEach((subscription) => subscription())
}, [setActiveAssistant])
const value = useMemo(
() => ({
activeAssistant,
activeTopic,
setActiveAssistant,
setActiveTopic
}),
[activeAssistant, activeTopic, setActiveAssistant]
)
return <ChatContext value={value}>{children}</ChatContext>
}
export const useChat = () => {
const context = use(ChatContext)
if (!context) {
throw new Error('useChat must be used within ChatProvider')
}
return context
}

View File

@@ -3,7 +3,7 @@ import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { RootState } from '@renderer/store'
import { messageBlocksSelectors } from '@renderer/store/messageBlock'
import { selectMessagesForTopic } from '@renderer/store/newMessage'
import { setActiveTopic, setSelectedMessageIds, toggleMultiSelectMode } from '@renderer/store/runtime'
import { setSelectedMessageIds, toggleMultiSelectMode } from '@renderer/store/runtime'
import { Topic } from '@renderer/types'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -27,10 +27,6 @@ export const useChatContext = (activeTopic: Topic) => {
return () => unsubscribe()
}, [dispatch])
useEffect(() => {
dispatch(setActiveTopic(activeTopic))
}, [dispatch, activeTopic])
const handleToggleMultiSelectMode = useCallback(
(value: boolean) => {
dispatch(toggleMultiSelectMode(value))

View File

@@ -23,7 +23,6 @@ import { useCallback, useEffect, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { v4 as uuidv4 } from 'uuid'
import { useAgents } from './useAgents'
import { useAssistants } from './useAssistant'
export const useKnowledge = (baseId: string) => {
@@ -145,7 +144,8 @@ export const useKnowledge = (baseId: string) => {
}
}
if (item.type === 'file' && typeof item.content === 'object') {
await window.api.file.deleteDir(item.content.id)
// name: eg. text.pdf
await window.api.file.delete(item.content.name)
}
}
// 刷新项目
@@ -294,7 +294,6 @@ export const useKnowledgeBases = () => {
const dispatch = useDispatch()
const bases = useSelector((state: RootState) => state.knowledge.bases)
const { assistants, updateAssistants } = useAssistants()
const { agents, updateAgents } = useAgents()
const addKnowledgeBase = (base: KnowledgeBase) => {
dispatch(addBase(base))
@@ -318,19 +317,7 @@ export const useKnowledgeBases = () => {
return assistant
})
// remove agent knowledge_base
const _agents = agents.map((agent) => {
if (agent.knowledge_bases?.find((kb) => kb.id === baseId)) {
return {
...agent,
knowledge_bases: agent.knowledge_bases.filter((kb) => kb.id !== baseId)
}
}
return agent
})
updateAssistants(_assistants)
updateAgents(_agents)
}
const updateKnowledgeBases = (bases: KnowledgeBase[]) => {

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