Compare commits

...

165 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
kangfenmao
8f38422e7f chore(version): 1.4.9 2025-07-09 10:17:40 +08:00
自由的世界人
79a64f0118 fix: Improve model filtering and group handling (#7950)
Expanded the text-to-image model regex to include more identifiers. Removed the getModelGroup function and now use the model's group property directly. Updated model selection in ModelSettings and TranslatePage to also filter out rerank and text-to-image models, ensuring only appropriate models are shown in dropdowns.
2025-07-09 00:15:47 +08:00
Chen Tao
55648350ed fix: knowledge file cannot open (#7957) 2025-07-08 20:47:45 +08:00
one
2a33a9af64 revert: timing for adding citation references (#7953) 2025-07-08 20:02:51 +08:00
SuYao
14c5357fa3 fix(ApiClientFactory): adjust provider type handling for OpenAI clients (#7675) 2025-07-08 19:21:49 +08:00
Calcium-Ion
a343377a43 feat: add painting support for NewAPI provider (#7905)
* feat: add NewAPI painting support

* fix(NewApiPage): update help link to point to the correct documentation

* feat(NewApiPage): support image generation in API client

* fix: resolve the issue of messy drawing data from aihubmix provider

* feat: group model options in dropdown by category

* fix: update translation to use LanguagesEnum
2025-07-08 17:27:53 +08:00
one
de75992e7b refactor(CodePreview): smoothing code highlighting on streaming (#7842) 2025-07-08 17:25:14 +08:00
SuYao
fba6c1642d feat: implement tool call progress handling and status updates (#7303)
* feat: implement tool call progress handling and status updates

- Update MCP tool response handling to include 'pending' and 'cancelled' statuses.
- Introduce new IPC channel for progress updates.
- Enhance UI components to reflect tool call statuses, including pending and cancelled states.
- Add localization for new status messages in multiple languages.
- Refactor message handling logic to accommodate new tool response types.

* fix: adjust alignment of action tool container in MessageTools component

- Change justify-content from flex-end to flex-start to improve layout consistency.

* feat: enhance tool confirmation handling and update related components

- Introduced a new tool confirmation mechanism in userConfirmation.ts, allowing for individual tool confirmations.
- Updated GeminiAPIClient and OpenAIResponseAPIClient to include tool configuration options.
- Refactored MessageTools component to utilize new confirmation functions and improved styling.
- Enhanced mcp-tools.ts to manage tool invocation and confirmation processes more effectively, ensuring real-time status updates.

* refactor(McpToolChunkMiddleware): enhance tool execution handling and confirmation tracking

- Updated createToolHandlingTransform to manage confirmed tool calls and results more effectively.
- Refactored executeToolCalls and executeToolUseResponses to return both tool results and confirmed tool calls.
- Adjusted buildParamsWithToolResults to utilize confirmed tool calls for building new request messages.
- Improved error handling in messageThunk for tool call status updates, ensuring accurate block ID mapping.

* feat(McpToolChunkMiddleware, ToolUseExtractionMiddleware, mcp-tools, userConfirmation): enhance tool execution and confirmation handling

- Updated McpToolChunkMiddleware to execute tool calls and responses asynchronously, improving performance and response handling.
- Enhanced ToolUseExtractionMiddleware to generate unique tool IDs for better tracking.
- Modified parseToolUse function to accept a starting index for tool extraction.
- Improved user confirmation handling with abort signal support to manage tool action confirmations more effectively.
- Updated SYSTEM_PROMPT to clarify the use of multiple tools per message.

* fix(tagExtraction): update test expectations for tag extraction results

- Adjusted expected length of results from 7 to 9 to reflect changes in tag extraction logic.
- Modified content assertions for specific tag contents to ensure accurate validation of extracted tags.

* refactor(GeminiAPIClient, OpenAIResponseAPIClient): remove unused function calling configurations

- Removed the unused FunctionCallingConfigMode from GeminiAPIClient to streamline the code.
- Eliminated the parallel_tool_calls property from OpenAIResponseAPIClient, simplifying the tool call configuration.

* feat(McpToolChunkMiddleware): enhance LLM response handling and tool call confirmation

- Added notification to UI for new LLM response processing before recursive calls in createToolHandlingTransform.
- Improved tool call confirmation logic in executeToolCalls to match tool IDs more accurately, enhancing response validation.

* refactor(McpToolChunkMiddleware, ToolUseExtractionMiddleware, messageThunk): remove unnecessary console logs

- Eliminated redundant console log statements in McpToolChunkMiddleware, ToolUseExtractionMiddleware, and messageThunk to clean up the code and improve performance.
- Focused on enhancing readability and maintainability by reducing clutter in the logging output.

* refactor(McpToolChunkMiddleware): remove redundant logging statements

- Eliminated unnecessary logging in createToolHandlingTransform to streamline the code and enhance readability.
- Focused on reducing clutter in the logging output while maintaining error handling functionality.

* feat: enhance action button functionality with cancel and confirm options

* refactor(AbortHandlerMiddleware, McpToolChunkMiddleware, ToolUseExtractionMiddleware, messageThunk): improve error handling and code clarity

- Updated AbortHandlerMiddleware to skip abort status checks if an error chunk is received, enhancing error handling logic.
- Replaced console.error with Logger.error in McpToolChunkMiddleware for consistent logging practices.
- Refined ToolUseExtractionMiddleware to improve tool use extraction logic and ensure proper handling of tool_use tags.
- Enhanced messageThunk to include initialPlaceholderBlockId in block ID checks, improving error state management.

* refactor(ToolUseExtractionMiddleware): enhance tool use parsing logic with counter

- Introduced a toolCounter to track the number of tool use responses processed.
- Updated parseToolUse function calls to include the toolCounter, improving the extraction logic and ensuring accurate response handling.

* feat(McpService, IpcChannel, MessageTools): implement tool abort functionality

- Added Mcp_AbortTool channel to handle tool abortion requests.
- Implemented abortTool method in McpService to manage active tool calls and provide logging.
- Updated MessageTools component to include an abort button for ongoing tool calls, enhancing user control.
- Modified API calls to support optional callId for better tracking of tool executions.
- Added localization strings for tool abort messages in multiple languages.

---------

Co-authored-by: Vaayne <liu.vaayne@gmail.com>
2025-07-08 17:17:58 +08:00
LiuVaayne
4f7ca3ede8 fix: include headers when importing MCP server configurations (#7944)
- Add missing headers field to newServer object creation in AddMcpServerModal.tsx
- Update streamableHttp JSON example to show headers format
- Fixes issue where Content-Type and Authorization headers were not imported

Fixes #7932

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

Co-authored-by: Claude <noreply@anthropic.com>
2025-07-08 17:16:59 +08:00
SuYao
8fd59e89de feat: provider custom header (#7874)
* feat: provider custom header

* fix: state update dependency

* refactor: migrate to code editor onBlur

* fix: lint

* fix: migrate
2025-07-08 17:11:43 +08:00
one
da5badc189 perf: draggable virtual list (#7904)
* perf(TopicsTab): use DraggableVirtualList for the topic list

- Add a DraggableVirtualList implemented using react-virtual
- Rename DragableList to DraggableList
- Add tests

* refactor: improve props, fix drag area
2025-07-08 17:05:40 +08:00
beyondkmp
33da5d31cf fix: cannot show window in mini and hide status (#7943)
* feat(ProtocolClient): show main window on protocol URL handling

* refactor(ProtocolClient): remove main window display logic; update handleProviders to show window on macOS

* fix lint

---------

Co-authored-by: rcadmin <rcadmin@rcadmins-MacBook-Pro-4.local>
2025-07-08 16:49:28 +08:00
SuYao
f506a9d7ac fix(provider): fix azure type (#7926)
* fix(provider): fix azure type

* fix: lint

---------

Co-authored-by: George·Dong <98630204+GeorgeDong32@users.noreply.github.com>
2025-07-08 16:31:10 +08:00
one
915291d780 fix: content search count on enable (#7920) 2025-07-08 14:44:21 +08:00
one
08b9e0788f chore: git blame ignore (#7925) 2025-07-08 14:23:55 +08:00
beyondkmp
115d2078b9 feat: implement local cloud directory backup functionality (#6353)
* feat: implement local backup functionality

- Added new IPC channels for local backup operations including backup, restore, list, delete, and set directory.
- Enhanced BackupManager with methods for handling local backups and integrated auto-sync capabilities.
- Updated settings to include local backup configurations and integrated UI components for managing local backups.
- Localized new features in English, Japanese, Russian, and Chinese.

* refactor: enhance BackupManager and LocalBackupModals for improved file handling

- Updated BackupManager to specify the type of result array for better type safety.
- Refactored showBackupModal in LocalBackupModals to use useCallback and generate a more descriptive default file name based on device type and timestamp.

* refactor: update localBackupDir path in BackupManager for consistency

- Changed localBackupDir to use the temp directory instead of userData for better alignment with backup storage practices.

* refactor: enforce localBackupDir parameter in BackupManager methods

- Updated BackupManager methods to require localBackupDir as a parameter, removing fallback to a default value for improved clarity and consistency in backup operations.
- Removed the localBackupDir property from the class, streamlining the backup management process.

* fix: update localization strings for improved clarity and consistency

- Revised English, Russian, Chinese, and Traditional Chinese localization strings for better user understanding.
- Adjusted phrases related to backup and restore processes to enhance clarity.
- Standardized terminology across different languages for consistency.

* fix: update Chinese localization strings for consistency and clarity

- Revised export menu strings in zh-cn.json to improve formatting and consistency.
- Removed spaces in phrases for a more streamlined appearance.

* feat(settings): add option to disable hardware acceleration

- Introduced a new setting to allow users to disable hardware acceleration.
- Added corresponding IPC channel and configuration management methods.
- Updated UI components to reflect the new setting and prompt for app restart.
- Localized confirmation messages for hardware acceleration changes in multiple languages.

* udpate migrate

* format code

* feat(i18n): add localized error messages for backup directory selection

- Introduced new error messages for selecting a backup directory in multiple languages, including English, Japanese, Russian, Simplified Chinese, and Traditional Chinese.
- Added checks in the LocalBackupSettings component to ensure the selected directory is not the same as the application data or installation paths, and that it has write permissions.

* format

* update migrate

* refactor(LocalBackup): streamline local backup directory validation and enhance settings UI

- Removed translation dependency in LocalBackupModals for error handling.
- Added comprehensive validation for local backup directory in LocalBackupSettings, including checks for app data path, install path, and write permissions.
- Introduced a clear directory button in the settings UI to reset the local backup directory.
- Updated the auto-sync logic to account for local backup settings.

* refactor(LocalBackupManager): remove redundant error messages for invalid local backup directory

- Eliminated repeated error message calls for invalid local backup directory in the LocalBackupManager component.
- Streamlined the validation logic to enhance code clarity and maintainability.
2025-07-08 14:04:29 +08:00
fullex
00151f2c67 fix: linux launch on boot (#7907)
* fix: linux launch on boot

* fix

* fix(AppService): change fs ops from sync to async
2025-07-08 14:01:58 +08:00
Konv Suu
0e670329c8 feat(miniapp): add swap fo betterr filtering (#7784)
* feat(miniapp): add swap fo betterr filtering

* update
2025-07-08 13:42:24 +08:00
one
4ac8a38834 style: set eol to lf, code formatting (#7923)
* chore(gitattributes): set eol to lf

* chore: git renormalize

* style: reformatting

* chore: keep eslint prettier plugin consistent on eol
2025-07-08 09:50:33 +08:00
Phantom
4111ee4c58 fix(databases): fix upgrade undefined error (#7929)
fix(databases): 修复升级到V8时语言对映射的逻辑错误

修复在数据库升级到V8版本时,语言对映射逻辑中未正确处理originPair为空的情况
2025-07-08 09:44:54 +08:00
Phantom
05b8afd681 feat: more encoding (#7898)
* feat(文件处理): 添加文件编码支持以正确处理不同编码的文本文件

添加文件编码检测和指定编码读取功能
- 在FileMetadata接口中添加encoding字段
- 添加iconv-lite和jschardet依赖用于编码处理和检测
- 文件上传时自动检测文本文件编码
- 文件读取时支持指定编码参数
- 更新所有API客户端以传递文件编码参数

* feat(文件处理): 添加文件编码检测和UTF-8读取功能

新增文件编码检测工具函数和UTF-8读取功能,统一处理不同编码的文件读取
移除重复的编码检测逻辑,优化代码结构

* refactor(FileStorage): 使用 readFileUTF8 替换 decodeBuffer 读取文件

移除冗余的 decodeBuffer 逻辑,直接使用封装好的 readFileUTF8 方法读取文件内容

* docs(utils): 为文件编码相关函数添加注释说明

添加对 detectEncoding、decodeBuffer 和 readFileUTF8 函数的详细注释,说明其功能和使用方法

* fix(utils): 为detectEncoding函数添加返回类型声明

* refactor(文件处理): 移除冗余的decodeBuffer函数并直接使用iconv.decode

简化文件读取逻辑,直接调用iconv.decode而不是通过中间函数decodeBuffer

* test(file): 添加文件编码检测的测试用例

* test(文件编码检测): 移除ISO-8859-1编码的测试匹配

* refactor(file): 移除文件编码相关逻辑,统一使用UTF-8读取文本文件

移除FileMetadata接口中的encoding字段及相关检测逻辑
将所有文件读取操作统一改为使用readTextFileUTF8方法

* fix(文件读取): 改进文本文件解码逻辑以处理编码识别错误

当自动识别的编码包含错误字符时,尝试其他可能的编码

* refactor(utils): 将 console 日志替换为 electron-log 记录器

* refactor(文件存储): 移除文件读取时的可选编码参数

简化文件读取逻辑,始终使用UTF-8编码读取文本文件

* fix(utils): 修复文件编码检测中的文件描述符泄漏

在detectEncoding函数中,文件描述符在使用后未关闭,可能导致资源泄漏

* refactor(文件处理): 将readTextFileUTF8重命名为readTextFileWithAutoEncoding并改进编码检测

修复文件编码检测中未正确关闭文件描述符的问题
改进文本文件读取功能以支持自动编码检测

* test(file): 重构编码检测测试用例并改进测试结构

- 将 describe 块重命名为更明确的 detectEncoding
- 提取公共的 mock 逻辑到 beforeEach
- 更新测试描述为英文并保持一致性
- 简化测试实现,移除重复代码

* test(file): 添加对readTextFileWithAutoEncoding的测试用例
2025-07-08 00:57:31 +08:00
Phantom
2b4ca03376 fix(store): fix store migrate and version (#7924)
* fix(store): 更新持久化存储版本号至121

更新版本号以匹配最新的迁移配置变更

* fix(store): 合并migrate版本121到120
2025-07-08 00:57:08 +08:00
Phantom
a314a43f0f refactor(translate): Language Type (#7727)
* refactor(translate): 重构翻译功能使用语言枚举类型

统一翻译功能中的语言表示方式,使用枚举类型替代字符串
更新相关组件和服务以适配新的语言类型定义
添加数据库迁移脚本处理语言类型变更
添加store迁移处理语言类型变更

* refactor(translate): 移除调试用的console.log语句

* refactor(translate): 移除冗余的类型检查逻辑

* fix(db): 添加对TranslateHistory的db迁移

* fix(databases): 捕获数据库升级时的语言映射错误

添加错误处理以防止语言映射失败时中断升级过程

* fix(翻译组件): 修复语言比较和选择逻辑错误

修复语言比较时直接比较对象而非langCode的问题
更新Select组件使用langCode作为值并正确处理语言切换

* refactor(translate): 将saveTranslateHistory参数类型从Language改为LanguageCode

* refactor(hooks): 更新useMessageOperations中的语言代码类型

将targetLanguage和sourceLanguage参数类型从string更新为LanguageCode,提高类型安全性

* docs(translate): 更新JSDoc注释以使用TypeScript类型语法

* feat(备份服务): 升级数据库版本至v8并添加迁移逻辑

添加从v7到v8的数据库迁移支持
更新翻译历史记录中的语言代码映射
优化迁移过程中的日志记录和错误处理

* fix(store): 修复目标语言迁移时的默认值处理

确保在迁移配置时将旧版语言代码正确映射到新版格式,无法映射时使用默认英语

* refactor(translate): 将语言标签从字符串改为函数以支持动态翻译

* refactor(translate): 优化翻译窗口语言选择逻辑

重构翻译窗口的目标语言选择逻辑,使用语言代码获取完整语言信息
移除冗余的Space组件,简化Select选项渲染方式

* docs(技术文档): 新增数据库设置字段文档

添加数据库设置字段的说明文档,包含翻译相关字段的类型和用途

* refactor(translate): 修改db中biDirectionLangPair存储类型

将语言代码处理统一改为存储langCode而非Language对象
修改相关代码以使用getLanguageByLangcode进行转换
更新数据库升级逻辑以兼容新格式

* docs(translate): 为getLanguageByLangcode函数添加注释说明

* fix(数据库升级): 修复升级到V8时可能出现的空值访问问题

* refactor(databases): 优化语言映射错误处理逻辑

将不必要的try-catch块替换为if条件判断

* docs(technical): 修正数据库设置文档中的类型描述

* refactor: 优化语言代码处理和变量命名

* fix(ActionTranslate): 使用langCode存储双向翻译语言对

* fix(migrate): 修复错误的迁移过程

* refactor(translate): 重构语言选项从硬编码改为动态生成

将translateLanguageOptions从硬编码的数组改为通过LanguagesEnum动态生成,提高可维护性

* fix(store): 更新持久化存储版本并修复语言映射迁移问题

将持久化存储版本从119升级到120,并修复语言代码映射迁移问题。迁移过程中将旧的语言标识转换为新的标准语言代码格式。
2025-07-07 22:08:56 +08:00
George·Dong
278fd931fb feat: object storage backup (#7791)
* chore: import opendal

* feat: 添加S3备份支持及相关设置界面

- 在IpcChannel中新增S3备份相关IPC事件,支持备份、恢复、
  列表、删除文件及连接检测
- 在ipc主进程注册对应的S3备份处理函数,集成backupManager
- 新增S3设置页面,支持配置Endpoint、Region、Bucket、AccessKey等
  参数,并提供同步和备份策略的UI控制
- 删除未使用的RemoteStorage.ts,简化代码库

提升备份功能的灵活性,支持S3作为远程存储目标

* feat(S3 Backup): 完善S3备份功能

- 支持自动备份
- 优化设置前端
- 优化备份恢复代码

* feat(i18n): add S3 storage translations

* feat(settings): 优化数据设置页面和S3设置页面UI

* feat(settings): optimize S3 settings state structure and update usage

* refactor: simplify S3 backup and restore modal logic

* feat(s3 backup): improve S3 settings defaults and modal props

* fix(i18n): optimize S3 access key translations

* feat(backup): optimize logging and progress reporting

* fix(settings): set S3 maxBackups as unlimited by default

* chore(package): restore opendal dependency in package.json

* feat(backup): migrate S3 Backup dependency from opendal to aws-sdk

* refactor(backup): simplify S3 config handling and partial updates

* refactor(backup): update Nutstore sync state to use RemoteSyncState

* feat(store): add migration 120 to initialize missing s3 settings

* feat(settings): add tooltip and help link for S3 storage

* fix(s3settings): disable backup button until all fields are set

---------

Co-authored-by: suyao <sy20010504@gmail.com>
2025-07-07 21:00:51 +08:00
Phantom
1e0f0f47fa feat(mcp): Add default args for built-in file system MCP server (#7865)
feat(mcp): 为内置文件系统MCP服务器添加允许目录参数
2025-07-07 11:05:47 +08:00
Konv Suu
942faf474b fix(MessageMenubar): use classNames function to handle className (#7903) 2025-07-07 10:53:19 +08:00
one
9fd2583fd5 refactor(CodeEditor): add blur extension, move some extensions to hooks (#7882) 2025-07-07 10:51:56 +08:00
Chen Tao
463ca6185b chore: move ocr and preprocess into knowledge folder (#7896)
chore: move ocr and preprocess into knowledge file
2025-07-07 07:29:15 +08:00
one
2c5bb5b699 chore: remove useless classnames (#7795)
* chore: remove useless classnames

* fix: respect filterIncludeUser
2025-07-07 01:46:11 +08:00
fullex
40519b48c5 fix(SelectionAssistant): overall bug fix from v1.4.8 (#7834)
* feat(SelectionService): enable toolbar visibility on all workspaces

* feat: update selection-hook to v1.0.5

* fix: show toolbar over fullscreen apps

* fix(SelectionService): adjust macOS window type handling for fullscreen apps
2025-07-06 23:41:20 +08:00
自由的世界人
7f8ad88c06 feat: add show/hide toggle for API keys in settings (#7883)
* feat: add show/hide toggle for API keys in settings

Introduces an eye icon button to toggle visibility of API keys and tokens in Joplin, Notion, Siyuan, and Yuque settings pages. Refactors input fields to allow users to view or hide sensitive credentials, improving usability and security. Also updates translation keys in AgentsSubscribeUrlSettings for consistency.

* refactor: settings pages to use Input.Password for tokens

Replaced custom password visibility toggles and related styled-components with Ant Design's Input.Password component in Joplin, Notion, Siyuan, and Yuque settings pages. This simplifies the codebase and improves consistency in handling sensitive input fields.

* fix: Improve layout of token input fields in settings

Wrapped token/password input and check button pairs in Joplin, Notion, and Siyuan settings with Ant Design's Space.Compact for better alignment and consistent UI.

* fix: trigger token change handler on blur in settings

Added onBlur event handlers to the Joplin and Siyuan token input fields to ensure token changes are handled when the input loses focus, improving reliability of token updates.
2025-07-06 21:37:17 +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
351 changed files with 33011 additions and 19390 deletions

View File

@@ -1,9 +1,9 @@
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true

2
.git-blame-ignore-revs Normal file
View File

@@ -0,0 +1,2 @@
# ignore #7923 eol change and code formatting
4ac8a388347ff35f34de42c3ef4a2f81f03fb3b1

1
.gitattributes vendored
View File

@@ -1,2 +1,3 @@
* text=auto eol=lf
/.yarn/** linguist-vendored
/.yarn/releases/* binary

View File

@@ -73,4 +73,4 @@ body:
id: additional
attributes:
label: 附加信息
description: 任何能让我们对您的问题有更多了解的信息,包括截图或相关链接
description: 任何能让我们对您的问题有更多了解的信息,包括截图或相关链接

View File

@@ -73,4 +73,4 @@ body:
id: additional
attributes:
label: Additional Information
description: Any other information that could help us better understand your question, including screenshots or relevant links
description: Any other information that could help us better understand your question, including screenshots or relevant links

View File

@@ -9,115 +9,115 @@ labels:
# skips and removes
- name: skip all
content:
regexes: "[Ss]kip (?:[Aa]ll |)[Ll]abels?"
regexes: '[Ss]kip (?:[Aa]ll |)[Ll]abels?'
- name: remove all
content:
regexes: "[Rr]emove (?:[Aa]ll |)[Ll]abels?"
regexes: '[Rr]emove (?:[Aa]ll |)[Ll]abels?'
- name: skip kind/bug
content:
regexes: "[Ss]kip (?:[Ll]abels? |)(?:`|)kind/bug(?:`|)"
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)kind/bug(?:`|)'
- name: remove kind/bug
content:
regexes: "[Rr]emove (?:[Ll]abels? |)(?:`|)kind/bug(?:`|)"
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)kind/bug(?:`|)'
- name: skip kind/enhancement
content:
regexes: "[Ss]kip (?:[Ll]abels? |)(?:`|)kind/enhancement(?:`|)"
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)kind/enhancement(?:`|)'
- name: remove kind/enhancement
content:
regexes: "[Rr]emove (?:[Ll]abels? |)(?:`|)kind/enhancement(?:`|)"
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)kind/enhancement(?:`|)'
- name: skip kind/question
content:
regexes: "[Ss]kip (?:[Ll]abels? |)(?:`|)kind/question(?:`|)"
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)kind/question(?:`|)'
- name: remove kind/question
content:
regexes: "[Rr]emove (?:[Ll]abels? |)(?:`|)kind/question(?:`|)"
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)kind/question(?:`|)'
- name: skip area/Connectivity
content:
regexes: "[Ss]kip (?:[Ll]abels? |)(?:`|)area/Connectivity(?:`|)"
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)area/Connectivity(?:`|)'
- name: remove area/Connectivity
content:
regexes: "[Rr]emove (?:[Ll]abels? |)(?:`|)area/Connectivity(?:`|)"
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)area/Connectivity(?:`|)'
- name: skip area/UI/UX
content:
regexes: "[Ss]kip (?:[Ll]abels? |)(?:`|)area/UI/UX(?:`|)"
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)area/UI/UX(?:`|)'
- name: remove area/UI/UX
content:
regexes: "[Rr]emove (?:[Ll]abels? |)(?:`|)area/UI/UX(?:`|)"
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)area/UI/UX(?:`|)'
- name: skip kind/documentation
content:
regexes: "[Ss]kip (?:[Ll]abels? |)(?:`|)kind/documentation(?:`|)"
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)kind/documentation(?:`|)'
- name: remove kind/documentation
content:
regexes: "[Rr]emove (?:[Ll]abels? |)(?:`|)kind/documentation(?:`|)"
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)kind/documentation(?:`|)'
- name: skip client:linux
content:
regexes: "[Ss]kip (?:[Ll]abels? |)(?:`|)client:linux(?:`|)"
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)client:linux(?:`|)'
- name: remove client:linux
content:
regexes: "[Rr]emove (?:[Ll]abels? |)(?:`|)client:linux(?:`|)"
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)client:linux(?:`|)'
- name: skip client:mac
content:
regexes: "[Ss]kip (?:[Ll]abels? |)(?:`|)client:mac(?:`|)"
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)client:mac(?:`|)'
- name: remove client:mac
content:
regexes: "[Rr]emove (?:[Ll]abels? |)(?:`|)client:mac(?:`|)"
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)client:mac(?:`|)'
- name: skip client:win
content:
regexes: "[Ss]kip (?:[Ll]abels? |)(?:`|)client:win(?:`|)"
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)client:win(?:`|)'
- name: remove client:win
content:
regexes: "[Rr]emove (?:[Ll]abels? |)(?:`|)client:win(?:`|)"
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)client:win(?:`|)'
- name: skip sig/Assistant
content:
regexes: "[Ss]kip (?:[Ll]abels? |)(?:`|)sig/Assistant(?:`|)"
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)sig/Assistant(?:`|)'
- name: remove sig/Assistant
content:
regexes: "[Rr]emove (?:[Ll]abels? |)(?:`|)sig/Assistant(?:`|)"
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)sig/Assistant(?:`|)'
- name: skip sig/Data
content:
regexes: "[Ss]kip (?:[Ll]abels? |)(?:`|)sig/Data(?:`|)"
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)sig/Data(?:`|)'
- name: remove sig/Data
content:
regexes: "[Rr]emove (?:[Ll]abels? |)(?:`|)sig/Data(?:`|)"
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)sig/Data(?:`|)'
- name: skip sig/MCP
content:
regexes: "[Ss]kip (?:[Ll]abels? |)(?:`|)sig/MCP(?:`|)"
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)sig/MCP(?:`|)'
- name: remove sig/MCP
content:
regexes: "[Rr]emove (?:[Ll]abels? |)(?:`|)sig/MCP(?:`|)"
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)sig/MCP(?:`|)'
- name: skip sig/RAG
content:
regexes: "[Ss]kip (?:[Ll]abels? |)(?:`|)sig/RAG(?:`|)"
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)sig/RAG(?:`|)'
- name: remove sig/RAG
content:
regexes: "[Rr]emove (?:[Ll]abels? |)(?:`|)sig/RAG(?:`|)"
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)sig/RAG(?:`|)'
- name: skip lgtm
content:
regexes: "[Ss]kip (?:[Ll]abels? |)(?:`|)lgtm(?:`|)"
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)lgtm(?:`|)'
- name: remove lgtm
content:
regexes: "[Rr]emove (?:[Ll]abels? |)(?:`|)lgtm(?:`|)"
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)lgtm(?:`|)'
- name: skip License
content:
regexes: "[Ss]kip (?:[Ll]abels? |)(?:`|)License(?:`|)"
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)License(?:`|)'
- name: remove License
content:
regexes: "[Rr]emove (?:[Ll]abels? |)(?:`|)License(?:`|)"
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)License(?:`|)'
# `Dev Team`
- name: Dev Team
@@ -129,7 +129,7 @@ labels:
# Area labels
- name: area/Connectivity
content: area/Connectivity
regexes: "代理|[Pp]roxy"
regexes: '代理|[Pp]roxy'
skip-if:
- skip all
- skip area/Connectivity
@@ -139,7 +139,7 @@ labels:
- name: area/UI/UX
content: area/UI/UX
regexes: "界面|[Uu][Ii]|重叠|按钮|图标|组件|渲染|菜单|栏目|头像|主题|样式|[Cc][Ss][Ss]"
regexes: '界面|[Uu][Ii]|重叠|按钮|图标|组件|渲染|菜单|栏目|头像|主题|样式|[Cc][Ss][Ss]'
skip-if:
- skip all
- skip area/UI/UX
@@ -150,7 +150,7 @@ labels:
# Kind labels
- name: kind/documentation
content: kind/documentation
regexes: "文档|教程|[Dd]oc(s|umentation)|[Rr]eadme"
regexes: '文档|教程|[Dd]oc(s|umentation)|[Rr]eadme'
skip-if:
- skip all
- skip kind/documentation
@@ -161,7 +161,7 @@ labels:
# Client labels
- name: client:linux
content: client:linux
regexes: "(?:[Ll]inux|[Uu]buntu|[Dd]ebian)"
regexes: '(?:[Ll]inux|[Uu]buntu|[Dd]ebian)'
skip-if:
- skip all
- skip client:linux
@@ -171,7 +171,7 @@ labels:
- name: client:mac
content: client:mac
regexes: "(?:[Mm]ac|[Mm]acOS|[Oo]SX)"
regexes: '(?:[Mm]ac|[Mm]acOS|[Oo]SX)'
skip-if:
- skip all
- skip client:mac
@@ -181,7 +181,7 @@ labels:
- name: client:win
content: client:win
regexes: "(?:[Ww]in|[Ww]indows)"
regexes: '(?:[Ww]in|[Ww]indows)'
skip-if:
- skip all
- skip client:win
@@ -192,7 +192,7 @@ labels:
# SIG labels
- name: sig/Assistant
content: sig/Assistant
regexes: "快捷助手|[Aa]ssistant"
regexes: '快捷助手|[Aa]ssistant'
skip-if:
- skip all
- skip sig/Assistant
@@ -202,7 +202,7 @@ labels:
- name: sig/Data
content: sig/Data
regexes: "[Ww]ebdav|坚果云|备份|同步|数据|Obsidian|Notion|Joplin|思源"
regexes: '[Ww]ebdav|坚果云|备份|同步|数据|Obsidian|Notion|Joplin|思源'
skip-if:
- skip all
- skip sig/Data
@@ -212,7 +212,7 @@ labels:
- name: sig/MCP
content: sig/MCP
regexes: "[Mm][Cc][Pp]"
regexes: '[Mm][Cc][Pp]'
skip-if:
- skip all
- skip sig/MCP
@@ -222,7 +222,7 @@ labels:
- name: sig/RAG
content: sig/RAG
regexes: "知识库|[Rr][Aa][Gg]"
regexes: '知识库|[Rr][Aa][Gg]'
skip-if:
- skip all
- skip sig/RAG
@@ -233,7 +233,7 @@ labels:
# Other labels
- name: lgtm
content: lgtm
regexes: "(?:[Ll][Gg][Tt][Mm]|[Ll]ooks [Gg]ood [Tt]o [Mm]e)"
regexes: '(?:[Ll][Gg][Tt][Mm]|[Ll]ooks [Gg]ood [Tt]o [Mm]e)'
skip-if:
- skip all
- skip lgtm
@@ -243,7 +243,7 @@ labels:
- name: License
content: License
regexes: "(?:[Ll]icense|[Cc]opyright|[Mm][Ii][Tt]|[Aa]pache)"
regexes: '(?:[Ll]icense|[Cc]opyright|[Mm][Ii][Tt]|[Aa]pache)'
skip-if:
- skip all
- skip License

View File

@@ -1,4 +1,4 @@
name: "Issue Checker"
name: 'Issue Checker'
on:
issues:
@@ -19,7 +19,7 @@ jobs:
steps:
- uses: MaaAssistantArknights/issue-checker@v1.14
with:
repo-token: "${{ secrets.GITHUB_TOKEN }}"
repo-token: '${{ secrets.GITHUB_TOKEN }}'
configuration-path: .github/issue-checker.yml
not-before: 2022-08-05T00:00:00Z
include-title: 1
include-title: 1

View File

@@ -1,8 +1,8 @@
name: "Stale Issue Management"
name: 'Stale Issue Management'
on:
schedule:
- cron: "0 0 * * *"
- cron: '0 0 * * *'
workflow_dispatch:
env:
@@ -24,18 +24,18 @@ jobs:
uses: actions/stale@v9
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
only-labels: "needs-more-info"
only-labels: 'needs-more-info'
days-before-stale: ${{ env.daysBeforeStale }}
days-before-close: 0 # Close immediately after stale
stale-issue-label: "inactive"
close-issue-label: "closed:no-response"
days-before-close: 0 # Close immediately after stale
stale-issue-label: 'inactive'
close-issue-label: 'closed:no-response'
stale-issue-message: |
This issue has been labeled as needing more information and has been inactive for ${{ env.daysBeforeStale }} days.
It will be closed now due to lack of additional information.
该问题被标记为"需要更多信息"且已经 ${{ env.daysBeforeStale }} 天没有任何活动,将立即关闭。
operations-per-run: 50
exempt-issue-labels: "pending, Dev Team"
exempt-issue-labels: 'pending, Dev Team'
days-before-pr-stale: -1
days-before-pr-close: -1
@@ -45,11 +45,11 @@ jobs:
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-stale: ${{ env.daysBeforeStale }}
days-before-close: ${{ env.daysBeforeClose }}
stale-issue-label: "inactive"
stale-issue-label: 'inactive'
stale-issue-message: |
This issue has been inactive for a prolonged period and will be closed automatically in ${{ env.daysBeforeClose }} days.
该问题已长时间处于闲置状态,${{ env.daysBeforeClose }} 天后将自动关闭。
exempt-issue-labels: "pending, Dev Team, kind/enhancement"
exempt-issue-labels: 'pending, Dev Team, kind/enhancement'
days-before-pr-stale: -1 # Completely disable stalling for PRs
days-before-pr-close: -1 # Completely disable closing for PRs

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
@@ -117,4 +120,4 @@ jobs:
makeLatest: false
tag: ${{ steps.get-tag.outputs.tag }}
artifacts: 'dist/*.exe,dist/*.zip,dist/*.dmg,dist/*.AppImage,dist/*.snap,dist/*.deb,dist/*.rpm,dist/*.tar.gz,dist/latest*.yml,dist/rc*.yml,dist/*.blockmap'
token: ${{ secrets.GITHUB_TOKEN }}
token: ${{ secrets.GITHUB_TOKEN }}

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,11 @@
# 数据库设置字段
此文档包含部分字段的数据类型说明。
## 字段
| 字段名 | 类型 | 说明 |
| ------------------------------ | ------------------------------ | ------------ |
| `translate:target:language` | `LanguageCode` | 翻译目标语言 |
| `translate:source:language` | `LanguageCode` | 翻译源语言 |
| `translate:bidirectional:pair` | `[LanguageCode, LanguageCode]` | 双向翻译对 |

View File

@@ -117,9 +117,8 @@ afterSign: scripts/notarize.js
artifactBuildCompleted: scripts/artifact-build-completed.js
releaseInfo:
releaseNotes: |
划词助手:支持 macOS 系统
文档处理:增加 MinerU、Doc2xMistral 等服务商支持
知识库:新的知识库界面,增加扫描版 PDF 支持
OCRmacOS 增加系统 OCR 支持
服务商:支持一键添加服务商,新增 PH8 大模型开放平台, 支持 PPIO OAuth 登录
修复Linux下数据目录移动问题
• [新增] MCP 工具调用自动审批流程
• [优化] 输入框快捷弹窗多选交互支持
• [新增] 网页内容生成实时预览功能
• [支持] Grok-4 大语言模型接入
• [修复] Anthropic 模型输出截断缺陷

View File

@@ -26,7 +26,7 @@ export default defineConfig([
'simple-import-sort/exports': 'error',
'unused-imports/no-unused-imports': 'error',
'@eslint-react/no-prop-types': 'error',
'prettier/prettier': ['error', { endOfLine: 'auto' }]
'prettier/prettier': ['error']
}
},
// Configuration for ensuring compatibility with the original ESLint(8.x) rules

View File

@@ -1,6 +1,6 @@
{
"name": "CherryStudio",
"version": "1.4.8",
"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",
@@ -55,20 +55,23 @@
"test:lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts",
"format": "prettier --write .",
"lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
"prepare": "husky"
"prepare": "git config blame.ignoreRevsFile .git-blame-ignore-revs && husky"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.840.0",
"@cherrystudio/pdf-to-img-napi": "^0.0.1",
"@libsql/client": "0.14.0",
"@libsql/win32-x64-msvc": "^0.4.7",
"@strongtz/win32-arm64-msvc": "^0.4.7",
"iconv-lite": "^0.6.3",
"jschardet": "^3.1.4",
"jsdom": "26.1.0",
"macos-release": "^3.4.0",
"node-stream-zip": "^1.15.0",
"notion-helper": "^1.3.22",
"os-proxy-config": "^1.1.2",
"pdfjs-dist": "4.10.38",
"selection-hook": "^1.0.4",
"selection-hook": "^1.0.6",
"turndown": "7.2.0"
},
"devDependencies": {
@@ -89,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",
@@ -104,7 +108,7 @@
"@langchain/community": "^0.3.36",
"@langchain/ollama": "^0.2.1",
"@mistralai/mistralai": "^1.6.0",
"@modelcontextprotocol/sdk": "^1.11.4",
"@modelcontextprotocol/sdk": "^1.12.3",
"@mozilla/readability": "^0.6.0",
"@notionhq/client": "^2.2.15",
"@playwright/test": "^1.52.0",
@@ -138,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",
@@ -170,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",
@@ -200,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",
@@ -222,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

@@ -74,6 +74,8 @@ export enum IpcChannel {
Mcp_ServersChanged = 'mcp:servers-changed',
Mcp_ServersUpdated = 'mcp:servers-updated',
Mcp_CheckConnectivity = 'mcp:check-connectivity',
Mcp_SetProgress = 'mcp:set-progress',
Mcp_AbortTool = 'mcp:abort-tool',
// Python
Python_Execute = 'python:execute',
@@ -145,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',
@@ -165,6 +168,16 @@ export enum IpcChannel {
Backup_CheckConnection = 'backup:checkConnection',
Backup_CreateDirectory = 'backup:createDirectory',
Backup_DeleteWebdavFile = 'backup:deleteWebdavFile',
Backup_BackupToLocalDir = 'backup:backupToLocalDir',
Backup_RestoreFromLocalBackup = 'backup:restoreFromLocalBackup',
Backup_ListLocalBackupFiles = 'backup:listLocalBackupFiles',
Backup_DeleteLocalBackupFile = 'backup:deleteLocalBackupFile',
Backup_SetLocalBackupDir = 'backup:setLocalBackupDir',
Backup_BackupToS3 = 'backup:backupToS3',
Backup_RestoreFromS3 = 'backup:restoreFromS3',
Backup_ListS3Files = 'backup:listS3Files',
Backup_DeleteS3File = 'backup:deleteS3File',
Backup_CheckS3Connection = 'backup:checkS3Connection',
// zip
Zip_Compress = 'zip:compress',
@@ -229,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

@@ -12,8 +12,10 @@ import { BrowserWindow, dialog, ipcMain, session, shell, systemPreferences, webC
import log from 'electron-log'
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'
@@ -29,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'
@@ -114,12 +117,8 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
})
// launch on boot
ipcMain.handle(IpcChannel.App_SetLaunchOnBoot, (_, openAtLogin: boolean) => {
// Set login item settings for windows and mac
// linux is not supported because it requires more file operations
if (isWin || isMac) {
app.setLoginItemSettings({ openAtLogin })
}
ipcMain.handle(IpcChannel.App_SetLaunchOnBoot, (_, isLaunchOnBoot: boolean) => {
appService.setAppLaunchOnBoot(isLaunchOnBoot)
})
// launch to tray
@@ -368,6 +367,16 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.Backup_CheckConnection, backupManager.checkConnection)
ipcMain.handle(IpcChannel.Backup_CreateDirectory, backupManager.createDirectory)
ipcMain.handle(IpcChannel.Backup_DeleteWebdavFile, backupManager.deleteWebdavFile)
ipcMain.handle(IpcChannel.Backup_BackupToLocalDir, backupManager.backupToLocalDir)
ipcMain.handle(IpcChannel.Backup_RestoreFromLocalBackup, backupManager.restoreFromLocalBackup)
ipcMain.handle(IpcChannel.Backup_ListLocalBackupFiles, backupManager.listLocalBackupFiles)
ipcMain.handle(IpcChannel.Backup_DeleteLocalBackupFile, backupManager.deleteLocalBackupFile)
ipcMain.handle(IpcChannel.Backup_SetLocalBackupDir, backupManager.setLocalBackupDir)
ipcMain.handle(IpcChannel.Backup_BackupToS3, backupManager.backupToS3)
ipcMain.handle(IpcChannel.Backup_RestoreFromS3, backupManager.restoreFromS3)
ipcMain.handle(IpcChannel.Backup_ListS3Files, backupManager.listS3Files)
ipcMain.handle(IpcChannel.Backup_DeleteS3File, backupManager.deleteS3File)
ipcMain.handle(IpcChannel.Backup_CheckS3Connection, backupManager.checkS3Connection)
// file
ipcMain.handle(IpcChannel.File_Open, fileManager.open)
@@ -392,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) => {
@@ -494,6 +504,10 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.Mcp_GetResource, mcpService.getResource)
ipcMain.handle(IpcChannel.Mcp_GetInstallInfo, mcpService.getInstallInfo)
ipcMain.handle(IpcChannel.Mcp_CheckConnectivity, mcpService.checkMcpConnectivity)
ipcMain.handle(IpcChannel.Mcp_AbortTool, mcpService.abortTool)
ipcMain.handle(IpcChannel.Mcp_SetProgress, (_, progress: number) => {
mainWindow.webContents.send('mcp-progress', progress)
})
// Register Python execution handler
ipcMain.handle(
@@ -565,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

@@ -1,8 +1,7 @@
import * as fs from 'node:fs'
import { JsonLoader, LocalPathLoader, RAGApplication, TextLoader } from '@cherrystudio/embedjs'
import type { AddLoaderReturn } from '@cherrystudio/embedjs-interfaces'
import { WebLoader } from '@cherrystudio/embedjs-loader-web'
import { readTextFileWithAutoEncoding } from '@main/utils/file'
import { LoaderReturn } from '@shared/config/types'
import { FileMetadata, KnowledgeBaseParams } from '@types'
import Logger from 'electron-log'
@@ -115,7 +114,7 @@ export async function addFileLoader(
// HTML类型处理
loaderReturn = await ragApplication.addLoader(
new WebLoader({
urlOrContent: fs.readFileSync(file.path, 'utf-8'),
urlOrContent: await readTextFileWithAutoEncoding(file.path),
chunkSize: base.chunkSize,
chunkOverlap: base.chunkOverlap
}) as any,
@@ -125,7 +124,7 @@ export async function addFileLoader(
case 'json':
try {
jsonObject = JSON.parse(fs.readFileSync(file.path, 'utf-8'))
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)
@@ -141,7 +140,7 @@ export async function addFileLoader(
// 如果是其他文本类型且尚未读取文件,则读取文件
loaderReturn = await ragApplication.addLoader(
new TextLoader({
text: fs.readFileSync(file.path, 'utf-8'),
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

@@ -0,0 +1,81 @@
import { isDev, isLinux, isMac, isWin } from '@main/constant'
import { app } from 'electron'
import log from 'electron-log'
import fs from 'fs'
import os from 'os'
import path from 'path'
export class AppService {
private static instance: AppService
private constructor() {
// Private constructor to prevent direct instantiation
}
public static getInstance(): AppService {
if (!AppService.instance) {
AppService.instance = new AppService()
}
return AppService.instance
}
public async setAppLaunchOnBoot(isLaunchOnBoot: boolean): Promise<void> {
// Set login item settings for windows and mac
// linux is not supported because it requires more file operations
if (isWin || isMac) {
app.setLoginItemSettings({ openAtLogin: isLaunchOnBoot })
} else if (isLinux) {
try {
const autostartDir = path.join(os.homedir(), '.config', 'autostart')
const desktopFile = path.join(autostartDir, isDev ? 'cherry-studio-dev.desktop' : 'cherry-studio.desktop')
if (isLaunchOnBoot) {
// Ensure autostart directory exists
try {
await fs.promises.access(autostartDir)
} catch {
await fs.promises.mkdir(autostartDir, { recursive: true })
}
// Get executable path
let executablePath = app.getPath('exe')
if (process.env.APPIMAGE) {
// For AppImage packaged apps, use APPIMAGE environment variable
executablePath = process.env.APPIMAGE
}
// Create desktop file content
const desktopContent = `[Desktop Entry]
Type=Application
Name=Cherry Studio
Comment=A powerful AI assistant for producer.
Exec=${executablePath}
Icon=cherrystudio
Terminal=false
StartupNotify=false
Categories=Development;Utility;
X-GNOME-Autostart-enabled=true
Hidden=false`
// Write desktop file
await fs.promises.writeFile(desktopFile, desktopContent)
log.info('Created autostart desktop file for Linux')
} else {
// Remove desktop file
try {
await fs.promises.access(desktopFile)
await fs.promises.unlink(desktopFile)
log.info('Removed autostart desktop file for Linux')
} catch {
// File doesn't exist, no need to remove
}
}
} catch (error) {
log.error('Failed to set launch on boot for Linux:', error)
}
}
}
}
// Default export as singleton instance
export default AppService.getInstance()

View File

@@ -1,5 +1,6 @@
import { IpcChannel } from '@shared/IpcChannel'
import { WebDavConfig } from '@types'
import { S3Config } from '@types'
import archiver from 'archiver'
import { exec } from 'child_process'
import { app } from 'electron'
@@ -10,6 +11,7 @@ import * as path from 'path'
import { CreateDirectoryOptions, FileStat } from 'webdav'
import { getDataPath } from '../utils'
import S3Storage from './S3Storage'
import WebDav from './WebDav'
import { windowService } from './WindowService'
@@ -25,6 +27,16 @@ class BackupManager {
this.restoreFromWebdav = this.restoreFromWebdav.bind(this)
this.listWebdavFiles = this.listWebdavFiles.bind(this)
this.deleteWebdavFile = this.deleteWebdavFile.bind(this)
this.listLocalBackupFiles = this.listLocalBackupFiles.bind(this)
this.deleteLocalBackupFile = this.deleteLocalBackupFile.bind(this)
this.backupToLocalDir = this.backupToLocalDir.bind(this)
this.restoreFromLocalBackup = this.restoreFromLocalBackup.bind(this)
this.setLocalBackupDir = this.setLocalBackupDir.bind(this)
this.backupToS3 = this.backupToS3.bind(this)
this.restoreFromS3 = this.restoreFromS3.bind(this)
this.listS3Files = this.listS3Files.bind(this)
this.deleteS3File = this.deleteS3File.bind(this)
this.checkS3Connection = this.checkS3Connection.bind(this)
}
private async setWritableRecursive(dirPath: string): Promise<void> {
@@ -85,7 +97,11 @@ class BackupManager {
const onProgress = (processData: { stage: string; progress: number; total: number }) => {
mainWindow?.webContents.send(IpcChannel.BackupProgress, processData)
Logger.log('[BackupManager] backup progress', processData)
// 只在关键阶段记录日志:开始、结束和主要阶段转换点
const logStages = ['preparing', 'writing_data', 'preparing_compression', 'completed']
if (logStages.includes(processData.stage) || processData.progress === 100) {
Logger.log('[BackupManager] backup progress', processData)
}
}
try {
@@ -147,18 +163,23 @@ class BackupManager {
let totalBytes = 0
let processedBytes = 0
// 首先计算总文件数和总大小
// 首先计算总文件数和总大小,但不记录详细日志
const calculateTotals = async (dirPath: string) => {
const items = await fs.readdir(dirPath, { withFileTypes: true })
for (const item of items) {
const fullPath = path.join(dirPath, item.name)
if (item.isDirectory()) {
await calculateTotals(fullPath)
} else {
totalEntries++
const stats = await fs.stat(fullPath)
totalBytes += stats.size
try {
const items = await fs.readdir(dirPath, { withFileTypes: true })
for (const item of items) {
const fullPath = path.join(dirPath, item.name)
if (item.isDirectory()) {
await calculateTotals(fullPath)
} else {
totalEntries++
const stats = await fs.stat(fullPath)
totalBytes += stats.size
}
}
} catch (error) {
// 仅在出错时记录日志
Logger.error('[BackupManager] Error calculating totals:', error)
}
}
@@ -230,7 +251,11 @@ class BackupManager {
const onProgress = (processData: { stage: string; progress: number; total: number }) => {
mainWindow?.webContents.send(IpcChannel.RestoreProgress, processData)
Logger.log('[BackupManager] restore progress', processData)
// 只在关键阶段记录日志
const logStages = ['preparing', 'extracting', 'extracted', 'reading_data', 'completed']
if (logStages.includes(processData.stage) || processData.progress === 100) {
Logger.log('[BackupManager] restore progress', processData)
}
}
try {
@@ -382,21 +407,54 @@ class BackupManager {
destination: string,
onProgress: (size: number) => void
): Promise<void> {
const items = await fs.readdir(source, { withFileTypes: true })
// 先统计总文件数
let totalFiles = 0
let processedFiles = 0
let lastProgressReported = 0
for (const item of items) {
const sourcePath = path.join(source, item.name)
const destPath = path.join(destination, item.name)
// 计算总文件数
const countFiles = async (dir: string): Promise<number> => {
let count = 0
const items = await fs.readdir(dir, { withFileTypes: true })
for (const item of items) {
if (item.isDirectory()) {
count += await countFiles(path.join(dir, item.name))
} else {
count++
}
}
return count
}
if (item.isDirectory()) {
await fs.ensureDir(destPath)
await this.copyDirWithProgress(sourcePath, destPath, onProgress)
} else {
const stats = await fs.stat(sourcePath)
await fs.copy(sourcePath, destPath)
onProgress(stats.size)
totalFiles = await countFiles(source)
// 复制文件并更新进度
const copyDir = async (src: string, dest: string): Promise<void> => {
const items = await fs.readdir(src, { withFileTypes: true })
for (const item of items) {
const sourcePath = path.join(src, item.name)
const destPath = path.join(dest, item.name)
if (item.isDirectory()) {
await fs.ensureDir(destPath)
await copyDir(sourcePath, destPath)
} else {
const stats = await fs.stat(sourcePath)
await fs.copy(sourcePath, destPath)
processedFiles++
// 只在进度变化超过5%时报告进度
const currentProgress = Math.floor((processedFiles / totalFiles) * 100)
if (currentProgress - lastProgressReported >= 5 || processedFiles === totalFiles) {
lastProgressReported = currentProgress
onProgress(stats.size)
}
}
}
}
await copyDir(source, destination)
}
async checkConnection(_: Electron.IpcMainInvokeEvent, webdavConfig: WebDavConfig) {
@@ -423,6 +481,191 @@ class BackupManager {
throw new Error(error.message || 'Failed to delete backup file')
}
}
async backupToLocalDir(
_: Electron.IpcMainInvokeEvent,
data: string,
fileName: string,
localConfig: {
localBackupDir: string
skipBackupFile: boolean
}
) {
try {
const backupDir = localConfig.localBackupDir
// Create backup directory if it doesn't exist
await fs.ensureDir(backupDir)
const backupedFilePath = await this.backup(_, fileName, data, backupDir, localConfig.skipBackupFile)
return backupedFilePath
} catch (error) {
Logger.error('[BackupManager] Local backup failed:', error)
throw error
}
}
async backupToS3(_: Electron.IpcMainInvokeEvent, data: string, s3Config: S3Config) {
const os = require('os')
const deviceName = os.hostname ? os.hostname() : 'device'
const timestamp = new Date()
.toISOString()
.replace(/[-:T.Z]/g, '')
.slice(0, 14)
const filename = s3Config.fileName || `cherry-studio.backup.${deviceName}.${timestamp}.zip`
Logger.log(`[BackupManager] Starting S3 backup to ${filename}`)
const backupedFilePath = await this.backup(_, filename, data, undefined, s3Config.skipBackupFile)
const s3Client = new S3Storage(s3Config)
try {
const fileBuffer = await fs.promises.readFile(backupedFilePath)
const result = await s3Client.putFileContents(filename, fileBuffer)
await fs.remove(backupedFilePath)
Logger.log(`[BackupManager] S3 backup completed successfully: ${filename}`)
return result
} catch (error) {
Logger.error(`[BackupManager] S3 backup failed:`, error)
await fs.remove(backupedFilePath)
throw error
}
}
async restoreFromLocalBackup(_: Electron.IpcMainInvokeEvent, fileName: string, localBackupDir: string) {
try {
const backupDir = localBackupDir
const backupPath = path.join(backupDir, fileName)
if (!fs.existsSync(backupPath)) {
throw new Error(`Backup file not found: ${backupPath}`)
}
return await this.restore(_, backupPath)
} catch (error) {
Logger.error('[BackupManager] Local restore failed:', error)
throw error
}
}
async listLocalBackupFiles(_: Electron.IpcMainInvokeEvent, localBackupDir: string) {
try {
const files = await fs.readdir(localBackupDir)
const result: Array<{ fileName: string; modifiedTime: string; size: number }> = []
for (const file of files) {
const filePath = path.join(localBackupDir, file)
const stat = await fs.stat(filePath)
if (stat.isFile() && file.endsWith('.zip')) {
result.push({
fileName: file,
modifiedTime: stat.mtime.toISOString(),
size: stat.size
})
}
}
// Sort by modified time, newest first
return result.sort((a, b) => new Date(b.modifiedTime).getTime() - new Date(a.modifiedTime).getTime())
} catch (error) {
Logger.error('[BackupManager] List local backup files failed:', error)
throw error
}
}
async deleteLocalBackupFile(_: Electron.IpcMainInvokeEvent, fileName: string, localBackupDir: string) {
try {
const filePath = path.join(localBackupDir, fileName)
if (!fs.existsSync(filePath)) {
throw new Error(`Backup file not found: ${filePath}`)
}
await fs.remove(filePath)
return true
} catch (error) {
Logger.error('[BackupManager] Delete local backup file failed:', error)
throw error
}
}
async setLocalBackupDir(_: Electron.IpcMainInvokeEvent, dirPath: string) {
try {
// Check if directory exists
await fs.ensureDir(dirPath)
return true
} catch (error) {
Logger.error('[BackupManager] Set local backup directory failed:', error)
throw error
}
}
async restoreFromS3(_: Electron.IpcMainInvokeEvent, s3Config: S3Config) {
const filename = s3Config.fileName || 'cherry-studio.backup.zip'
Logger.log(`[BackupManager] Starting restore from S3: ${filename}`)
const s3Client = new S3Storage(s3Config)
try {
const retrievedFile = await s3Client.getFileContents(filename)
const backupedFilePath = path.join(this.backupDir, filename)
if (!fs.existsSync(this.backupDir)) {
fs.mkdirSync(this.backupDir, { recursive: true })
}
await new Promise<void>((resolve, reject) => {
const writeStream = fs.createWriteStream(backupedFilePath)
writeStream.write(retrievedFile as Buffer)
writeStream.end()
writeStream.on('finish', () => resolve())
writeStream.on('error', (error) => reject(error))
})
Logger.log(`[BackupManager] S3 restore file downloaded successfully: ${filename}`)
return await this.restore(_, backupedFilePath)
} catch (error: any) {
Logger.error('[BackupManager] Failed to restore from S3:', error)
throw new Error(error.message || 'Failed to restore backup file')
}
}
listS3Files = async (_: Electron.IpcMainInvokeEvent, s3Config: S3Config) => {
try {
const s3Client = new S3Storage(s3Config)
const objects = await s3Client.listFiles()
const files = objects
.filter((obj) => obj.key.endsWith('.zip'))
.map((obj) => {
const segments = obj.key.split('/')
const fileName = segments[segments.length - 1]
return {
fileName,
modifiedTime: obj.lastModified || '',
size: obj.size
}
})
return files.sort((a, b) => new Date(b.modifiedTime).getTime() - new Date(a.modifiedTime).getTime())
} catch (error: any) {
Logger.error('Failed to list S3 files:', error)
throw new Error(error.message || 'Failed to list backup files')
}
}
async deleteS3File(_: Electron.IpcMainInvokeEvent, fileName: string, s3Config: S3Config) {
try {
const s3Client = new S3Storage(s3Config)
return await s3Client.deleteFile(fileName)
} catch (error: any) {
Logger.error('Failed to delete S3 file:', error)
throw new Error(error.message || 'Failed to delete backup file')
}
}
async checkS3Connection(_: Electron.IpcMainInvokeEvent, s3Config: S3Config) {
const s3Client = new S3Storage(s3Config)
return await s3Client.checkConnection()
}
}
export default BackupManager

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

@@ -1,4 +1,4 @@
import { getFilesDir, getFileType, getTempDir } from '@main/utils/file'
import { getFilesDir, getFileType, getTempDir, readTextFileWithAutoEncoding } from '@main/utils/file'
import { documentExts, imageExts, MB } from '@shared/config/constant'
import { FileMetadata } from '@types'
import * as crypto from 'crypto'
@@ -188,6 +188,8 @@ class FileStorage {
count: 1
}
logger.info('[FileStorage] File uploaded:', fileMetadata)
return fileMetadata
}
@@ -229,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)
@@ -256,7 +262,16 @@ class FileStorage {
}
}
return fs.readFileSync(filePath, 'utf8')
try {
if (detectEncoding) {
return readTextFileWithAutoEncoding(filePath)
} else {
return fs.readFileSync(filePath, 'utf-8')
}
} catch (error) {
logger.error(error)
return 'failed to read file'
}
}
public createTempFile = async (_: Electron.IpcMainInvokeEvent, fileName: string): Promise<string> => {
@@ -409,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

@@ -24,9 +24,9 @@ import { WebLoader } from '@cherrystudio/embedjs-loader-web'
import Embeddings from '@main/knowledage/embeddings/Embeddings'
import { addFileLoader } from '@main/knowledage/loader'
import { NoteLoader } from '@main/knowledage/loader/noteLoader'
import OcrProvider from '@main/knowledage/ocr/OcrProvider'
import PreprocessProvider from '@main/knowledage/preprocess/PreprocessProvider'
import Reranker from '@main/knowledage/reranker/Reranker'
import OcrProvider from '@main/ocr/OcrProvider'
import PreprocessProvider from '@main/preprocess/PreprocessProvider'
import { windowService } from '@main/services/WindowService'
import { getDataPath } from '@main/utils'
import { getAllFiles } from '@main/utils/file'

View File

@@ -28,6 +28,7 @@ import { app } from 'electron'
import Logger from 'electron-log'
import { EventEmitter } from 'events'
import { memoize } from 'lodash'
import { v4 as uuidv4 } from 'uuid'
import { CacheService } from './CacheService'
import { CallBackServer } from './mcp/oauth/callback'
@@ -71,6 +72,7 @@ function withCache<T extends unknown[], R>(
class McpService {
private clients: Map<string, Client> = new Map()
private pendingClients: Map<string, Promise<Client>> = new Map()
private activeToolCalls: Map<string, AbortController> = new Map()
constructor() {
this.initClient = this.initClient.bind(this)
@@ -84,6 +86,7 @@ class McpService {
this.removeServer = this.removeServer.bind(this)
this.restartServer = this.restartServer.bind(this)
this.stopServer = this.stopServer.bind(this)
this.abortTool = this.abortTool.bind(this)
this.cleanup = this.cleanup.bind(this)
}
@@ -455,10 +458,14 @@ class McpService {
*/
public async callTool(
_: Electron.IpcMainInvokeEvent,
{ server, name, args }: { server: MCPServer; name: string; args: any }
{ server, name, args, callId }: { server: MCPServer; name: string; args: any; callId?: string }
): Promise<MCPCallToolResponse> {
const toolCallId = callId || uuidv4()
const abortController = new AbortController()
this.activeToolCalls.set(toolCallId, abortController)
try {
Logger.info('[MCP] Calling:', server.name, name, args)
Logger.info('[MCP] Calling:', server.name, name, args, 'callId:', toolCallId)
if (typeof args === 'string') {
try {
args = JSON.parse(args)
@@ -468,12 +475,19 @@ class McpService {
}
const client = await this.initClient(server)
const result = await client.callTool({ name, arguments: args }, undefined, {
timeout: server.timeout ? server.timeout * 1000 : 60000 // Default timeout of 1 minute
onprogress: (process) => {
console.log('[MCP] Progress:', process.progress / (process.total || 1))
window.api.mcp.setProgress(process.progress / (process.total || 1))
},
timeout: server.timeout ? server.timeout * 1000 : 60000, // Default timeout of 1 minute
signal: this.activeToolCalls.get(toolCallId)?.signal
})
return result as MCPCallToolResponse
} catch (error) {
Logger.error(`[MCP] Error calling tool ${name} on ${server.name}:`, error)
throw error
} finally {
this.activeToolCalls.delete(toolCallId)
}
}
@@ -664,6 +678,20 @@ class McpService {
delete env.http_proxy
delete env.https_proxy
}
// 实现 abortTool 方法
public async abortTool(_: Electron.IpcMainInvokeEvent, callId: string) {
const activeToolCall = this.activeToolCalls.get(callId)
if (activeToolCall) {
activeToolCall.abort()
this.activeToolCalls.delete(callId)
Logger.info(`[MCP] Aborted tool call: ${callId}`)
return true
} else {
Logger.warn(`[MCP] No active tool call found for callId: ${callId}`)
return false
}
}
}
export default new McpService()

View File

@@ -1,57 +0,0 @@
// import Logger from 'electron-log'
// import { Operator } from 'opendal'
// export default class RemoteStorage {
// public instance: Operator | undefined
// /**
// *
// * @param scheme is the scheme for opendal services. Available value includes "azblob", "azdls", "cos", "gcs", "obs", "oss", "s3", "webdav", "webhdfs", "aliyun-drive", "alluxio", "azfile", "dropbox", "gdrive", "onedrive", "postgresql", "mysql", "redis", "swift", "mongodb", "alluxio", "b2", "seafile", "upyun", "koofr", "yandex-disk"
// * @param options is the options for given opendal services. Valid options depend on the scheme. Checkout https://docs.rs/opendal/latest/opendal/services/index.html for all valid options.
// *
// * For example, use minio as remote storage:
// *
// * ```typescript
// * const storage = new RemoteStorage('s3', {
// * endpoint: 'http://localhost:9000',
// * region: 'us-east-1',
// * bucket: 'testbucket',
// * access_key_id: 'user',
// * secret_access_key: 'password',
// * root: '/path/to/basepath',
// * })
// * ```
// */
// constructor(scheme: string, options?: Record<string, string> | undefined | null) {
// this.instance = new Operator(scheme, options)
// this.putFileContents = this.putFileContents.bind(this)
// this.getFileContents = this.getFileContents.bind(this)
// }
// public putFileContents = async (filename: string, data: string | Buffer) => {
// if (!this.instance) {
// return new Error('RemoteStorage client not initialized')
// }
// try {
// return await this.instance.write(filename, data)
// } catch (error) {
// Logger.error('[RemoteStorage] Error putting file contents:', error)
// throw error
// }
// }
// public getFileContents = async (filename: string) => {
// if (!this.instance) {
// throw new Error('RemoteStorage client not initialized')
// }
// try {
// return await this.instance.read(filename)
// } catch (error) {
// Logger.error('[RemoteStorage] Error getting file contents:', error)
// throw error
// }
// }
// }

View File

@@ -0,0 +1,183 @@
import {
DeleteObjectCommand,
GetObjectCommand,
HeadBucketCommand,
ListObjectsV2Command,
PutObjectCommand,
S3Client
} from '@aws-sdk/client-s3'
import type { S3Config } from '@types'
import Logger from 'electron-log'
import * as net from 'net'
import { Readable } from 'stream'
/**
* 将可读流转换为 Buffer
*/
function streamToBuffer(stream: Readable): Promise<Buffer> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = []
stream.on('data', (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)))
stream.on('error', reject)
stream.on('end', () => resolve(Buffer.concat(chunks)))
})
}
// 需要使用 Virtual Host-Style 的服务商域名后缀白名单
const VIRTUAL_HOST_SUFFIXES = ['aliyuncs.com', 'myqcloud.com']
/**
* 使用 AWS SDK v3 的简单 S3 封装,兼容之前 RemoteStorage 的最常用接口。
*/
export default class S3Storage {
private client: S3Client
private bucket: string
private root: string
constructor(config: S3Config) {
const { endpoint, region, accessKeyId, secretAccessKey, bucket, root } = config
const usePathStyle = (() => {
if (!endpoint) return false
try {
const { hostname } = new URL(endpoint)
if (hostname === 'localhost' || net.isIP(hostname) !== 0) {
return true
}
const isInWhiteList = VIRTUAL_HOST_SUFFIXES.some((suffix) => hostname.endsWith(suffix))
return !isInWhiteList
} catch (e) {
Logger.warn('[S3Storage] Failed to parse endpoint, fallback to Path-Style:', endpoint, e)
return true
}
})()
this.client = new S3Client({
region,
endpoint: endpoint || undefined,
credentials: {
accessKeyId: accessKeyId,
secretAccessKey: secretAccessKey
},
forcePathStyle: usePathStyle
})
this.bucket = bucket
this.root = root?.replace(/^\/+/g, '').replace(/\/+$/g, '') || ''
this.putFileContents = this.putFileContents.bind(this)
this.getFileContents = this.getFileContents.bind(this)
this.deleteFile = this.deleteFile.bind(this)
this.listFiles = this.listFiles.bind(this)
this.checkConnection = this.checkConnection.bind(this)
}
/**
* 内部辅助方法,用来拼接带 root 的对象 key
*/
private buildKey(key: string): string {
if (!this.root) return key
return key.startsWith(`${this.root}/`) ? key : `${this.root}/${key}`
}
async putFileContents(key: string, data: Buffer | string) {
try {
const contentType = key.endsWith('.zip') ? 'application/zip' : 'application/octet-stream'
return await this.client.send(
new PutObjectCommand({
Bucket: this.bucket,
Key: this.buildKey(key),
Body: data,
ContentType: contentType
})
)
} catch (error) {
Logger.error('[S3Storage] Error putting object:', error)
throw error
}
}
async getFileContents(key: string): Promise<Buffer> {
try {
const res = await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: this.buildKey(key) }))
if (!res.Body || !(res.Body instanceof Readable)) {
throw new Error('Empty body received from S3')
}
return await streamToBuffer(res.Body as Readable)
} catch (error) {
Logger.error('[S3Storage] Error getting object:', error)
throw error
}
}
async deleteFile(key: string) {
try {
const keyWithRoot = this.buildKey(key)
const variations = new Set([keyWithRoot, key.replace(/^\//, '')])
for (const k of variations) {
try {
await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: k }))
} catch {
// 忽略删除失败
}
}
} catch (error) {
Logger.error('[S3Storage] Error deleting object:', error)
throw error
}
}
/**
* 列举指定前缀下的对象,默认列举全部。
*/
async listFiles(prefix = ''): Promise<Array<{ key: string; lastModified?: string; size: number }>> {
const files: Array<{ key: string; lastModified?: string; size: number }> = []
let continuationToken: string | undefined
const fullPrefix = this.buildKey(prefix)
try {
do {
const res = await this.client.send(
new ListObjectsV2Command({
Bucket: this.bucket,
Prefix: fullPrefix === '' ? undefined : fullPrefix,
ContinuationToken: continuationToken
})
)
res.Contents?.forEach((obj) => {
if (!obj.Key) return
files.push({
key: obj.Key,
lastModified: obj.LastModified?.toISOString(),
size: obj.Size ?? 0
})
})
continuationToken = res.IsTruncated ? res.NextContinuationToken : undefined
} while (continuationToken)
return files
} catch (error) {
Logger.error('[S3Storage] Error listing objects:', error)
throw error
}
}
/**
* 尝试调用 HeadBucket 判断凭证/网络是否可用
*/
async checkConnection() {
try {
await this.client.send(new HeadBucketCommand({ Bucket: this.bucket }))
return true
} catch (error) {
Logger.error('[S3Storage] Error checking connection:', error)
throw error
}
}
}

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 {
@@ -141,7 +141,7 @@ export class SelectionService {
* Initialize zoom factor from config and subscribe to changes
* Ensures UI elements scale properly with system DPI settings
*/
private initZoomFactor() {
private initZoomFactor(): void {
const zoomFactor = configManager.getZoomFactor()
if (zoomFactor) {
this.setZoomFactor(zoomFactor)
@@ -154,7 +154,7 @@ export class SelectionService {
this.zoomFactor = zoomFactor
}
private initConfig() {
private initConfig(): void {
this.triggerMode = configManager.getSelectionAssistantTriggerMode() as TriggerMode
this.isFollowToolbar = configManager.getSelectionAssistantFollowToolbar()
this.isRemeberWinSize = configManager.getSelectionAssistantRemeberWinSize()
@@ -207,7 +207,7 @@ export class SelectionService {
* @param mode - The mode to set, either 'default', 'whitelist', or 'blacklist'
* @param list - An array of strings representing the list of items to include or exclude
*/
private setHookGlobalFilterMode(mode: string, list: string[]) {
private setHookGlobalFilterMode(mode: string, list: string[]): void {
if (!this.selectionHook) return
const modeMap = {
@@ -245,7 +245,7 @@ export class SelectionService {
}
}
private setHookFineTunedList() {
private setHookFineTunedList(): void {
if (!this.selectionHook) return
const excludeClipboardCursorDetectList = isWin
@@ -271,6 +271,11 @@ export class SelectionService {
* @returns {boolean} Success status of service start
*/
public start(): boolean {
if (!isSupportedOS) {
this.logError(new Error('SelectionService start(): not supported on this OS'))
return false
}
if (!this.selectionHook) {
this.logError(new Error('SelectionService start(): instance is null'))
return false
@@ -373,7 +378,7 @@ export class SelectionService {
* Toggle the enabled state of the selection service
* Will sync the new enabled store to all renderer windows
*/
public toggleEnabled(enabled: boolean | undefined = undefined) {
public toggleEnabled(enabled: boolean | undefined = undefined): void {
if (!this.selectionHook) return
const newEnabled = enabled === undefined ? !configManager.getSelectionAssistantEnabled() : enabled
@@ -389,7 +394,7 @@ export class SelectionService {
* Sets up window properties, event handlers, and loads the toolbar UI
* @param readyCallback Optional callback when window is ready to show
*/
private createToolbarWindow(readyCallback?: () => void) {
private createToolbarWindow(readyCallback?: () => void): void {
if (this.isToolbarAlive()) return
const { toolbarWidth, toolbarHeight } = this.getToolbarRealSize()
@@ -414,9 +419,11 @@ export class SelectionService {
backgroundMaterial: 'none',
// Platform specific settings
// [macOS] DO NOT set type to 'panel', it will not work because it conflicts with other settings
// [macOS] DO NOT set focusable to false, it will make other windows bring to front together
...(isWin ? { type: 'toolbar', focusable: false } : {}),
// [macOS] `panel` conflicts with other settings ,
// and log will show `NSWindow does not support nonactivating panel styleMask 0x80`
// but it seems still work on fullscreen apps, so we set this anyway
...(isWin ? { type: 'toolbar', focusable: false } : { type: 'panel' }),
hiddenInMissionControl: true, // [macOS only]
acceptFirstMouse: true, // [macOS only]
@@ -447,13 +454,6 @@ export class SelectionService {
// Add show/hide event listeners
this.toolbarWindow.on('show', () => {
this.toolbarWindow?.webContents.send(IpcChannel.Selection_ToolbarVisibilityChange, true)
// [macOS] force the toolbar window to be visible on current desktop
// but it will make docker icon flash. And we found that it's not necessary now.
// will remove after testing
// if (isMac) {
// this.toolbarWindow!.setVisibleOnAllWorkspaces(false)
// }
})
this.toolbarWindow.on('hide', () => {
@@ -485,10 +485,10 @@ export class SelectionService {
* @param point Reference point for positioning, logical coordinates
* @param orientation Preferred position relative to reference point
*/
private showToolbarAtPosition(point: Point, orientation: RelativeOrientation) {
private showToolbarAtPosition(point: Point, orientation: RelativeOrientation, programName: string): void {
if (!this.isToolbarAlive()) {
this.createToolbarWindow(() => {
this.showToolbarAtPosition(point, orientation)
this.showToolbarAtPosition(point, orientation, programName)
})
return
}
@@ -509,25 +509,55 @@ export class SelectionService {
//should set every time the window is shown
this.toolbarWindow!.setAlwaysOnTop(true, 'screen-saver')
// [macOS] force the toolbar window to be visible on current desktop
// but it will make docker icon flash. And we found that it's not necessary now.
// will remove after testing
// if (isMac) {
// this.toolbarWindow!.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: 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
}
/************************************************
* [macOS] the following code is only for macOS
*
* WARNING:
* DO NOT MODIFY THESE CODES, UNLESS YOU REALLY KNOW WHAT YOU ARE DOING!!!!
*************************************************/
// [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()
/**
* [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)
// [macOS] restore the focusable status
this.toolbarWindow!.setFocusable(true)
this.startHideByMouseKeyListener()
return
}
/**
@@ -588,8 +618,8 @@ export class SelectionService {
* Check if toolbar window exists and is not destroyed
* @returns {boolean} Toolbar window status
*/
private isToolbarAlive() {
return this.toolbarWindow && !this.toolbarWindow.isDestroyed()
private isToolbarAlive(): boolean {
return !!(this.toolbarWindow && !this.toolbarWindow.isDestroyed())
}
/**
@@ -598,7 +628,7 @@ export class SelectionService {
* @param width New toolbar width
* @param height New toolbar height
*/
public determineToolbarSize(width: number, height: number) {
public determineToolbarSize(width: number, height: number): void {
const toolbarWidth = Math.ceil(width)
// only update toolbar width if it's changed
@@ -611,7 +641,7 @@ export class SelectionService {
* Get actual toolbar dimensions accounting for zoom factor
* @returns Object containing toolbar width and height
*/
private getToolbarRealSize() {
private getToolbarRealSize(): { toolbarWidth: number; toolbarHeight: number } {
return {
toolbarWidth: this.TOOLBAR_WIDTH * this.zoomFactor,
toolbarHeight: this.TOOLBAR_HEIGHT * this.zoomFactor
@@ -882,8 +912,9 @@ export class SelectionService {
refPoint = { x: Math.round(refPoint.x), y: Math.round(refPoint.y) }
}
this.showToolbarAtPosition(refPoint, refOrientation)
this.toolbarWindow?.webContents.send(IpcChannel.Selection_TextSelected, selectionData)
// [macOS] isFullscreen is only available on macOS
this.showToolbarAtPosition(refPoint, refOrientation, selectionData.programName)
this.toolbarWindow!.webContents.send(IpcChannel.Selection_TextSelected, selectionData)
}
/**
@@ -891,7 +922,7 @@ export class SelectionService {
*/
// Start monitoring global mouse clicks
private startHideByMouseKeyListener() {
private startHideByMouseKeyListener(): void {
try {
// Register event handlers
this.selectionHook!.on('mouse-down', this.handleMouseDownHide)
@@ -904,7 +935,7 @@ export class SelectionService {
}
// Stop monitoring global mouse clicks
private stopHideByMouseKeyListener() {
private stopHideByMouseKeyListener(): void {
if (!this.isHideByMouseKeyListenerActive) return
try {
@@ -1098,7 +1129,7 @@ export class SelectionService {
* Initialize preloaded action windows
* Creates a pool of windows at startup for faster response
*/
private async initPreloadedActionWindows() {
private async initPreloadedActionWindows(): Promise<void> {
try {
// Create initial pool of preloaded windows
for (let i = 0; i < this.PRELOAD_ACTION_WINDOW_COUNT; i++) {
@@ -1112,7 +1143,7 @@ export class SelectionService {
/**
* Close all preloaded action windows
*/
private closePreloadedActionWindows() {
private closePreloadedActionWindows(): void {
for (const actionWindow of this.preloadedActionWindows) {
if (!actionWindow.isDestroyed()) {
actionWindow.destroy()
@@ -1124,7 +1155,7 @@ export class SelectionService {
* Preload a new action window asynchronously
* This method is called after popping a window to ensure we always have windows ready
*/
private async pushNewActionWindow() {
private async pushNewActionWindow(): Promise<void> {
try {
const actionWindow = this.createPreloadedActionWindow()
this.preloadedActionWindows.push(actionWindow)
@@ -1138,7 +1169,7 @@ export class SelectionService {
* Immediately returns a window and asynchronously creates a new one
* @returns {BrowserWindow} The action window
*/
private popActionWindow() {
private popActionWindow(): BrowserWindow {
// Get a window from the preloaded queue or create a new one if empty
const actionWindow = this.preloadedActionWindows.pop() || this.createPreloadedActionWindow()
@@ -1189,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) {
private showActionWindow(actionWindow: BrowserWindow, isFullScreen: boolean = false): void {
let actionWindowWidth = this.ACTION_WINDOW_WIDTH
let actionWindowHeight = this.ACTION_WINDOW_HEIGHT
@@ -1212,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
@@ -1226,53 +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 {
@@ -1292,38 +1386,40 @@ export class SelectionService {
* Switches between selection-based and alt-key based triggering
* Manages appropriate event listeners for each mode
*/
private processTriggerMode() {
private processTriggerMode(): void {
if (!this.selectionHook) return
switch (this.triggerMode) {
case TriggerMode.Selected:
if (this.isCtrlkeyListenerActive) {
this.selectionHook!.off('key-down', this.handleKeyDownCtrlkeyMode)
this.selectionHook!.off('key-up', this.handleKeyUpCtrlkeyMode)
this.selectionHook.off('key-down', this.handleKeyDownCtrlkeyMode)
this.selectionHook.off('key-up', this.handleKeyUpCtrlkeyMode)
this.isCtrlkeyListenerActive = false
}
this.selectionHook!.setSelectionPassiveMode(false)
this.selectionHook.setSelectionPassiveMode(false)
break
case TriggerMode.Ctrlkey:
if (!this.isCtrlkeyListenerActive) {
this.selectionHook!.on('key-down', this.handleKeyDownCtrlkeyMode)
this.selectionHook!.on('key-up', this.handleKeyUpCtrlkeyMode)
this.selectionHook.on('key-down', this.handleKeyDownCtrlkeyMode)
this.selectionHook.on('key-up', this.handleKeyUpCtrlkeyMode)
this.isCtrlkeyListenerActive = true
}
this.selectionHook!.setSelectionPassiveMode(true)
this.selectionHook.setSelectionPassiveMode(true)
break
case TriggerMode.Shortcut:
//remove the ctrlkey listener, don't need any key listener for shortcut mode
if (this.isCtrlkeyListenerActive) {
this.selectionHook!.off('key-down', this.handleKeyDownCtrlkeyMode)
this.selectionHook!.off('key-up', this.handleKeyUpCtrlkeyMode)
this.selectionHook.off('key-down', this.handleKeyDownCtrlkeyMode)
this.selectionHook.off('key-up', this.handleKeyUpCtrlkeyMode)
this.isCtrlkeyListenerActive = false
}
this.selectionHook!.setSelectionPassiveMode(true)
this.selectionHook.setSelectionPassiveMode(true)
break
}
}
@@ -1376,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) => {
@@ -1404,13 +1501,13 @@ export class SelectionService {
this.isIpcHandlerRegistered = true
}
private logInfo(message: string, forceShow: boolean = false) {
private logInfo(message: string, forceShow: boolean = false): void {
if (isDev || forceShow) {
Logger.info('[SelectionService] Info: ', message)
}
}
private logError(...args: [...string[], Error]) {
private logError(...args: [...string[], Error]): void {
Logger.error('[SelectionService] Error: ', ...args)
}
}
@@ -1423,7 +1520,7 @@ export class SelectionService {
export function initSelectionService(): boolean {
if (!isSupportedOS) return false
configManager.subscribe(ConfigKeys.SelectionAssistantEnabled, (enabled: boolean) => {
configManager.subscribe(ConfigKeys.SelectionAssistantEnabled, (enabled: boolean): void => {
//avoid closure
const ss = SelectionService.getInstance()
if (!ss) {

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

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

View File

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

@@ -1,3 +1,4 @@
import { isMac } from '@main/constant'
import Logger from 'electron-log'
import { windowService } from '../WindowService'
@@ -33,8 +34,13 @@ export async function handleProvidersProtocolUrl(url: URL) {
(await mainWindow.webContents.executeJavaScript(`typeof window.navigate === 'function'`))
) {
mainWindow.webContents.executeJavaScript(`window.navigate('/settings/provider?addProviderData=${data}')`)
if (isMac) {
windowService.showMainWindow()
}
} else {
setTimeout(() => {
Logger.info('handleProvidersProtocolUrl timeout', { data, version })
handleProvidersProtocolUrl(url)
}, 1000)
}

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,14 +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 { 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', () => ({
@@ -241,4 +246,54 @@ describe('file', () => {
expect(appConfigDir).toBe('/mock/home/.cherrystudio/config/')
})
})
describe('readTextFileWithAutoEncoding', () => {
const mockFilePath = '/path/to/mock/file.txt'
it('should read file with auto encoding', async () => {
const content = '这是一段GB2312编码的测试内容'
const buffer = iconv.encode(content, 'GB2312')
// 创建模拟的 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', async () => {
const content = '这是一段GB2312编码的测试内容'
const buffer = iconv.encode(content, 'GB2312')
// 创建模拟的 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,11 +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 * as jschardet from 'jschardet'
import { v4 as uuidv4 } from 'uuid'
export function initAppDataDir() {
@@ -202,3 +206,49 @@ export function getCacheDir() {
export function getAppConfigDir(name: string) {
return path.join(getConfigDir(), name)
}
/**
* 读取文件内容并自动检测编码格式进行解码
* @param filePath - 文件路径
* @returns 解码后的文件内容
*/
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()
// 获取文件编码格式,最多取前两个可能的编码
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')
}
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

@@ -1,26 +1,26 @@
import { BrowserWindow } from 'electron'
import { configManager } from '../services/ConfigManager'
export function handleZoomFactor(wins: BrowserWindow[], delta: number, reset: boolean = false) {
if (reset) {
wins.forEach((win) => {
win.webContents.setZoomFactor(1)
})
configManager.setZoomFactor(1)
return
}
if (delta === 0) {
return
}
const currentZoom = configManager.getZoomFactor()
const newZoom = Number((currentZoom + delta).toFixed(1))
if (newZoom >= 0.5 && newZoom <= 2.0) {
wins.forEach((win) => {
win.webContents.setZoomFactor(newZoom)
})
configManager.setZoomFactor(newZoom)
}
}
import { BrowserWindow } from 'electron'
import { configManager } from '../services/ConfigManager'
export function handleZoomFactor(wins: BrowserWindow[], delta: number, reset: boolean = false) {
if (reset) {
wins.forEach((win) => {
win.webContents.setZoomFactor(1)
})
configManager.setZoomFactor(1)
return
}
if (delta === 0) {
return
}
const currentZoom = configManager.getZoomFactor()
const newZoom = Number((currentZoom + delta).toFixed(1))
if (newZoom >= 0.5 && newZoom <= 2.0) {
wins.forEach((win) => {
win.webContents.setZoomFactor(newZoom)
})
configManager.setZoomFactor(newZoom)
}
}

View File

@@ -10,6 +10,7 @@ import {
KnowledgeItem,
MCPServer,
Provider,
S3Config,
Shortcut,
ThemeMode,
WebDavConfig
@@ -72,9 +73,9 @@ const api = {
decompress: (text: Buffer) => ipcRenderer.invoke(IpcChannel.Zip_Decompress, text)
},
backup: {
backup: (fileName: string, data: string, destinationPath?: string, skipBackupFile?: boolean) =>
ipcRenderer.invoke(IpcChannel.Backup_Backup, fileName, data, destinationPath, skipBackupFile),
restore: (backupPath: string) => ipcRenderer.invoke(IpcChannel.Backup_Restore, backupPath),
backup: (filename: string, content: string, path: string, skipBackupFile: boolean) =>
ipcRenderer.invoke(IpcChannel.Backup_Backup, filename, content, path, skipBackupFile),
restore: (path: string) => ipcRenderer.invoke(IpcChannel.Backup_Restore, path),
backupToWebdav: (data: string, webdavConfig: WebDavConfig) =>
ipcRenderer.invoke(IpcChannel.Backup_BackupToWebdav, data, webdavConfig),
restoreFromWebdav: (webdavConfig: WebDavConfig) =>
@@ -86,14 +87,36 @@ const api = {
createDirectory: (webdavConfig: WebDavConfig, path: string, options?: CreateDirectoryOptions) =>
ipcRenderer.invoke(IpcChannel.Backup_CreateDirectory, webdavConfig, path, options),
deleteWebdavFile: (fileName: string, webdavConfig: WebDavConfig) =>
ipcRenderer.invoke(IpcChannel.Backup_DeleteWebdavFile, fileName, webdavConfig)
ipcRenderer.invoke(IpcChannel.Backup_DeleteWebdavFile, fileName, webdavConfig),
backupToLocalDir: (
data: string,
fileName: string,
localConfig: { localBackupDir?: string; skipBackupFile?: boolean }
) => ipcRenderer.invoke(IpcChannel.Backup_BackupToLocalDir, data, fileName, localConfig),
restoreFromLocalBackup: (fileName: string, localBackupDir?: string) =>
ipcRenderer.invoke(IpcChannel.Backup_RestoreFromLocalBackup, fileName, localBackupDir),
listLocalBackupFiles: (localBackupDir?: string) =>
ipcRenderer.invoke(IpcChannel.Backup_ListLocalBackupFiles, localBackupDir),
deleteLocalBackupFile: (fileName: string, localBackupDir?: string) =>
ipcRenderer.invoke(IpcChannel.Backup_DeleteLocalBackupFile, fileName, localBackupDir),
setLocalBackupDir: (dirPath: string) => ipcRenderer.invoke(IpcChannel.Backup_SetLocalBackupDir, dirPath),
checkWebdavConnection: (webdavConfig: WebDavConfig) =>
ipcRenderer.invoke(IpcChannel.Backup_CheckConnection, webdavConfig),
backupToS3: (data: string, s3Config: S3Config) => ipcRenderer.invoke(IpcChannel.Backup_BackupToS3, data, s3Config),
restoreFromS3: (s3Config: S3Config) => ipcRenderer.invoke(IpcChannel.Backup_RestoreFromS3, s3Config),
listS3Files: (s3Config: S3Config) => ipcRenderer.invoke(IpcChannel.Backup_ListS3Files, s3Config),
deleteS3File: (fileName: string, s3Config: S3Config) =>
ipcRenderer.invoke(IpcChannel.Backup_DeleteS3File, fileName, s3Config),
checkS3Connection: (s3Config: S3Config) => ipcRenderer.invoke(IpcChannel.Backup_CheckS3Connection, s3Config)
},
file: {
select: (options?: OpenDialogOptions) => ipcRenderer.invoke(IpcChannel.File_Select, options),
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),
/**
@@ -124,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)
@@ -206,8 +230,8 @@ const api = {
restartServer: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_RestartServer, server),
stopServer: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_StopServer, server),
listTools: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_ListTools, server),
callTool: ({ server, name, args }: { server: MCPServer; name: string; args: any }) =>
ipcRenderer.invoke(IpcChannel.Mcp_CallTool, { server, name, args }),
callTool: ({ server, name, args, callId }: { server: MCPServer; name: string; args: any; callId?: string }) =>
ipcRenderer.invoke(IpcChannel.Mcp_CallTool, { server, name, args, callId }),
listPrompts: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_ListPrompts, server),
getPrompt: ({ server, name, args }: { server: MCPServer; name: string; args?: Record<string, any> }) =>
ipcRenderer.invoke(IpcChannel.Mcp_GetPrompt, { server, name, args }),
@@ -215,7 +239,9 @@ const api = {
getResource: ({ server, uri }: { server: MCPServer; uri: string }) =>
ipcRenderer.invoke(IpcChannel.Mcp_GetResource, { server, uri }),
getInstallInfo: () => ipcRenderer.invoke(IpcChannel.Mcp_GetInstallInfo),
checkMcpConnectivity: (server: any) => ipcRenderer.invoke(IpcChannel.Mcp_CheckConnectivity, server)
checkMcpConnectivity: (server: any) => ipcRenderer.invoke(IpcChannel.Mcp_CheckConnectivity, server),
abortTool: (callId: string) => ipcRenderer.invoke(IpcChannel.Mcp_AbortTool, callId),
setProgress: (progress: number) => ipcRenderer.invoke(IpcChannel.Mcp_SetProgress, progress)
},
python: {
execute: (script: string, context?: Record<string, any>, timeout?: number) =>
@@ -285,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

@@ -1,46 +1,45 @@
<!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' 'unsafe-inline' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: * blob:; frame-src * file:" />
<title>Cherry Studio</title>
<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' 'unsafe-inline' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: * blob:; frame-src * file:" />
<title>Cherry Studio</title>
<style>
html,
body {
margin: 0;
}
<style>
html,
body {
margin: 0;
}
#spinner {
position: fixed;
width: 100vw;
height: 100vh;
flex-direction: row;
justify-content: center;
align-items: center;
display: flex;
}
#spinner {
position: fixed;
width: 100vw;
height: 100vh;
flex-direction: row;
justify-content: center;
align-items: center;
display: flex;
}
#spinner img {
width: 100px;
border-radius: 50px;
}
</style>
</head>
#spinner img {
width: 100px;
border-radius: 50px;
}
</style>
</head>
<body>
<div id="root"></div>
<div id="spinner">
<img src="/src/assets/images/logo.png" />
</div>
<script>
console.time('init')
</script>
<script type="module" src="/src/init.ts"></script>
<script type="module" src="/src/entryPoint.tsx"></script>
</body>
</html>
<body>
<div id="root"></div>
<div id="spinner">
<img src="/src/assets/images/logo.png" />
</div>
<script>
console.time('init')
</script>
<script type="module" src="/src/init.ts"></script>
<script type="module" src="/src/entryPoint.tsx"></script>
</body>
</html>

View File

@@ -1,24 +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>
<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>
<style>
html,
body {
margin: 0;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/windows/mini/entryPoint.tsx"></script>
</body>
</html>
<body>
<div id="root"></div>
<script type="module" src="/src/windows/mini/entryPoint.tsx"></script>
</body>
</html>

View File

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

View File

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

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

@@ -47,10 +47,9 @@ export class ApiClientFactory {
// 然后检查标准的provider type
switch (provider.type) {
case 'openai':
case 'azure-openai':
console.log(`[ApiClientFactory] Creating OpenAIApiClient for provider: ${provider.id}`)
instance = new OpenAIAPIClient(provider) as BaseApiClient
break
case 'azure-openai':
case 'openai-response':
instance = new OpenAIResponseAPIClient(provider) as BaseApiClient
break

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

@@ -106,7 +106,7 @@ export class NewAPIClient extends BaseApiClient {
return client
}
if (model.endpoint_type === 'openai') {
if (model.endpoint_type === 'openai' || model.endpoint_type === 'image-generation') {
const client = this.clients.get('openai')
if (!client || !this.isValidClient(client)) {
throw new Error('Failed to get openai client')

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

@@ -2,6 +2,7 @@ import { GenericChunk } from '@renderer/aiCore/middleware/schemas'
import { CompletionsContext } from '@renderer/aiCore/middleware/types'
import {
isOpenAIChatCompletionOnlyModel,
isOpenAILLMModel,
isSupportedReasoningEffortOpenAIModel,
isVisionModel
} from '@renderer/config/models'
@@ -38,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'
@@ -64,10 +65,13 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
* 根据模型特征选择合适的客户端
*/
public getClient(model: Model) {
if (isOpenAIChatCompletionOnlyModel(model)) {
return this.client
} else {
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
}
}
@@ -76,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(
@@ -172,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
@@ -353,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. 最终请求消息
@@ -423,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
@@ -434,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
@@ -450,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')
@@ -509,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
@@ -534,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

@@ -67,7 +67,12 @@ export const AbortHandlerMiddleware: CompletionsMiddleware =
const streamWithAbortHandler = (result.stream as ReadableStream<Chunk>).pipeThrough(
new TransformStream<Chunk, Chunk | ErrorChunk>({
transform(chunk, controller) {
// 检查 abort 状态
// 如果已经收到错误块,不再检查 abort 状态
if (chunk.type === ChunkType.ERROR) {
controller.enqueue(chunk)
return
}
if (abortSignal?.aborted) {
// 转换为 ErrorChunk
const errorChunk: ErrorChunk = {

View File

@@ -136,7 +136,6 @@ function extractAndAccumulateUsageMetrics(ctx: CompletionsContext, chunk: Generi
Logger.debug(`[${MIDDLEWARE_NAME}] First token timestamp: ${ctx._internal.customState.firstTokenTimestamp}`)
}
if (chunk.type === ChunkType.LLM_RESPONSE_COMPLETE) {
Logger.debug(`[${MIDDLEWARE_NAME}] LLM_RESPONSE_COMPLETE chunk received:`, ctx._internal)
// 从LLM_RESPONSE_COMPLETE chunk中提取usage数据
if (chunk.response?.usage) {
accumulateUsage(ctx._internal.observer.usage, chunk.response.usage)

View File

@@ -89,6 +89,11 @@ function createToolHandlingTransform(
let hasToolUseResponses = false
let streamEnded = false
// 存储已执行的工具结果
const executedToolResults: SdkMessageParam[] = []
const executedToolCalls: SdkToolCall[] = []
const executionPromises: Promise<void>[] = []
return new TransformStream({
async transform(chunk: GenericChunk, controller) {
try {
@@ -98,22 +103,64 @@ function createToolHandlingTransform(
// 1. 处理Function Call方式的工具调用
if (createdChunk.tool_calls && createdChunk.tool_calls.length > 0) {
toolCalls.push(...createdChunk.tool_calls)
hasToolCalls = true
for (const toolCall of createdChunk.tool_calls) {
toolCalls.push(toolCall)
const executionPromise = (async () => {
try {
const result = await executeToolCalls(
ctx,
[toolCall],
mcpTools,
allToolResponses,
currentParams.onChunk,
currentParams.assistant.model!
)
// 缓存执行结果
executedToolResults.push(...result.toolResults)
executedToolCalls.push(...result.confirmedToolCalls)
} catch (error) {
console.error(`🔧 [${MIDDLEWARE_NAME}] Error executing tool call asynchronously:`, error)
}
})()
executionPromises.push(executionPromise)
}
}
// 2. 处理Tool Use方式的工具调用
if (createdChunk.tool_use_responses && createdChunk.tool_use_responses.length > 0) {
toolUseResponses.push(...createdChunk.tool_use_responses)
hasToolUseResponses = true
for (const toolUseResponse of createdChunk.tool_use_responses) {
toolUseResponses.push(toolUseResponse)
const executionPromise = (async () => {
try {
const result = await executeToolUseResponses(
ctx,
[toolUseResponse], // 单个执行
mcpTools,
allToolResponses,
currentParams.onChunk,
currentParams.assistant.model!
)
// 缓存执行结果
executedToolResults.push(...result.toolResults)
} catch (error) {
console.error(`🔧 [${MIDDLEWARE_NAME}] Error executing tool use response asynchronously:`, error)
// 错误时不影响其他工具的执行
}
})()
executionPromises.push(executionPromise)
}
}
// 不转发MCP工具进展chunks避免重复处理
return
} else {
controller.enqueue(chunk)
}
// 转发其他所有chunk
controller.enqueue(chunk)
} catch (error) {
console.error(`🔧 [${MIDDLEWARE_NAME}] Error processing chunk:`, error)
controller.error(error)
@@ -121,43 +168,33 @@ function createToolHandlingTransform(
},
async flush(controller) {
const shouldExecuteToolCalls = hasToolCalls && toolCalls.length > 0
const shouldExecuteToolUseResponses = hasToolUseResponses && toolUseResponses.length > 0
if (!streamEnded && (shouldExecuteToolCalls || shouldExecuteToolUseResponses)) {
// 在流结束时等待所有异步工具执行完成,然后进行递归调用
if (!streamEnded && (hasToolCalls || hasToolUseResponses)) {
streamEnded = true
try {
let toolResult: SdkMessageParam[] = []
if (shouldExecuteToolCalls) {
toolResult = await executeToolCalls(
ctx,
toolCalls,
mcpTools,
allToolResponses,
currentParams.onChunk,
currentParams.assistant.model!
)
} else if (shouldExecuteToolUseResponses) {
toolResult = await executeToolUseResponses(
ctx,
toolUseResponses,
mcpTools,
allToolResponses,
currentParams.onChunk,
currentParams.assistant.model!
)
}
if (toolResult.length > 0) {
await Promise.all(executionPromises)
if (executedToolResults.length > 0) {
const output = ctx._internal.toolProcessingState?.output
const newParams = buildParamsWithToolResults(
ctx,
currentParams,
output,
executedToolResults,
executedToolCalls
)
// 在递归调用前通知UI开始新的LLM响应处理
if (currentParams.onChunk) {
currentParams.onChunk({
type: ChunkType.LLM_RESPONSE_CREATED
})
}
const newParams = buildParamsWithToolResults(ctx, currentParams, output, toolResult, toolCalls)
await executeWithToolHandling(newParams, depth + 1)
}
} catch (error) {
console.error(`🔧 [${MIDDLEWARE_NAME}] Error in tool processing:`, error)
Logger.error(`🔧 [${MIDDLEWARE_NAME}] Error in tool processing:`, error)
controller.error(error)
} finally {
hasToolCalls = false
@@ -178,8 +215,7 @@ async function executeToolCalls(
allToolResponses: MCPToolResponse[],
onChunk: CompletionsParams['onChunk'],
model: Model
): Promise<SdkMessageParam[]> {
// 转换为MCPToolResponse格式
): Promise<{ toolResults: SdkMessageParam[]; confirmedToolCalls: SdkToolCall[] }> {
const mcpToolResponses: ToolCallResponse[] = toolCalls
.map((toolCall) => {
const mcpTool = ctx.apiClientInstance.convertSdkToolCallToMcp(toolCall, mcpTools)
@@ -192,11 +228,11 @@ async function executeToolCalls(
if (mcpToolResponses.length === 0) {
console.warn(`🔧 [${MIDDLEWARE_NAME}] No valid MCP tool responses to execute`)
return []
return { toolResults: [], confirmedToolCalls: [] }
}
// 使用现有的parseAndCallTools函数执行工具
const toolResults = await parseAndCallTools(
const { toolResults, confirmedToolResponses } = await parseAndCallTools(
mcpToolResponses,
allToolResponses,
onChunk,
@@ -204,10 +240,26 @@ async function executeToolCalls(
return ctx.apiClientInstance.convertMcpToolResponseToSdkMessageParam(mcpToolResponse, resp, model)
},
model,
mcpTools
mcpTools,
ctx._internal?.flowControl?.abortSignal
)
return toolResults
// 找出已确认工具对应的原始toolCalls
const confirmedToolCalls = toolCalls.filter((toolCall) => {
return confirmedToolResponses.find((confirmed) => {
// 根据不同的ID字段匹配原始toolCall
return (
('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 ||
('toolCallId' in confirmed && confirmed.toolCallId === toolCall.id) ||
('function' in toolCall && toolCall.function.name.toLowerCase().includes(confirmed.tool.name.toLowerCase()))
)
})
})
return { toolResults, confirmedToolCalls }
}
/**
@@ -221,9 +273,9 @@ async function executeToolUseResponses(
allToolResponses: MCPToolResponse[],
onChunk: CompletionsParams['onChunk'],
model: Model
): Promise<SdkMessageParam[]> {
): Promise<{ toolResults: SdkMessageParam[] }> {
// 直接使用parseAndCallTools函数处理已经解析好的ToolUseResponse
const toolResults = await parseAndCallTools(
const { toolResults } = await parseAndCallTools(
toolUseResponses,
allToolResponses,
onChunk,
@@ -231,10 +283,11 @@ async function executeToolUseResponses(
return ctx.apiClientInstance.convertMcpToolResponseToSdkMessageParam(mcpToolResponse, resp, model)
},
model,
mcpTools
mcpTools,
ctx._internal?.flowControl?.abortSignal
)
return toolResults
return { toolResults }
}
/**
@@ -245,7 +298,7 @@ function buildParamsWithToolResults(
currentParams: CompletionsParams,
output: SdkRawOutput | string | undefined,
toolResults: SdkMessageParam[],
toolCalls: SdkToolCall[]
confirmedToolCalls: SdkToolCall[]
): CompletionsParams {
// 获取当前已经转换好的reqMessages如果没有则使用原始messages
const currentReqMessages = getCurrentReqMessages(ctx)
@@ -253,7 +306,7 @@ function buildParamsWithToolResults(
const apiClient = ctx.apiClientInstance
// 从回复中构建助手消息
const newReqMessages = apiClient.buildSdkMessages(currentReqMessages, output, toolResults, toolCalls)
const newReqMessages = apiClient.buildSdkMessages(currentReqMessages, output, toolResults, confirmedToolCalls)
if (output && ctx._internal.toolProcessingState) {
ctx._internal.toolProcessingState.output = undefined

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

@@ -22,7 +22,8 @@ const TOOL_USE_TAG_CONFIG: TagConfig = {
* 1. 从文本流中检测并提取 <tool_use></tool_use> 标签
* 2. 解析工具调用信息并转换为 ToolUseResponse 格式
* 3. 生成 MCP_TOOL_CREATED chunk 供 McpToolChunkMiddleware 处理
* 4. 清理文本流,移除工具使用标签但保留正常文本
* 4. 丢弃 tool_use 之后的所有内容(助手幻觉)
* 5. 清理文本流,移除工具使用标签但保留正常文本
*
* 注意:此中间件只负责提取和转换,实际工具调用由 McpToolChunkMiddleware 处理
*/
@@ -32,13 +33,10 @@ export const ToolUseExtractionMiddleware: CompletionsMiddleware =
async (ctx: CompletionsContext, params: CompletionsParams): Promise<CompletionsResult> => {
const mcpTools = params.mcpTools || []
// 如果没有工具,直接调用下一个中间件
if (!mcpTools || mcpTools.length === 0) return next(ctx, params)
// 调用下游中间件
const result = await next(ctx, params)
// 响应后处理:处理工具使用标签提取
if (result.stream) {
const resultFromUpstream = result.stream as ReadableStream<GenericChunk>
@@ -60,7 +58,9 @@ function createToolUseExtractionTransform(
_ctx: CompletionsContext,
mcpTools: MCPTool[]
): TransformStream<GenericChunk, GenericChunk> {
const tagExtractor = new TagExtractor(TOOL_USE_TAG_CONFIG)
const toolUseExtractor = new TagExtractor(TOOL_USE_TAG_CONFIG)
let hasAnyToolUse = false
let toolCounter = 0
return new TransformStream({
async transform(chunk: GenericChunk, controller) {
@@ -68,30 +68,38 @@ function createToolUseExtractionTransform(
// 处理文本内容,检测工具使用标签
if (chunk.type === ChunkType.TEXT_DELTA) {
const textChunk = chunk as TextDeltaChunk
const extractionResults = tagExtractor.processText(textChunk.text)
for (const result of extractionResults) {
// 处理 tool_use 标签
const toolUseResults = toolUseExtractor.processText(textChunk.text)
for (const result of toolUseResults) {
if (result.complete && result.tagContentExtracted) {
// 提取到完整的工具使用内容,解析并转换为 SDK ToolCall 格式
const toolUseResponses = parseToolUse(result.tagContentExtracted, mcpTools)
const toolUseResponses = parseToolUse(result.tagContentExtracted, mcpTools, toolCounter)
toolCounter += toolUseResponses.length
if (toolUseResponses.length > 0) {
// 生成 MCP_TOOL_CREATED chunk复用现有的处理流程
controller.enqueue({ type: ChunkType.TEXT_COMPLETE, text: '' })
// 生成 MCP_TOOL_CREATED chunk
const mcpToolCreatedChunk: MCPToolCreatedChunk = {
type: ChunkType.MCP_TOOL_CREATED,
tool_use_responses: toolUseResponses
}
controller.enqueue(mcpToolCreatedChunk)
// 标记已有工具调用
hasAnyToolUse = true
}
} else if (!result.isTagContent && result.content) {
// 发送标签外的正常文本内容
const cleanTextChunk: TextDeltaChunk = {
...textChunk,
text: result.content
if (!hasAnyToolUse) {
const cleanTextChunk: TextDeltaChunk = {
...textChunk,
text: result.content
}
controller.enqueue(cleanTextChunk)
}
controller.enqueue(cleanTextChunk)
}
// 注意标签内的内容不会作为TEXT_DELTA转发,避免重复显示
// tool_use 标签内的内容不转发,避免重复显示
}
return
}
@@ -105,16 +113,17 @@ function createToolUseExtractionTransform(
},
async flush(controller) {
// 检查是否有未完成的标签内容
const finalResult = tagExtractor.finalize()
if (finalResult && finalResult.tagContentExtracted) {
const toolUseResponses = parseToolUse(finalResult.tagContentExtracted, mcpTools)
// 检查是否有未完成的 tool_use 标签内容
const finalToolUseResult = toolUseExtractor.finalize()
if (finalToolUseResult && finalToolUseResult.tagContentExtracted) {
const toolUseResponses = parseToolUse(finalToolUseResult.tagContentExtracted, mcpTools, toolCounter)
if (toolUseResponses.length > 0) {
const mcpToolCreatedChunk: MCPToolCreatedChunk = {
type: ChunkType.MCP_TOOL_CREATED,
tool_use_responses: toolUseResponses
}
controller.enqueue(mcpToolCreatedChunk)
hasAnyToolUse = true
}
}
}

View File

@@ -1,13 +1,13 @@
@font-face {
font-family: 'Twemoji Country Flags';
unicode-range:
U+1F1E6-1F1FF, U+1F3F4, U+E0062-E0063, U+E0065, U+E0067, U+E006C, U+E006E, U+E0073-E0074, U+E0077, U+E007F;
/*https://github.com/beyondkmp/country-flag-emoji-polyfill/blob/master/font/TwemojiCountryFlags.woff2 */
src: url('TwemojiCountryFlags.woff2') format('woff2');
font-display: swap;
}
/* 国旗字体样式类 */
.country-flag-font {
font-family: 'Twemoji Country Flags', 'Apple Color Emoji', 'Segoe UI Emoji', sans-serif;
}
@font-face {
font-family: 'Twemoji Country Flags';
unicode-range:
U+1F1E6-1F1FF, U+1F3F4, U+E0062-E0063, U+E0065, U+E0067, U+E006C, U+E006E, U+E0073-E0074, U+E0077, U+E007F;
/*https://github.com/beyondkmp/country-flag-emoji-polyfill/blob/master/font/TwemojiCountryFlags.woff2 */
src: url('TwemojiCountryFlags.woff2') format('woff2');
font-display: swap;
}
/* 国旗字体样式类 */
.country-flag-font {
font-family: 'Twemoji Country Flags', 'Apple Color Emoji', 'Segoe UI Emoji', sans-serif;
}

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,20 +1,20 @@
:root {
--font-family:
Ubuntu, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Roboto, Oxygen, Cantarell, 'Open Sans',
'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
--font-family-serif:
serif, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Ubuntu, Roboto, Oxygen, Cantarell, 'Open Sans',
'Helvetica Neue', Arial, 'Noto Sans', 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--code-font-family: 'Cascadia Code', 'Fira Code', 'Consolas', Menlo, Courier, monospace;
}
// Windows系统专用字体配置
body[os='windows'] {
--font-family:
'Twemoji Country Flags', Ubuntu, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Roboto, Oxygen,
Cantarell, 'Open Sans', 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
'Segoe UI Symbol', 'Noto Color Emoji';
}
:root {
--font-family:
Ubuntu, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Roboto, Oxygen, Cantarell, 'Open Sans',
'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
--font-family-serif:
serif, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Ubuntu, Roboto, Oxygen, Cantarell, 'Open Sans',
'Helvetica Neue', Arial, 'Noto Sans', 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--code-font-family: 'Cascadia Code', 'Fira Code', 'Consolas', Menlo, Courier, monospace;
}
// Windows系统专用字体配置
body[os='windows'] {
--font-family:
'Twemoji Country Flags', Ubuntu, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Roboto, Oxygen,
Cantarell, 'Open Sans', 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
'Segoe UI Symbol', 'Noto Color Emoji';
}

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'
@@ -9,12 +9,13 @@ import { debounce } from 'lodash'
import { ChevronsDownUp, ChevronsUpDown, Text as UnWrapIcon, WrapText as WrapIcon } from 'lucide-react'
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
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
@@ -150,7 +151,8 @@ const CodePreview = ({ children, language, setTools }: CodePreviewProps) => {
{
'--gutter-width': `${gutterDigits}ch`,
fontSize: `${fontSize - 1}px`,
maxHeight: shouldCollapse ? MAX_COLLAPSE_HEIGHT : undefined
maxHeight: shouldCollapse ? MAX_COLLAPSE_HEIGHT : undefined,
overflowY: shouldCollapse ? 'auto' : 'hidden'
} as React.CSSProperties
}>
<div
@@ -162,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}>
@@ -195,9 +189,49 @@ const CodePreview = ({ children, language, setTools }: CodePreviewProps) => {
CodePreview.displayName = 'CodePreview'
/**
* 补全代码行 tokens把原始内容拼接到高亮内容之后确保渲染出整行来。
*/
function completeLineTokens(themedTokens: ThemedToken[], rawLine: string): ThemedToken[] {
// 如果出现空行,补一个空格保证行高
if (rawLine.length === 0) {
return [
{
content: ' ',
offset: 0,
color: 'inherit',
bgColor: 'inherit',
htmlStyle: {
opacity: '0.35'
}
}
]
}
const themedContent = themedTokens.map((token) => token.content).join('')
const extraContent = rawLine.slice(themedContent.length)
// 已有内容已经全部高亮,直接返回
if (!extraContent) return themedTokens
// 补全剩余内容
return [
...themedTokens,
{
content: extraContent,
offset: themedContent.length,
color: 'inherit',
bgColor: 'inherit',
htmlStyle: {
opacity: '0.35'
}
}
]
}
interface VirtualizedRowData {
rawLine: string
tokenLine?: any[]
tokenLine?: ThemedToken[]
showLineNumbers: boolean
}
@@ -210,17 +244,11 @@ const VirtualizedRow = memo(
<div className="line">
{showLineNumbers && <span className="line-number">{index + 1}</span>}
<span className="line-content">
{tokenLine ? (
// 渲染高亮后的内容
tokenLine.map((token, tokenIndex) => (
<span key={tokenIndex} style={getReactStyleFromToken(token)}>
{token.content}
</span>
))
) : (
// 渲染原始内容
<span className="line-content-raw">{rawLine || ' '}</span>
)}
{completeLineTokens(tokenLine ?? [], rawLine).map((token, tokenIndex) => (
<span key={tokenIndex} style={getReactStyleFromToken(token)}>
{token.content}
</span>
))}
</span>
</div>
)
@@ -234,7 +262,7 @@ const ScrollContainer = styled.div<{
$lineHeight?: number
}>`
display: block;
overflow: auto;
overflow-x: auto;
position: relative;
border-radius: inherit;
padding: 0.5em 1em;
@@ -264,10 +292,6 @@ const ScrollContainer = styled.div<{
overflow-wrap: ${(props) => (props.$wrap ? 'break-word' : 'normal')};
}
}
.line-content-raw {
opacity: 0.35;
}
}
`

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,9 +292,12 @@ const SplitViewWrapper = styled.div`
width: 100%;
}
&:not(:has(+ [class*='Container'])) {
&:not(:has(+ .html-artifacts)) {
border-radius: 0 0 8px 8px;
}
`
export default memo(CodeBlockView)
&:not(:has(+ [class*='Container'])) {
border-radius: 0 0 8px 8px;
overflow: hidden;
}
`

View File

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

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