Compare commits

..

2 Commits

Author SHA1 Message Date
qiuqiu
7d74a2330e Windows7 support V1.4.1 (#7111)
* feat: Add-aihubmix-ideogram-v3 (#5958)

* chore: update Yarn version to 4.9.1 and add rendering speed option for V3 model

* Discard changes to .yarnrc.yml

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

* Update package.json

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

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

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

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

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

* clean

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

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

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

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

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

---------

Co-authored-by: 亢奋猫 <kangfenmao@qq.com>
Co-authored-by: suyao <sy20010504@gmail.com>

* refactor(Scrollbar): Optimize scroll handling logic to support external scroll events (#6047)

* refactor(Scrollbar): Optimize scroll handling logic to support external scroll events

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

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

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

* fix(Messages): Remove unused FC imports and add onComponentUpdate and onFirstUpdate properties

* hotfix: enhance reasoning summary handling in OpenAI response processing (#6037)

* feat: add zoom factor handling on window restore in WindowService (#6169)

Co-authored-by: beyondkmp <beyondkmkp@gmail.com>

* Hotfix/gemini-para-bug (#6173)

* fix(OpenAIProvider): enhance model support and formatting logic

* fix(OpenAIProvider): Gemini OpenAI Compatible

* hotfix: respect user-defined model group name (#6174)

* feat(settings): add OpenAI alert (#6164)

* hotfix: add OpenAI settings tab and related functionality (#6040)

* feat: add OpenAI settings tab and related functionality

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

* fix(OpenAIResponseProvider): remove unused isOpenAILLMModel import

* feat: support default Quick Assistant model (#6150)

* feat: support default Quick Assistant model

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

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

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

* refactor(i18n): uniformly the terms, changing "快速助手" to "快捷助手"

* refactor: remove constructor from KnowledgeQueue and invoke checkAllBases in useAppInit hook

* feat: add resolveFilePath functionality to resolve restoring from different computer (#5980)

* feat: add resolveFilePath functionality to file management

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

* refactor: remove unused path import from preload index

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

---------

Co-authored-by: beyondkmp <beyondkmkp@gmail.com>

* docs: update branching strategy documentation and add English and Chinese versions

- Updated the README to link to the new English branching strategy document.
- Added a new English version of the branching strategy document.
- Removed the outdated branching strategy document.
- Added a Chinese version of the branching strategy document.

* fix: update file path resolution in new Electron

* fix: update default values and improve component structure

- Changed default value for `getTrayOnClose` to true in `ConfigManager`.
- Removed fullscreen toggle logic from `WindowService`.
- Adjusted styles in `OpenAIAlert` for better spacing.
- Reorganized imports in `Navbar` and updated component paths in `AssistantsTab` and `SettingsTab`.
- Added new components `AssistantItem` and `OpenAISettingsGroup` for better modularity.
- Enhanced `SettingGroup` styles for improved UI consistency.
- Updated `QuickPhraseSettings` to utilize theme context.
- Minor fixes and refactoring across various services and components.

* feat: add MCP servers via JSON quickly (#6099)

* feat: add MCP servers via JSON quickly

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

* feat: json linter for EditMcpJsonPopup

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

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

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

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

* fix(MCPSettings): fix AddMcpServerModal default baseUrl login

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

* feat(MCPSettings): support CodeEditor in AddMcpServerModal

* fix: Remove unnecessary type checks for JSON parsing login

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

* refactor: remove unnessary cdoe

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

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

* chroe: modify the Dropdown items' name of addServer

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

---------

Co-authored-by: one <wangan.cs@gmail.com>

* fix: set default initial values for rendering speed in aihubmixConfig

- Added initialValue 'DEFAULT' for renderingSpeed in multiple mode configurations to ensure consistent default behavior across the application.

* chore(version): 1.3.7

* refactor: centralize paste handling logic with PasteService (#6199)

- Integrated PasteService for handling paste events in Inputbar and MessageEditor components.
- Removed redundant paste handling code and improved maintainability.
- Registered paste handlers and set last focused component for better user experience.
- Ensured consistent behavior for text and file pasting across components.

Co-authored-by: beyondkmp <beyondkmkp@gmail.com>

* chore: update Hailuo logo (#6208)

* feat: update Hailuo model configuration with new logo property and border option

* chore: update hailuo_dark model image

* feat: add new model provider BurnCloud

* fix(mcp): mcp result preview is missing parameters (#6165)

* fix: knowledge icon consistency across components (#6209)

feat: knowledge icon consistency across components

* feat: enhance ContentSearch and MessageTools components

- Added placeholder and adjusted styles for the Input in ContentSearch.
- Updated SearchBarContainer to use fixed positioning and improved padding.
- Refactored MessageTools to render raw content correctly and added a new MarkdownContainer for better styling.
- Minor adjustments to other components for improved layout and user experience.

* fix: add context menu trigger to FloatingSidebar component

* refactor: improve FloatingSidebar and HomeTabs components

- Simplified the onOpenChange handler in FloatingSidebar for better readability.
- Added style prop to HomeTabs for enhanced customization.
- Adjusted Container style in HomeTabs to merge with existing border styles.

* Revert "feat: add resolveFilePath functionality to resolve restoring from different computer (#5980)"

This reverts commit 3abe0e803c.

* feat: implement getFilePath method in FileManager and update KnowledgeContent to use it

- Added getFilePath method in FileManager to construct file paths based on file ID and extension.
- Updated KnowledgeContent to utilize the new getFilePath method for opening file paths, improving code clarity and maintainability.

* chore(version): 1.3.8

* refactor(PasteService): optimize handler registration logic (#6223)

- Updated registerHandler to only log and update the handler if it changes, reducing unnecessary operations.
- Removed logging from unregisterHandler for cleaner code.

* Feat/mcp run python (#6151)

* feat: add MCP Run Python server and integrate Pyodide for executing Python code

* fix: comment out unused polyfill imports in MCP Run Python server

* fix: handle undefined html title (#6229)

* fix: improve CodeEditor controlled mode, prevent unnecessary trimming (#6221)

* fix: improve CodeEditor controlled mode, prevent unnecessary trimming

* fix: useEffect dependency

* fix: sse mcp 404 not found

* feat: add message multiple select (#6085)

* feat: add message multiple select

* fix: build error

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

* fix: code review

* Revert "fix: code review"

This reverts commit 7e29d5147c.

* fix: hide input bar display

* fix: extract the ChatContext

* fix: eventemitter

* feat: enhance multi-select functionality with message registration

* fix: history page message search

* fix: build error

* fix: remove Event Emitter

* fix: build error

* feat: add hideMenuBar prop to MessageItem and integrate MessageEditingProvider

* fix: improve message selection logic and handle drag events

* fix: update translation keys for multiple select functionality

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

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

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

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

This reverts commit f4d1454525.

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

* fix: streamline multi-select handling by consolidating context usage and simplifying component props

* feat/notification (#6144)

* WIP

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

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

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

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

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

* feat: Integrate NotificationService for user notifications across various components

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

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

* feat: Refactor notification handling and integrate new notification settings

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

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

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

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

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

* feat: Add notification icon to Electron notifications

* fix: import SYSTEM_MODELS in EditModelsPopup for improved model handling

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

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

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

- Added NotificationProvider to manage notifications within the application.
- Updated App component to include NotificationProvider, enhancing user feedback through notifications.
- Refactored NotificationQueue to support multiple listeners for notification handling.

* refactor: update MultiSelectionPopup icons and improve styling

- Replaced Ant Design icons with Lucide icons for a more modern look.
- Adjusted ActionButton styling to have a circular border radius.
- Updated translation keys in Chinese locales for better formatting.
- Enhanced event handling in ChatContext to manage multi-select mode more effectively.
- Cleaned up unused imports and props in MessageGroup and MessageSelect components.

* feat: add Google miniapp

* fix: enhance OpenAI service tier handling and initialize settings during migration

- Updated getServiceTier method to ensure proper handling of undefined OpenAI models.
- Added initialization for OpenAI settings in the migration process to set default values if not present.

* refactor: simplify MCP service integration and cleanup logic

- Replaced singleton pattern with direct instantiation of McpService for cleaner code.
- Updated IPC handlers to use the new mcpService instance directly.
- Removed unnecessary logging in Inputbar and MCPToolsButton components to streamline functionality.
- Cleaned up error handling in McpSettings to improve user experience.

* fix: topic deletion button covering the text

* fix: message content width will exceed the bubble limit in narrow layout mode

* fix: settings -> openAI -> summaryText undefined

* chore: remove electron-notification-state dependency and update notification settings

- Removed the electron-notification-state package from dependencies as it is no longer used.
- Updated NotificationService to eliminate Do Not Disturb handling.
- Changed default notification settings in the Redux store to false for assistant messages, backups, and knowledge embedding.

* fix: topic prompt not working #6226

close #6226

* fix: english serif font rendering issue #6224

close #6224

* chore(version): 1.3.9

* fix: increase the upper limit of issue-management operations-per-run (#6257)

* refactor: reuse shiki highlighter utils (#6235)

* refactor: improve shiki highlighter utils and reuse it in ShikiStreamService

* refactor: reuse shiki highlighter and markdown-it renderer

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

* chore: update shiki

* refactor: improve ContextMenu and Message components layout

- Replaced the div in ContextMenu with a styled component for better styling control.
- Enhanced Message component to handle editing state more cleanly, separating the editor from the message display.
- Adjusted styling for the MessageEditor and FileBlocksContainer for improved layout and responsiveness.

* refactor: migrate chat context to a custom hook and enhance multi-selection functionality

- Replaced the ChatContext with a custom hook `useChatContext` for better modularity and reusability.
- Updated components to utilize the new hook, passing the active topic as an argument.
- Enhanced multi-selection logic and state management for messages, improving user experience in the chat interface.
- Removed the old ChatContext file to streamline the codebase.

* refactor: enhance multi-selection handling in Inputbar and MessageEditor

- Integrated `useAppSelector` in Inputbar to manage multi-selection state.
- Updated Inputbar to conditionally render based on multi-selection mode.
- Modified MessageEditor to display the resend button only for assistant messages, improving UI clarity.

* style: update border color in SearchBarContainer for improved UI consistency

* fix: handle user cancellation and improve error reporting in file saving process

- Added a check for user cancellation in the file save dialog, rejecting the promise if canceled.
- Enhanced error handling to reject the promise with a detailed error message instead of returning null.

* refactor: update IPC channel names for notifications

- Renamed IPC channels for notifications to improve clarity and consistency.
- Updated related handlers in the main process and preload scripts to reflect the new naming convention.
- Enhanced notification service to respect user settings before sending notifications.

* refactor: enhance notification handling based on page context

- Updated NotificationProvider to truncate long messages for better display.
- Modified message sending logic in messageThunk to prevent notifications on the home page, improving user experience.

* chore: update release notes in electron-builder.yml

- Added new features including message notification functionality and support for Google Mini Programs.
- Improved MCP capabilities to run Python code and fixed several issues related to message editing and display.
- Updated release notes to reflect these changes and enhancements.

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

* refactor: improve shiki highlighter utils and reuse it in ShikiStreamService

* refactor: reuse shiki highlighter and markdown-it renderer

* hotfix: exclude GitHub and Copilot models

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

---------

Co-authored-by: one <wangan.cs@gmail.com>

* fix: show x-scrollbar in codeblock if unwrapped, simplify style definitions (#6266)

* fix: show x-scrollbar in codeblock if unwrapped, simplify style definitions

* chore: clean up useless code

* revert: mcp run python (#6151)

This reverts commit c468c3cfd5.

* refactor: CodePreview fade in on the first highlighting (#6228)

* refactor(CodePreview): fade in on the first highlighting

* refactor: improve code placeholder style

* refactor: update Gemini file upload method to accept baseURL parameter

- Modified the uploadFile method in GeminiService to include baseURL in the parameters.
- Updated the corresponding calls in the preload and renderer layers to pass the baseURL along with the apiKey.

* fix: enhance message loading and search functionality in HistoryPage and SearchResults

- Integrated Redux dispatch to load topic messages when a message is clicked in HistoryPage.
- Updated SearchResults to utilize message blocks for improved search results, including content extraction.
- Refactored state management to accommodate new content structure in search results.

* fix: token 取整 (#6300)

* fix: Use effective theme for code style in SettingsTab (#6305)

* Fix: Use effective theme for code style in SettingsTab

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

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

* Refactor: Remove unnecessary comments in SettingsTab

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

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>

* fix: aihubmix provider model proxy rule (#6293)

Update AihubmixProvider.ts

Co-authored-by: zhaochenxue <zhaochenxue@bixin.cn>

* feat: add functionality to insert messages at a specific index in the… (#6299)

* feat: add functionality to insert messages at a specific index in the Redux store

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

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

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

---------

Co-authored-by: kangfenmao <kangfenmao@qq.com>

* feat: implement message location functionality and refactor multi-select handling

- Added event listeners for LOCATE_MESSAGE events to scroll to specific messages in the MessageGroup component.
- Introduced a new SelectionBox component to handle multi-select functionality, allowing users to select multiple messages with drag actions.
- Refactored Messages component to remove unused multi-select logic and improve overall structure.
- Cleaned up code by removing commented-out sections and unnecessary state management related to dragging.

* chore: upgrade yarn version to v4.9.1

* build: add win-sign script

* fix: enhance backup and restore functionality with skip option (#6294)

* fix: enhance backup and restore functionality with skip option

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

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

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

- Updated `restore` and `restoreFromWebdav` methods in `BackupManager`, `BackupService`, and `NutstoreService` to remove the `skipBackupFile` parameter, streamlining the restore process.
- Adjusted corresponding IPC calls in `preload` and `RestorePopup` to reflect the changes, ensuring consistent functionality across the application.

* fix: handle empty block content in MessageTranslate component

* fix: non-streaming reasoning_content (#6308)

* fix: non-streaming reasoning_content

* fix: streamline reasoning handling in OpenAIProvider

* fix: #6301 (#6317)

* fix: #6301

* fix: update dependencies in useUpdateHandler and add eslint comment in ContentSearch

* fix: shiki does not load language as a fallback & themes error (#6281)

* fix: shiki does not load language as a fallback & themes error

* feat: shiki automatic loading language

* fix: adjusted max-width in McpDescription.tsx

* fix: handle proxy environment variables for bun command in MCPService

- Added logic to remove proxy environment variables when the command ends with 'bun'.
- Introduced a new private method `removeProxyEnv` to clean up the environment variables before starting the server.

* feat: add support for 'grok' provider in web search functionality

- Enhanced `isWebSearchModel` to recognize 'grok' as a valid web search model.
- Updated `getOpenAIWebSearchParams` to return specific search parameters for 'grok'.
- Modified `OpenAIProvider` to handle citations from 'grok' in web search results.
- Added 'grok' to the `WebSearchSource` enum for citation formatting.

* feat: toggle MCP servers on the card list (#6232)

* feat: Support Claude 4 (#6328)

* feat: gemini thinking summary support

- Removed unnecessary content accumulation and streamlined chunk processing logic.
- Introduced distinct handling for 'thinking' and 'text' parts, ensuring accurate onChunk calls for both types.
- Enhanced timing tracking for reasoning content, improving overall responsiveness in streaming scenarios.

* chore(version): 1.3.10

* fix: editing user messages is not re-sent; it can only be saved#6327

* feat: support pin topic to the top

Signed-off-by: jtsang4 <info@jtsang.me>

* fix: minapp search error

* fix: ensure correct PATH assignment in shell environment

- Updated the environment variable initialization to use a consistent type.
- Added logic to set the PATH variable correctly, ensuring it falls back to existing values if necessary.

* feat: add navigation buttons for webview history in MinApp popup (#6342)

* feat: add navigation buttons for webview history in MinApp popup

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

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

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

* fix: update Russian translations for MinApp popup UI elements

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

---------

Co-authored-by: beyondkmp <beyondkmkp@gmail.com>

* chore: update electron version to 35.4.0 in package.json and yarn.lock

* feat: support tokenflux provider (#6358)

* feat: add Cherry Cloud provider with associated assets and localization support

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

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

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

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

* feat: add CherryCloud provider configuration and token management

* feat: integrate TokenFlux provider support and update related configurations

fix: update redux-persist version to 104

refactor: remove redundant tokenflux provider model assignment in migration

fix: update migration to add TokenFlux provider instead of CherryCloud

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

* feat: update TokenFlux provider assets and add new models

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

* feat: disable TokenFlux provider by default in INITIAL_PROVIDERS

* feat: add TokenFlux billing URLs to providerCharge and providerBills functions

* refactor: remove manual chunking logic from Vite configuration

- Eliminated custom output chunking strategy for worker files and node_modules from the electron Vite configuration.
- Streamlined the configuration for improved maintainability and clarity.

* chore: update release notes and improve README assets

- Updated release notes to include new features such as TokenFlux service support, Claude 4 model integration, and fixes for various issues including Windows user startup problems and search crashes.
- Replaced outdated screenshots in README files with new images across English, Japanese, and Chinese documentation.
- Enhanced the 'Related Projects' section for better visibility.

* chore(version): 1.3.11

* chore: disable code signature verification for Windows updates in electron-builder configuration

* feat: add support for Windows ARM64 architecture in bun installation script

- Included package mappings for 'win32-arm64' and 'win32-arm64-baseline' to the BUN_PACKAGES object in install-bun.js, enhancing compatibility with ARM64 devices on Windows.

* fix: floating-sidebar header sticky (#6371)

* chore: add dependabot (#6369)

* fix: improve multi-select functionality in Messages and SelectionBox components

* feat: add disable MCP server functionality and update translations (#6398)

* feat: add disable MCP server functionality and update translations

* feat: update MCPToolsButton and WebSearchButton to use CircleX icon and change labels to 'close'

---------

Co-authored-by: kangfenmao <kangfenmao@qq.com>

* feat: 文字生成图新增提供商DMXAPI (#6352)

dmxapi文字生成图

Co-authored-by: 亢奋猫 <kangfenmao@qq.com>

* fix: update MainTextBlock to use a class for markdown styling

- Changed the paragraph element in MainTextBlock to include a "markdown" class for improved styling consistency.

* fix: handle optional usage properties in AnthropicProvider (#6418)

* fix: ensure args are an array in AddMcpServerModal and MCPService com… (#6413)

fix: ensure args are an array in AddMcpServerModal and MCPService components

* fix: update dimensions handling in KnowledgeBaseParams (#6417)

fix: update dimensions handling in KnowledgeBaseParams and add supported dimension providers

* fix: MessageMenubar copy uses latest content (#6435)

* Fix: MessageMenubar copy uses latest content

The 'Copy' button in MessageMenubar was previously using memoized content
derived from component props. This could lead to copying stale content if
you edited a message (e.g., a code block), saved it, and then
immediately clicked 'Copy', because the asynchronous Redux store update
might not have completed and propagated to the props yet.

This commit modifies the onCopy function in MessageMenubar.tsx to
fetch the latest version of the message directly from the Redux store
(store.getState().messages.entities[message.id]) at the moment the
copy action is performed. This ensures that the most up-to-date content
is always copied, resolving the stale content issue.

* Chore: Remove unnecessary comments from MessageMenubar

Removes a few explanatory comments from the onCopy function in
MessageMenubar.tsx that were deemed unnecessary, to keep the code
cleaner.

The comments originated from an example provided in a previous description. The core logic of the function remains unchanged.

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>

* refactor(CodeTool): use hook for codeblock tools rather than context (#6273)

* refactor(CodeTool): use hook for codeblock tools rather than context

* fix: codeblock overflow behaviour

* fix: CodePreview scrollbar

* refactor: move margin to CodeHeader

* refactor: add min-width to codeblock

* 修复DMXAPI文生成画bug

* feat: enhance citation handling in message export functionality (#6422)

* feat: enhance citation handling in message export functionality

- Refactored message export functions to include citation content in markdown output.
- Introduced a new utility function to extract and format citations from messages.
- Updated related imports and adjusted existing markdown generation logic for improved clarity and maintainability.

* feat: enhance message export tests to include citation and reasoning content

- Added tests to verify inclusion of citation content in markdown output when citation blocks exist.
- Ensured proper formatting with double newlines between sections in exported markdown.
- Updated existing tests to handle cases with reasoning content and no main text block gracefully.

* fix: update citation mapping in export tests for consistency

- Modified the citation mapping in export tests to use the index parameter directly, improving clarity and consistency in the generated markdown output.

* fix: enhance ExportService to support nested bold and italic formatting (#6420)

* fix: enhance ExportService to support nested bold and italic formatting

- Added tracking for nested bold and italic tags in the ExportService.
- Updated text rendering logic to apply bold and italic styles based on the nesting level of the tags.

* fix: remove unused citation variable in messageToMarkdown function

* fix: enhance web search recognization in AI providers (#6423)

* fix: escape special characters in search pattern for improved filtering

* feat: throttle updateTranslationBlock dispatch for improved performance (#6442)

- Introduced throttling to the updateTranslationBlock dispatch function to limit the frequency of updates, enhancing performance during message operations.
- Utilized lodash's throttle function to ensure efficient handling of accumulated text updates.

* fix: Chinese input issue in AddProviderPopup (#6445)

* chore: remove postinstall script from package.json

* fix: improve header styling in CustomCollapse component (#6449)

* fix: return value from appUpdater.checkForUpdates in IPC handler

* docs: update README files to reflect new roadmap and feature enhancements

- Revised the TODO section to a comprehensive roadmap outlining core features, knowledge management, platform support, and advanced features.
- Added links to the project board and GitHub Discussions for community engagement and feedback.

* fix: update popup content to improve user interaction in MessageGroup component

* fix: change display style of .shiki class to flex for improved layout in CodePreview component

* docs: update README files to enhance feature listings and organization

- Renumbered feature sections for clarity and consistency across English, Japanese, and Chinese README files.
- Improved formatting by removing unnecessary bullet points for a cleaner presentation of core features, knowledge management, platform support, and advanced features.

* fix: 修复Nutstore设置中的自动同步状态和错误消息内容 (#6452)

- 在NutstoreSettings组件中,添加了设置Nutstore自动同步状态的逻辑。
- 更新NutstoreService中的错误消息内容,确保使用正确的国际化键。

* feat: add dark mode support for DMXAPI logo in settings

- Introduced a new dark mode logo for DMXAPI and updated the logo rendering logic in the DMXAPISettings component to switch between light and dark logos based on the current theme.

* chore: update electron configuration and add debug script (#6361)

* chore: update electron configuration and add debug script

- Added sourcemap generation for development builds in electron.vite.config.ts.
- Introduced a new debug script in package.json for easier debugging with remote inspection.

* docs: add debug section to development documentation

- Introduced a new section for debugging instructions, including the command to run the debug script and how to access the Chrome inspect tool.

* Feat: Supports sorting of textarea function buttons by dragging (#6268)

* feat(inputbar): add collapsible tools and localization for tool actions

* refactor(inputbar): simplify tool rendering logic in InputbarTools

* refactor(inputbar): enhance tool visibility logic and improve rendering structure in InputbarTools

* fix(inputbar): correct tooltip text for collapse/expand action in InputbarTools

* refactor(Inputbar): simplify Toolbar structure and improve styling

* chore: update release notes and fix various issues

- Updated release notes to include new DMXAPI service and fixed knowledge base search results issue.
- Enhanced drag-and-drop functionality for message selection and resolved memory exceptions in translation replies.
- Added styling adjustments for context menu and improved layout in CodeBlockView and MessageGroup components.

* chore: remove gitee provider

* refactor: streamline provider menu logic in settings

- Consolidated edit and delete menu items for providers into separate constants for improved readability and maintainability.
- Enhanced the logic for displaying menus based on provider status, ensuring correct options are presented for system providers and initial providers.

* chore(version): 1.3.12

* refactor: remove early return for empty MCP servers in MCPToolsButton

- Eliminated the conditional return for empty active MCP servers to streamline the component rendering logic.

* chore: update electron-builder configuration and package dependencies

- Modified electron-builder.yml to refine file inclusion/exclusion patterns.
- Removed and re-added dependencies in package.json for consistency and updated yarn.lock to reflect these changes.
- Cleaned up unnecessary entries in yarn.lock to streamline the dependency tree.

* test: more unit tests (#5130)

* test: more unit tests

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

* fix: mock individual properties

* test: add tests for CustomTag

* test: add tests for ExpandableText

* test: conditional rendering tooltip of tag

* chore: update dependencies

* feat: Selection Assistant / 划词助手 (#5900)

* feat(selection): implement selection assistant with toolbar and action management

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

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

* fix: toolbar hiding

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

* fix: improve logical coordinate handling in SelectionService

* fix: update URL loading and coordinate conversion in SelectionService

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

* refactor(SelectionService): enhance preloaded action window management

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

* fix: toolbar position calculating for multi monitor

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

* fix: SelectionActionUserModal layout

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

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

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

refactor: SelectionActionsList

* chore: enhance tooltip for trigger mode settings

* fix: console.log

* chore: update selection-hook to version 0.9.12

* fix: integrate language settings into selection components

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

* chore: update selection-hook package version to 0.9.13

* chore: update selection-hook package version to 0.9.14

* chore:  removed unused dependencies to reduce size (#6464)

* chore: update package dependencies and refactor BackupManager to use fs.promises

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

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

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

---------

Co-authored-by: beyondkmp <beyondkmkp@gmail.com>

* fix: cannot run from yarn dev (#6468)

* fix[SelectionAssistant]: remove console.log (#6474)

fix: remove console.log

* feat: integrate custom CSS support in SelectionAssistant

* fix: adjust order of tools in CodeToolbar constants for correct display

* chore: update @google/genai to version 1.0.1 and remove GeminiService references

- Updated the @google/genai dependency in package.json and yarn.lock to version 1.0.1.
- Removed the GeminiService and its related references from the codebase to streamline functionality.
- Introduced a new CacheService for managing cached data effectively.

* chore: update electron-builder configuration to refine file exclusion patterns

- Added exclusions for various distribution directories and module types to optimize the build process.
- Updated license file exclusions to be more inclusive of different casing variations.

* fix(MainTextBlock): adjust whiteSpace style for user role messages (#6501)

* feat: add title to selection action button in compact mode (#6498)

* feat: add title prototype to selection action button in compact mode

* fix: optimize the display name logic for action buttons in the selection toolbar

* fix: remove tiktoken

* chore: remove electron-icon-builder

* feat: painting aihubmix support model: gpt-image-1 (#6486)

* update select style

* add openai painting

* support base64 response

* update config

* fix upload preview bug

* fix remix default model

* fix history data

* feat: optimize structure

* fix history data

---------

Co-authored-by: zhaochenxue <zhaochenxue@bixin.cn>

* feat: add default painting provider support and update routing

- Introduced defaultPaintingProvider in settings to manage selected painting provider.
- Updated Sidebar component to reflect the selected painting provider in the route.
- Enhanced PaintingsRoutePage to dispatch the default painting provider based on URL parameters.
- Added PaintingProvider type to define available options for painting providers.

* feat: aihubmix painting support imagen (#6525)

* add imagen

* feat: support imagen model

* update proxy notice

---------

Co-authored-by: zhaochenxue <zhaochenxue@bixin.cn>

* refactor: TrayService & ConfigManager (#6526)

* refactor: TrayService

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

* refactor: enhance configuration management

- Updated ConfigManager to consolidate setting and notification logic into a single method, setAndNotify.
- Modified IPC handler to accept an additional parameter for notification control.
- Adjusted QuickAssistantSettings to utilize the new parameter for enabling notifications during configuration changes.

* fix: Optimize error message formatting (#5988)

* fix: Optimize error message formatting

* fix: improve error unit test

* refactor: simplify error handling in ErrorBlock component

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

* fix: update error handling in ErrorBlock component

- Removed unnecessary message prop from Alert component to simplify error display.
- Maintained existing error handling logic while improving code clarity.

* feat[SelectionAssistant]: add faq&feedback link (#6531)

feat: add FAQ button to Selection Assistant settings

* chore: refine file exclusion patterns in electron-builder configuration (#6502)

- Updated exclusion patterns to ensure more comprehensive filtering of unnecessary files and directories during the build process.
- Added additional file types and configurations to the exclusion list for better optimization.

Co-authored-by: beyondkmp <beyondkmkp@gmail.com>

* feat: support system prompt variables (#5995)

* feat: support system prompt variables

* feat: add tip

* fix ci test fail

* feat: Assistant add tag (#6065)

* feat: 添加助手标签显示逻辑
-增加助手的标签属性
-能够删除,修改,调整助手的标签
Signed-off-by: LeeSH <shuhao_lin@fzzixun.com>

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

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

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

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

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

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

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

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

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

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

---------

Signed-off-by: LeeSH <shuhao_lin@fzzixun.com>
Co-authored-by: linshuhao <nmnm1996>
Co-authored-by: Lee SH <shuhao_lin@fzzixun.com>

* fix: increase max cache limit and update slider marks in MiniAppSettings (#6414)

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

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

* Update MiniAppSettings.tsx

---------

Co-authored-by: George Zhao <georgezhao@SKJLAB>

* fix: update TikToken implementation and remove js-tiktoken dependency

- Replaced the existing TikToken implementation with a placeholder error message indicating it is not implemented.
- Removed the js-tiktoken dependency from package.json to streamline the project.
- Updated yarn.lock to reflect changes in dependencies and checksums.

* fix: update token limits for Claude-4 models and refine reasoning checks in OpenAIProvider

- Adjusted max token limit for 'claude-sonnet-4' and 'claude-opus-4' models from 64000 to 32000.
- Simplified reasoning checks in OpenAIProvider to combine conditions for supported models, enhancing code clarity.

* fix[SelectionAssistant]: interrupting in terminal apps (#6549)

fix: interrupting in terminal apps

* fix: add custom parameters to OpenAI generateImageByChat requests

* chore(version): 1.4.0-rc.1

* fix: update artifact patterns in release workflow

- Modified the artifact patterns in the GitHub Actions release workflow to include 'dist/rc*.yml' for better versioning support.

* fix(OpenAIProvider): adjust reasoning effort setting to default to 'medium' when set to 'auto'

* fix: suppress exhaustive-deps warnings in multiple components

- Added eslint-disable comments for react-hooks/exhaustive-deps in CustomCollapse, DmxapiPage, SelectionActionApp, ActionGeneral, and ActionTranslate components to prevent warnings related to missing dependencies in useEffect hooks.

* feat(SelectionAssistant): App Filter / 应用筛选 (#6519)

feat: add filter mode and list functionality to selection assistant

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

* fix: Repair abnormal line break display

* Revert "fix: Repair abnormal line break display"

This reverts commit 3d7fd5a30c.

* fix(HealthCheck): add a disclaimer (#6570)

* fix(HealthCheck): add a disclaimer

* fix: remove duplicates in zh-tw.json

* refactor: chat navigation triggering (#6576)

* refactor(ChatNavigation): move down the navigation bar

* refactor: attach listeners to MessagesContainer for better triggering experience

* refactor: add delay to Tooltips

* refactor: exclude some toolbars areas from triggering

* fix(style): global cursor style for scrollbar thumb

* fix(SvgPreview): dragging and sanitizing (#6568)

* fix(SvgPreview): dragging

* fix(SvgPreview): sanitize svg content

* feat(SelectionAssistant): support Shift+Click & enhance Ctrl key mode (#6566)

* feat: add filter mode and list functionality to selection assistant

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

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

* fix: remove comments

* fix(PROVIDER_CONFIG): update website URLs from ppinfra.com to ppio.cn

* fix(provider): update Qiniu's name and logo, fix gitee typo (#6593)

* fix(provider): update Qiniu's name and logo

* fix(provider): typo

* feat(theme): 用户自定义主题色 (#4613)

* feat(theme): 用户自定义主题色

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

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

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

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

* feat: Refactor theme management to use userTheme object for colorPrimary

* refactor(Chat, Messages): simplify maxWidth calculations and remove unused showAssistants variable

* refactor(Scrollbar, Chat, Messages): improve scroll handling and clean up component structure

* refactor(Messages): enhance message rendering and navigation exclusions

- Updated styles for message content and group containers to improve layout.
- Added new selectors to exclude additional elements from navigation.
- Implemented conditional rendering for mentions in message content.
- Simplified token display logic in message tokens component.

* feat(DisplaySettings): add theme color presets and zoom settings

- Introduced a new color selection feature in DisplaySettings, allowing users to choose from predefined theme color presets.
- Added a dedicated section for zoom settings in the DisplaySettings component, enhancing user customization options.
- Updated localization files to include new zoom settings titles in multiple languages.

* feat: 调整分组的效果 (#6561)

1,未分组标签改为未分组
2,列表展示效果持久化
3,增加一个管理列表展示效过的store

Co-authored-by: linshuhao <nmnm1996>

* refactor: standardize variable naming and improve tag calculation logic

- Renamed variables for consistency, changing `AssistantsTabSortType` to `assistantsTabSortType`.
- Refactored tag calculation in `useTags` to utilize `uniq` and `flatMap` for better performance and readability.
- Updated localization files to remove unnecessary characters in quick trigger messages.
- Enhanced the `AssistantItem` component by extracting menu item creation logic and sorting functions for better maintainability.

* test(QuickPanelView): integrate Redux store into tests and refactor rendering logic

- Added a mock Redux store to the QuickPanelView tests for better state management.
- Refactored test rendering to use a wrapper function for consistent provider usage.
- Updated Scrollbar test to verify throttle behavior with new delay and options.

* revert: fix: english serif font rendering issue #6224

This reverts commit 5dd508b4f4.

* feat(SelectionAssistant): add "Remember Window Size" functionality

- Introduced a new setting to remember the last adjusted size of the action window.
- Updated ConfigManager, SelectionService, and IPC channels to handle the new feature.
- Enhanced UI components to allow users to toggle the "Remember Size" option.
- Localized the new setting in multiple languages.

* feat: add "Regenerate" in action window

* chore(version): 1.4.0-rc.2

* feat(SelectionAssistant): improve selection in browsers and pdf readers (#6618)

fix: improve browsers and pdf readers selection

* refactor(Scrollbar, Messages): clean up scrollbar component and styles

- Removed unused 'right' prop from Scrollbar component.
- Increased scrolling timeout duration for better user experience.
- Updated scrollbar styles to simplify color handling.
- Adjusted Messages component to remove unnecessary props and added margin for better layout.
- Added responsive styles to CitationBlock for improved mobile display.

* fix: setting tab font size

* feat: dmxapi generate multiple image (#6632)

* chore(version): 1.3.8

* 新增自动添加

* 图片自增功能优化

---------

Co-authored-by: kangfenmao <kangfenmao@qq.com>

* refactor(SvgPreview): use shadow dom

* test(scrollbar): fix snapshot mismatched

* fix(MainTextBlock): update whiteSpace style for user messages to 'pre-wrap'

* fix(Messages, WebSearchProviderSetting): remove unused variables and update provider logo styling

* lint: fix eslint error and build:check

* feat: improve translation setting logic (#6463)

* feat: add auto-detect language option and improve translation logic

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

* fix: remove unused model removal function from TranslatePage component

* feat: add language detection and bidirectional translation utilities

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

* fix: improve interaction

* fix: change cld3-asm to franc

* fix: ui/ux

* fix: change eslint

* fix: update

* Revert "fix: update"

This reverts commit 1126a5cce9.

* Reapply "fix: update"

This reverts commit 82b7890f92.

* fix: setloading missing

* refactor: Theme improve (#6619)

* refactor(IpcChannel): rename theme change event and streamline theme handling

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

* refactor(Theme): standardize theme handling across components

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

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

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

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

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

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

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

* refactor(Settings): remove theme management from settings

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

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

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

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

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

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

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

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

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

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

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

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

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

* refactor(Theme): remove unused theme retrieval functionality

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

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

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

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

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

---------

Co-authored-by: beyondkmp <beyondkmkp@gmail.com>

* fix: interrupting in shell and improve pdf readers

* fix: The edit button cannot be used after using MCP. 修复对话中使用 MCP 后编辑按钮消失的问题 (#6623)

fix: The edit button cannot be used after using MCP.

* chore(version): 1.4.0-rc.3

* fix: thinking time reset (#6665)

* fix: thinking time reset

* fix: update theme listener to properly handle theme updates

---------

Co-authored-by: Pleasurecruise <3196812536@qq.com>

* feat(SelectionAssistant): predefined apps filter list (#6662)

* feat: predefined app blacklist

* fix

* fix

* fix

* fix: improve filter list processing in SelectionFilterListModal

* chore(version): 1.4.0

* refactor: sort mentioned models in QuickPanel (#6666)

* fix(OpenAIProvider): prevent atob error with non-base64 image URLs (#6673)

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

* fix: replace franc with franc-min for improved performance

* fix: provider o3 docs not found

* feat(SelectionService): enhance trigger mode handling and update predefined blacklist

* fix: adjust sidebar icon margins based on fullscreen state

* test: more unit tests for message rendering (#6663)

* refactor(encodeHTML): remove duplicate definition

* test(Scrollbar): update snapshot

* test: add more tests

Add tests for
- MainTextBlock
- ThinkingBlock
- Markdown
- CitationTooltip

* fix: token usage not updated after editing message (#6725)

fix: update token usage when edit message

* fix: qwen3 cannot name a topic (#6722)

* fix: qwen3 cannot name a topic

* feat: Display error message when topic naming fails

* fix: assistant emoji displaying incorrectly in specific situations #6243 (#6280)

* fix:  ssistant emoji displaying incorrectly in specific situations

* chore: remove unuse import

* fix: ensure default emoji

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

---------

Co-authored-by: 自由的世界人 <3196812536@qq.com>

* fix: mcp uv&bun installation status icon in nav bar not updated after… (#6654)

fix: mcp uv&bun installation status icon in nav bar not updated after installed

Signed-off-by: aprilandjan <merlin.ye@qq.com>

* hotfix(OpenAIProvider): remove redundant 'unkown' chunk (#6737)

fix(OpenAIProvider): remove redundant 'unknown' yield case in chunk processing

* hotfix: update qwen3 model identification logic to use startsWith for im… (#6738)

fix: update qwen3 model identification logic to use startsWith for improved accuracy

* feat(AppUpdater): implement localized update dialog (#6742)

feat(AppUpdater): implement localized update dialog with new translations for multiple languages

Co-authored-by: beyondkmp <beyondkmkp@gmail.com>

* Fix: outdated provider websites and models (#6766)

* fix: inappropriate provider websites (openrouter, grok)

* fix: outdated model list (grok)

* chore(gitignore): exclude cursor settings (#6779)

* fix: prevent message overflow when minimized width (#6775)

* refactor(BackupManager, WebDav): streamline WebDAV client initialization and enhance directory listing functionality (#6784)

Co-authored-by: beyondkmp <beyondkmkp@gmail.com>

* fix(SelectionAssistant): customCSS should not override background (#6746)

fix: customCSS should not override background

* feat(SelectionAssistant): Smart Translation ( aka BiDirectionTranslate) (#6715)

* feat(Translation): enhance translation functionality and UI improvements

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

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

- Added new languages (Polish, Turkish, Thai, Vietnamese, Indonesian, Urdu, Malay) to translation options in translate.ts.
- Updated localization JSON files (en-us, ja-jp, ru-ru, zh-cn, zh-tw) to include translations for the new languages.
- Enhanced language detection logic in translate.ts to support new language codes.

* fix: chat navigation triggering (#6774)

* fix: exclude MessageEditor

* fix: more accurate triggering area

* fix(ci): Update the nightly-build workflow (#6791)

Update the branch name from `develop` to `main`

* fix: optimize multilingual display of documents (#6793)

Update Sidebar.tsx

* fix: correct variable name obsidianVault in Obsidian export (#6796)

* fix: transparent window flashing when show (#6755)

* fix: avoid SelectionAssistant toolbar flashing

* add comments

* feat(SelectionAssistant): fullscreen game/presentation mode

* fix: codeblock overflow in bubble style (#6773)

* refactor: revert CodeBlockView style change

* fix: codeblock width and overflow

* refactor: improve CodeEditor border

* revert: context-menu-container width for message group

* fix(MermaidPreview): debounce mermaid rendering to alleviate flickering (#6675)

* hotfix: gemini auto thinking (#6810)

* fix(SelectionAssistant): JetBrains IDEs, Remote desktop, Gaming, PDF views, etc (#6809)

fix: jetbrains ides, remote desktop, pdf views, etcs

* fix: use monospace font for theme colorpicker (#6816)

* fix(AnthropicProvider): update usage and metrics handling to prevent TypeError  (#6813)

* fix: sync active topic after rename (#6804)

Co-authored-by: Chen Tao <70054568+eeee0717@users.noreply.github.com>

* fix: OpenAI provider api check doesn't handle error (#6769)

* feat(constants): expand supported file extensions and categorize text… (#6815)

* feat(constants): expand supported file extensions and categorize text file types

* refactor(constants): remove binary file extensions

* refactor(constants): remove Xcode project

* feat(Settings): Add token count display toggle (#6772)

* feat(Settings): add token count toggle

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

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

---------

Co-authored-by: Pleasurecruise <3196812536@qq.com>

* hotfix: ensure show token usage setting defaults to true (#6828)

Hotfix: ensure show token usage setting defaults to true

* fix(SelectionAssistant): ignore ctrl pressing when user is zooming in/out (#6822)

* fix(SelectionService): ignore ctrl pressing when user is zomming in/out

* chore: rename function

* fix: reset listener status

* refactor: enhance export functions (#5854)

* feat(markdown-export): add option to show model name in export

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

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

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

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

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

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

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

* fix(settings): remove duplicate showModelNameInMarkdown state

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

* feat(export): update Notion settings i18n

* fix(utils): correct citation markdown formatting

Swap citation title and URL positions in markdown links to ensure
the link text displays the title (or URL if title is missing) and
the link points to the correct URL. This improves citation clarity.

* fix: add blank lines between reasoning summary parts (#6827)

Co-authored-by: Chen Tao <70054568+eeee0717@users.noreply.github.com>

* support tokenflux image generation for [Flux.1 Kontext] (#6705)

* Add support for TokenFlux image generation service

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

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

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

* Add image upload support and comparison view to TokenFlux

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

* Refactor TokenFlux to use service class and components

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

* Refactor TokenFlux to fix state management and polling

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

* Auto-select first model when models are loaded

* Add image generation UI localization strings

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

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

* style: Remove padding from UploadedImageContainer in TokenFluxPage

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

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

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

---------

Co-authored-by: kangfenmao <kangfenmao@qq.com>

* feat: optimize UI interface display

* refactor(i18n): reorganize Notion settings localization strings in ja-jp.json

This commit restructures the localization strings for Notion settings in the Japanese language file, moving them from a nested structure to a more accessible format. This change improves clarity and maintainability of the localization data.

* chore: update OpenAI package to version 5.1.0 and adjust related patches (#6838)

* chore: update OpenAI package to version 5.1.0 and adjust related patches

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

* refactor(OpenAIResponseProvider): remove logging for image generation process

* fix(AssistantsTab): remove untagged group title

This commit updates the AssistantsTab component to only display group titles for tagged assistants, excluding the 'untagged' category. This change enhances the UI by reducing clutter and improving clarity in the display of assistant groups.

* chore: remove unused Delete tokenflux_painting_page.md (#6840)

Delete tokenflux_painting_page.md

* fix: Improve the switching logic in multi-tab state

* chore(version): 1.4.1

* 更新版本

* 解决冲突

---------

Signed-off-by: jtsang4 <info@jtsang.me>
Signed-off-by: LeeSH <shuhao_lin@fzzixun.com>
Signed-off-by: Chan Lee <Leetimemp@gmail.com>
Signed-off-by: aprilandjan <merlin.ye@qq.com>
Co-authored-by: Morax <100508620+fzlzjerry@users.noreply.github.com>
Co-authored-by: 亢奋猫 <kangfenmao@qq.com>
Co-authored-by: suyao <sy20010504@gmail.com>
Co-authored-by: jwcrystal <121911854+jwcrystal@users.noreply.github.com>
Co-authored-by: beyondkmp <beyondkmp@gmail.com>
Co-authored-by: beyondkmp <beyondkmkp@gmail.com>
Co-authored-by: one <wangan.cs@gmail.com>
Co-authored-by: fullex <106392080+0xfullex@users.noreply.github.com>
Co-authored-by: Wei <huangwei@burncloud.com>
Co-authored-by: karl <jialongkou@163.com>
Co-authored-by: Zhaker <0x149527@gmail.com>
Co-authored-by: George Zhao <georgezhao@SKJLAB>
Co-authored-by: LiuVaayne <10231735+vaayne@users.noreply.github.com>
Co-authored-by: 自由的世界人 <3196812536@qq.com>
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
Co-authored-by: chenxue <DDU1222@users.noreply.github.com>
Co-authored-by: zhaochenxue <zhaochenxue@bixin.cn>
Co-authored-by: MyPrototypeWhat <43230886+MyPrototypeWhat@users.noreply.github.com>
Co-authored-by: hutchisr <42283663+hutchisr@users.noreply.github.com>
Co-authored-by: jtsang4 <info@jtsang.me>
Co-authored-by: Caelan <79105826+jin-wang-c@users.noreply.github.com>
Co-authored-by: w <772814125@qq.com>
Co-authored-by: iola1999 <iola1999@foxmail.com>
Co-authored-by: fullex <0xfullex@gmail.com>
Co-authored-by: Konv Suu <2583695112@qq.com>
Co-authored-by: kanweiwei <kanweiwei@nutstore.net>
Co-authored-by: Roland <shlroland1995@gmail.com>
Co-authored-by: Teo <cheesen.xu@gmail.com>
Co-authored-by: shiquda <czy1847259360@gmail.com>
Co-authored-by: purefkh <purefkh@gmail.com>
Co-authored-by: nmnmtttt <34001432+nmnmtttt@users.noreply.github.com>
Co-authored-by: Lee SH <shuhao_lin@fzzixun.com>
Co-authored-by: George Zhao <38124587+CreatorZZY@users.noreply.github.com>
Co-authored-by: FunJim <xunjin.zheng@outlook.com>
Co-authored-by: stevending1st <stevending1st@163.com>
Co-authored-by: Alain <yinxulai@hotmail.com>
Co-authored-by: MyPrototypeWhat <daoquqiexing@gmail.com>
Co-authored-by: Rudbeckia.hirta.L <83773602@qq.com>
Co-authored-by: Doekin <105162544+Doekin@users.noreply.github.com>
Co-authored-by: icarus <eurfelux@gmail.com>
Co-authored-by: Wang Jiyuan <59059173+EurFelux@users.noreply.github.com>
Co-authored-by: May <aprilandjan@users.noreply.github.com>
Co-authored-by: Lucas <lucas04@foxmail.com>
Co-authored-by: Murphy <69335326+MurphyLo@users.noreply.github.com>
Co-authored-by: Chen Tao <70054568+eeee0717@users.noreply.github.com>
Co-authored-by: 熊可狸 <me@korin.im>
Co-authored-by: George·Dong <98630204+GeorgeDong32@users.noreply.github.com>
2025-06-24 13:32:15 +08:00
kangfenmao
8fd57d0271 refactor: windows7 support 2025-05-07 10:14:27 +08:00
473 changed files with 14217 additions and 60604 deletions

View File

@@ -1,17 +1,86 @@
version: 2
updates:
- package-ecosystem: 'github-actions'
directory: '/'
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: 'monthly'
interval: "monthly"
open-pull-requests-limit: 7
target-branch: "main"
commit-message:
prefix: "chore"
include: "scope"
groups:
# 核心框架
core-framework:
patterns:
- "react"
- "react-dom"
- "electron"
- "typescript"
- "@types/react*"
- "@types/node"
update-types:
- "minor"
- "patch"
# Electron 生态和构建工具
electron-build:
patterns:
- "electron-*"
- "@electron*"
- "vite"
- "@vitejs/*"
- "dotenv-cli"
- "rollup-plugin-*"
- "@swc/*"
update-types:
- "minor"
- "patch"
# 测试工具
testing-tools:
patterns:
- "vitest"
- "@vitest/*"
- "playwright"
- "@playwright/*"
- "eslint*"
- "@eslint*"
- "prettier"
- "husky"
- "lint-staged"
update-types:
- "minor"
- "patch"
# CherryStudio 自定义包
cherrystudio-packages:
patterns:
- "@cherrystudio/*"
update-types:
- "minor"
- "patch"
# 兜底其他 dependencies
other-dependencies:
dependency-type: "production"
# 兜底其他 devDependencies
other-dev-dependencies:
dependency-type: "development"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 3
commit-message:
prefix: 'ci'
include: 'scope'
prefix: "ci"
include: "scope"
groups:
github-actions:
patterns:
- '*'
- "*"
update-types:
- 'minor'
- 'patch'
- "minor"
- "patch"

View File

@@ -44,4 +44,4 @@ jobs:
run: yarn build:check
- name: Lint Check
run: yarn test:lint
run: yarn lint

View File

@@ -27,7 +27,7 @@ jobs:
- name: Check out Git repository
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: main
- name: Get release tag
id: get-tag
@@ -79,7 +79,6 @@ jobs:
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 }}
- name: Build Mac
if: matrix.os == 'macos-latest'
@@ -96,7 +95,6 @@ jobs:
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 }}
- name: Build Windows
if: matrix.os == 'windows-latest'
@@ -107,7 +105,6 @@ jobs:
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 }}
- name: Release
uses: ncipollo/release-action@v1
@@ -118,38 +115,3 @@ jobs:
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 }}
dispatch-docs-update:
needs: release
if: success() && github.repository == 'CherryHQ/cherry-studio' # 确保所有构建成功且在主仓库中运行
runs-on: ubuntu-latest
steps:
- name: Get release tag
id: get-tag
shell: bash
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "tag=${{ github.event.inputs.tag }}" >> $GITHUB_OUTPUT
else
echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
fi
- name: Check if tag is pre-release
id: check-tag
shell: bash
run: |
TAG="${{ steps.get-tag.outputs.tag }}"
if [[ "$TAG" == *"rc"* || "$TAG" == *"pre-release"* ]]; then
echo "is_pre_release=true" >> $GITHUB_OUTPUT
else
echo "is_pre_release=false" >> $GITHUB_OUTPUT
fi
- name: Dispatch update-download-version workflow to cherry-studio-docs
if: steps.check-tag.outputs.is_pre_release == 'false'
uses: peter-evans/repository-dispatch@v3
with:
token: ${{ secrets.REPO_DISPATCH_TOKEN }}
repository: CherryHQ/cherry-studio-docs
event-type: update-download-version
client-payload: '{"version": "${{ steps.get-tag.outputs.tag }}"}'

1
.vscode/launch.json vendored
View File

@@ -7,6 +7,7 @@
"request": "launch",
"cwd": "${workspaceRoot}",
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite",
"runtimeVersion": "20",
"windows": {
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite.cmd"
},

View File

@@ -1,8 +1,7 @@
{
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.organizeImports": "never"
"source.fixAll.eslint": "explicit"
},
"search.exclude": {
"**/dist/**": true,

File diff suppressed because one or more lines are too long

View File

@@ -1,69 +0,0 @@
diff --git a/es/dropdown/dropdown.js b/es/dropdown/dropdown.js
index 986877a762b9ad0aca596a8552732cd12d2eaabb..1f18aa2ea745e68950e4cee16d4d655f5c835fd5 100644
--- a/es/dropdown/dropdown.js
+++ b/es/dropdown/dropdown.js
@@ -2,7 +2,7 @@
import * as React from 'react';
import LeftOutlined from "@ant-design/icons/es/icons/LeftOutlined";
-import RightOutlined from "@ant-design/icons/es/icons/RightOutlined";
+import { ChevronRight } from 'lucide-react';
import classNames from 'classnames';
import RcDropdown from 'rc-dropdown';
import useEvent from "rc-util/es/hooks/useEvent";
@@ -158,8 +158,10 @@ const Dropdown = props => {
className: `${prefixCls}-menu-submenu-arrow`
}, direction === 'rtl' ? (/*#__PURE__*/React.createElement(LeftOutlined, {
className: `${prefixCls}-menu-submenu-arrow-icon`
- })) : (/*#__PURE__*/React.createElement(RightOutlined, {
- className: `${prefixCls}-menu-submenu-arrow-icon`
+ })) : (/*#__PURE__*/React.createElement(ChevronRight, {
+ size: 16,
+ strokeWidth: 1.8,
+ className: `${prefixCls}-menu-submenu-arrow-icon lucide-custom`
}))),
mode: "vertical",
selectable: false,
diff --git a/es/dropdown/style/index.js b/es/dropdown/style/index.js
index 768c01783002c6901c85a73061ff6b3e776a60ce..39b1b95a56cdc9fb586a193c3adad5141f5cf213 100644
--- a/es/dropdown/style/index.js
+++ b/es/dropdown/style/index.js
@@ -240,7 +240,8 @@ const genBaseStyle = token => {
marginInlineEnd: '0 !important',
color: token.colorTextDescription,
fontSize: fontSizeIcon,
- fontStyle: 'normal'
+ fontStyle: 'normal',
+ marginTop: 3,
}
}
}),
diff --git a/es/select/useIcons.js b/es/select/useIcons.js
index 959115be936ef8901548af2658c5dcfdc5852723..c812edd52123eb0faf4638b1154fcfa1b05b513b 100644
--- a/es/select/useIcons.js
+++ b/es/select/useIcons.js
@@ -4,10 +4,10 @@ import * as React from 'react';
import CheckOutlined from "@ant-design/icons/es/icons/CheckOutlined";
import CloseCircleFilled from "@ant-design/icons/es/icons/CloseCircleFilled";
import CloseOutlined from "@ant-design/icons/es/icons/CloseOutlined";
-import DownOutlined from "@ant-design/icons/es/icons/DownOutlined";
import LoadingOutlined from "@ant-design/icons/es/icons/LoadingOutlined";
import SearchOutlined from "@ant-design/icons/es/icons/SearchOutlined";
import { devUseWarning } from '../_util/warning';
+import { ChevronDown } from 'lucide-react';
export default function useIcons(_ref) {
let {
suffixIcon,
@@ -56,8 +56,10 @@ export default function useIcons(_ref) {
className: iconCls
}));
}
- return getSuffixIconNode(/*#__PURE__*/React.createElement(DownOutlined, {
- className: iconCls
+ return getSuffixIconNode(/*#__PURE__*/React.createElement(ChevronDown, {
+ size: 16,
+ strokeWidth: 1.8,
+ className: `${iconCls} lucide-custom`
}));
};
}

View File

@@ -65,44 +65,11 @@ index e8bd7bb46c8a54b3f55cf3a853ef924195271e01..f956e9f3fe9eb903c78aef3502553b01
await packager.info.emitArtifactBuildCompleted({
file: installerPath,
updateInfo,
diff --git a/out/util/yarn.js b/out/util/yarn.js
index 1ee20f8b252a8f28d0c7b103789cf0a9a427aec1..c2878ec54d57da50bf14225e0c70c9c88664eb8a 100644
--- a/out/util/yarn.js
+++ b/out/util/yarn.js
@@ -140,6 +140,7 @@ async function rebuild(config, { appDir, projectDir }, options) {
arch,
platform,
buildFromSource,
+ ignoreModules: config.excludeReBuildModules || undefined,
projectRootPath: projectDir,
mode: config.nativeRebuilder || "sequential",
disablePreGypCopy: true,
diff --git a/scheme.json b/scheme.json
index 433e2efc9cef156ff5444f0c4520362ed2ef9ea7..0167441bf928a92f59b5dbe70b2317a74dda74c9 100644
index 433e2efc9cef156ff5444f0c4520362ed2ef9ea7..a89c7a9b0b608fef67902c49106a43ebd0fa8b61 100644
--- a/scheme.json
+++ b/scheme.json
@@ -1825,6 +1825,20 @@
"string"
]
},
+ "excludeReBuildModules": {
+ "anyOf": [
+ {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "The modules to exclude from the rebuild."
+ },
"executableArgs": {
"anyOf": [
{
@@ -1975,6 +1989,13 @@
@@ -1975,6 +1975,13 @@
],
"description": "The mime types in addition to specified in the file associations. Use it if you don't want to register a new mime type, but reuse existing."
},
@@ -116,7 +83,7 @@ index 433e2efc9cef156ff5444f0c4520362ed2ef9ea7..0167441bf928a92f59b5dbe70b2317a7
"packageCategory": {
"description": "backward compatibility + to allow specify fpm-only category for all possible fpm targets in one place",
"type": [
@@ -2327,6 +2348,13 @@
@@ -2327,6 +2334,13 @@
"MacConfiguration": {
"additionalProperties": false,
"properties": {
@@ -130,28 +97,7 @@ index 433e2efc9cef156ff5444f0c4520362ed2ef9ea7..0167441bf928a92f59b5dbe70b2317a7
"additionalArguments": {
"anyOf": [
{
@@ -2527,6 +2555,20 @@
"string"
]
},
+ "excludeReBuildModules": {
+ "anyOf": [
+ {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "The modules to exclude from the rebuild."
+ },
"executableName": {
"description": "The executable name. Defaults to `productName`.",
"type": [
@@ -2737,7 +2779,7 @@
@@ -2737,7 +2751,7 @@
"type": "boolean"
},
"minimumSystemVersion": {
@@ -160,7 +106,7 @@ index 433e2efc9cef156ff5444f0c4520362ed2ef9ea7..0167441bf928a92f59b5dbe70b2317a7
"type": [
"null",
"string"
@@ -2959,6 +3001,13 @@
@@ -2959,6 +2973,13 @@
"MasConfiguration": {
"additionalProperties": false,
"properties": {
@@ -174,28 +120,7 @@ index 433e2efc9cef156ff5444f0c4520362ed2ef9ea7..0167441bf928a92f59b5dbe70b2317a7
"additionalArguments": {
"anyOf": [
{
@@ -3159,6 +3208,20 @@
"string"
]
},
+ "excludeReBuildModules": {
+ "anyOf": [
+ {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "The modules to exclude from the rebuild."
+ },
"executableName": {
"description": "The executable name. Defaults to `productName`.",
"type": [
@@ -3369,7 +3432,7 @@
@@ -3369,7 +3390,7 @@
"type": "boolean"
},
"minimumSystemVersion": {
@@ -204,28 +129,7 @@ index 433e2efc9cef156ff5444f0c4520362ed2ef9ea7..0167441bf928a92f59b5dbe70b2317a7
"type": [
"null",
"string"
@@ -6381,6 +6444,20 @@
"string"
]
},
+ "excludeReBuildModules": {
+ "anyOf": [
+ {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "The modules to exclude from the rebuild."
+ },
"executableName": {
"description": "The executable name. Defaults to `productName`.",
"type": [
@@ -6507,6 +6584,13 @@
@@ -6507,6 +6528,13 @@
"string"
]
},
@@ -239,28 +143,7 @@ index 433e2efc9cef156ff5444f0c4520362ed2ef9ea7..0167441bf928a92f59b5dbe70b2317a7
"protocols": {
"anyOf": [
{
@@ -7153,6 +7237,20 @@
"string"
]
},
+ "excludeReBuildModules": {
+ "anyOf": [
+ {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "The modules to exclude from the rebuild."
+ },
"executableName": {
"description": "The executable name. Defaults to `productName`.",
"type": [
@@ -7376,6 +7474,13 @@
@@ -7376,6 +7404,13 @@
],
"description": "MAS (Mac Application Store) development options (`mas-dev` target)."
},

View File

@@ -151,7 +151,7 @@ index 2404264d4ba0204322548945ebb7eab3bea82173..8f1bc45cc45e0797d50989d96b51147b
+ "embeddings/decoding base64 embeddings from base64"
+ );
+ return response._thenUnwrap((response) => {
+ if (response && response.data && typeof response.data[0]?.embedding === 'string') {
+ if (response && response.data) {
+ response.data.forEach((embeddingBase64Obj) => {
+ const embeddingBase64Str = embeddingBase64Obj.embedding;
+ embeddingBase64Obj.embedding = (0, utils_1.toFloat32Array)(
@@ -266,7 +266,7 @@ index 19dcaef578c194a89759c4360073cfd4f7dd2cbf..0284e9cc615c900eff508eb595f7360a
+ "embeddings/decoding base64 embeddings from base64"
+ );
+ return response._thenUnwrap((response) => {
+ if (response && response.data && typeof response.data[0]?.embedding === 'string') {
+ if (response && response.data) {
+ response.data.forEach((embeddingBase64Obj) => {
+ const embeddingBase64Str = embeddingBase64Obj.embedding;
+ embeddingBase64Obj.embedding = toFloat32Array(embeddingBase64Str);

201
README.md
View File

@@ -1,74 +1,12 @@
<div align="right" >
<details>
<summary >🌐 Language</summary>
<div>
<div align="right">
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=en">English</a></p>
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=zh-CN">简体中文</a></p>
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=zh-TW">繁體中文</a></p>
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=ja">日本語</a></p>
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=ko">한국어</a></p>
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=hi">हिन्दी</a></p>
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=th">ไทย</a></p>
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=fr">Français</a></p>
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=de">Deutsch</a></p>
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=es">Español</a></p>
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=it">Itapano</a></p>
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=ru">Русский</a></p>
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=pt">Português</a></p>
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=nl">Nederlands</a></p>
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=pl">Polski</a></p>
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=ar">العربية</a></p>
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=fa">فارسی</a></p>
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=tr">Türkçe</a></p>
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=vi">Tiếng Việt</a></p>
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=id">Bahasa Indonesia</a></p>
</div>
</div>
</details>
</div>
<h1 align="center">
<a href="https://github.com/CherryHQ/cherry-studio/releases">
<img src="https://github.com/CherryHQ/cherry-studio/blob/main/build/icon.png?raw=true" width="150" height="150" alt="banner" /><br>
</a>
</h1>
<p align="center">English | <a href="./docs/README.zh.md">中文</a> | <a href="./docs/README.ja.md">日本語</a> | <a href="https://cherry-ai.com">Official Site</a> | <a href="https://docs.cherry-ai.com/cherry-studio-wen-dang/en-us">Documents</a> | <a href="./docs/dev.md">Development</a> | <a href="https://github.com/CherryHQ/cherry-studio/issues">Feedback</a><br></p>
<!-- 题头徽章组合 -->
<p align="center">English | <a href="./docs/README.zh.md">中文</a> | <a href="./docs/README.ja.md">日本語</a><br></p>
<div align="center">
[![][deepwiki-shield]][deepwiki-link]
[![][twitter-shield]][twitter-link]
[![][discord-shield]][discord-link]
[![][telegram-shield]][telegram-link]
</div>
<!-- 项目统计徽章 -->
<div align="center">
[![][github-stars-shield]][github-stars-link]
[![][github-forks-shield]][github-forks-link]
[![][github-release-shield]][github-release-link]
[![][github-contributors-shield]][github-contributors-link]
</div>
<div align="center">
[![][license-shield]][license-link]
[![][commercial-shield]][commercial-link]
[![][sponsor-shield]][sponsor-link]
</div>
<div align="center">
<a href="https://hellogithub.com/repository/1605492e1e2a4df3be07abfa4578dd37" target="_blank"><img src="https://api.hellogithub.com/v1/widgets/recommend.svg?rid=1605492e1e2a4df3be07abfa4578dd37" alt="FeaturedHelloGitHub" style="width: 200px; height: 43px;" width="200" height="43" /></a>
<a href="https://trendshift.io/repositories/11772" target="_blank"><img src="https://trendshift.io/api/badge/repositories/11772" alt="kangfenmao%2Fcherry-studio | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
<a href="https://www.producthunt.com/posts/cherry-studio?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cherry&#0045;studio" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=496640&theme=light" alt="Cherry&#0032;Studio - AI&#0032;Chatbots&#0044;&#0032;AI&#0032;Desktop&#0032;Client | Product Hunt" style="width: 200px; height: 43px;" width="200" height="43" /></a>
<a href="https://www.producthunt.com/posts/cherry-studio?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cherry&#0045;studio" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=496640&theme=light" alt="Cherry&#0032;Studio - AI&#0032;Chatbots&#0044;&#0032;AI&#0032;Desktop&#0032;Client | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
</div>
# 🍒 Cherry Studio
@@ -79,6 +17,10 @@ Cherry Studio is a desktop client that supports for multiple LLM providers, avai
❤️ Like Cherry Studio? Give it a star 🌟 or [Sponsor](docs/sponsor.md) to support the development!
# 📖 Guide
<https://docs.cherry-ai.com>
# 🌠 Screenshot
![](https://github.com/user-attachments/assets/36dddb2c-e0fb-4a5f-9411-91447bab6e18)
@@ -172,6 +114,14 @@ Want to influence our roadmap? Join our [GitHub Discussions](https://github.com/
Welcome PR for more themes
# 🖥️ Develop
Refer to the [development documentation](docs/dev.md)
Refer to the [Architecture overview documentation](https://deepwiki.com/CherryHQ/cherry-studio)
Refer to the [Branching Strategy](docs/branching-strategy-en.md) for contribution guidelines
# 🤝 Contributing
We welcome contributions to Cherry Studio! Here are some ways you can contribute:
@@ -184,8 +134,6 @@ We welcome contributions to Cherry Studio! Here are some ways you can contribute
6. **Community Engagement**: Join discussions and help users.
7. **Promote Usage**: Spread the word about Cherry Studio.
Refer to the [Branching Strategy](docs/branching-strategy-en.md) for contribution guidelines
## Getting Started
1. **Fork the Repository**: Fork and clone it to your local machine.
@@ -197,78 +145,6 @@ For more detailed guidelines, please refer to our [Contributing Guide](./CONTRIB
Thank you for your support and contributions!
# 🔧 Developer Co-creation Program
We are launching the Cherry Studio Developer Co-creation Program to foster a healthy and positive-feedback loop within the open-source ecosystem. We believe that great software is built collaboratively, and every merged pull request breathes new life into the project.
We sincerely invite you to join our ranks of contributors and shape the future of Cherry Studio with us.
## Contributor Rewards Program
To give back to our core contributors and create a virtuous cycle, we have established the following long-term incentive plan.
**The inaugural tracking period for this program will be Q3 2025 (July, August, September). Rewards for this cycle will be distributed on October 1st.**
Within any tracking period (e.g., July 1st to September 30th for the first cycle), any developer who contributes more than **30 meaningful commits** to any of Cherry Studio's open-source projects on GitHub is eligible for the following benefits:
- **Cursor Subscription Sponsorship**: Receive a **$70 USD** credit or reimbursement for your [Cursor](https://cursor.sh/) subscription, making AI your most efficient coding partner.
- **Unlimited Model Access**: Get **unlimited** API calls for the **DeepSeek** and **Qwen** models.
- **Cutting-Edge Tech Access**: Enjoy occasional perks, including API access to models like **Claude**, **Gemini**, and **OpenAI**, keeping you at the forefront of technology.
## Growing Together & Future Plans
A vibrant community is the driving force behind any sustainable open-source project. As Cherry Studio grows, so will our rewards program. We are committed to continuously aligning our benefits with the best-in-class tools and resources in the industry. This ensures our core contributors receive meaningful support, creating a positive cycle where developers, the community, and the project grow together.
**Moving forward, the project will also embrace an increasingly open stance to give back to the entire open-source community.**
## How to Get Started?
We look forward to your first Pull Request!
You can start by exploring our repositories, picking up a `good first issue`, or proposing your own enhancements. Every commit is a testament to the spirit of open source.
Thank you for your interest and contributions.
Let's build together.
# 🏢 Enterprise Edition
Building on the Community Edition, we are proud to introduce **Cherry Studio Enterprise Edition**—a privately deployable AI productivity and management platform designed for modern teams and enterprises.
The Enterprise Edition addresses core challenges in team collaboration by centralizing the management of AI resources, knowledge, and data. It empowers organizations to enhance efficiency, foster innovation, and ensure compliance, all while maintaining 100% control over their data in a secure environment.
## Core Advantages
- **Unified Model Management**: Centrally integrate and manage various cloud-based LLMs (e.g., OpenAI, Anthropic, Google Gemini) and locally deployed private models. Employees can use them out-of-the-box without individual configuration.
- **Enterprise-Grade Knowledge Base**: Build, manage, and share team-wide knowledge bases. Ensure knowledge is retained and consistent, enabling team members to interact with AI based on unified and accurate information.
- **Fine-Grained Access Control**: Easily manage employee accounts and assign role-based permissions for different models, knowledge bases, and features through a unified admin backend.
- **Fully Private Deployment**: Deploy the entire backend service on your on-premises servers or private cloud, ensuring your data remains 100% private and under your control to meet the strictest security and compliance standards.
- **Reliable Backend Services**: Provides stable API services, enterprise-grade data backup and recovery mechanisms to ensure business continuity.
## ✨ Online Demo
> 🚧 **Public Beta Notice**
>
> The Enterprise Edition is currently in its early public beta stage, and we are actively iterating and optimizing its features. We are aware that it may not be perfectly stable yet. If you encounter any issues or have valuable suggestions during your trial, we would be very grateful if you could contact us via email to provide feedback.
**🔗 [Cherry Studio Enterprise](https://www.cherry-ai.com/enterprise)**
## Version Comparison
| Feature | Community Edition | Enterprise Edition |
| :---------------- | :----------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------- |
| **Open Source** | ✅ Yes | ⭕️ part. released to cust. |
| **Cost** | Free for Personal Use / Commercial License | Buyout / Subscription Fee |
| **Admin Backend** | — | ● Centralized **Model** Access<br>● **Employee** Management<br>● Shared **Knowledge Base**<br>● **Access** Control<br>● **Data** Backup |
| **Server** | — | ✅ Dedicated Private Deployment |
## Get the Enterprise Edition
We believe the Enterprise Edition will become your team's AI productivity engine. If you are interested in Cherry Studio Enterprise Edition and would like to learn more, request a quote, or schedule a demo, please contact us.
- **For Business Inquiries & Purchasing**:
**📧 [bd@cherry-ai.com](mailto:bd@cherry-ai.com)**
# 🔗 Related Projects
- [one-api](https://github.com/songquanpeng/one-api):LLM API management and distribution system, supporting mainstream models like OpenAI, Azure, and Anthropic. Features unified API interface, suitable for key management and secondary distribution.
@@ -282,37 +158,22 @@ We believe the Enterprise Edition will become your team's AI productivity engine
</a>
<br /><br />
# 🌐 Community
[Telegram](https://t.me/CherryStudioAI) | [Email](mailto:support@cherry-ai.com) | [Twitter](https://x.com/kangfenmao)
# ☕ Sponsor
[Buy Me a Coffee](docs/sponsor.md)
# 📃 License
[LICENSE](./LICENSE)
# ✉️ Contact
<yinsenho@cherry-ai.com>
# ⭐️ Star History
[![Star History Chart](https://api.star-history.com/svg?repos=CherryHQ/cherry-studio&type=Timeline)](https://star-history.com/#CherryHQ/cherry-studio&Timeline)
<!-- Links & Images -->
[deepwiki-shield]: https://img.shields.io/badge/Deepwiki-CherryHQ-0088CC?style=plastic
[deepwiki-link]: https://deepwiki.com/CherryHQ/cherry-studio
[twitter-shield]: https://img.shields.io/badge/Twitter-CherryStudioApp-0088CC?style=plastic&logo=x
[twitter-link]: https://twitter.com/CherryStudioHQ
[discord-shield]: https://img.shields.io/badge/Discord-@CherryStudio-0088CC?style=plastic&logo=discord
[discord-link]: https://discord.gg/wez8HtpxqQ
[telegram-shield]: https://img.shields.io/badge/Telegram-@CherryStudioAI-0088CC?style=plastic&logo=telegram
[telegram-link]: https://t.me/CherryStudioAI
<!-- Links & Images -->
[github-stars-shield]: https://img.shields.io/github/stars/CherryHQ/cherry-studio?style=social
[github-stars-link]: https://github.com/CherryHQ/cherry-studio/stargazers
[github-forks-shield]: https://img.shields.io/github/forks/CherryHQ/cherry-studio?style=social
[github-forks-link]: https://github.com/CherryHQ/cherry-studio/network
[github-release-shield]: https://img.shields.io/github/v/release/CherryHQ/cherry-studio
[github-release-link]: https://github.com/CherryHQ/cherry-studio/releases
[github-contributors-shield]: https://img.shields.io/github/contributors/CherryHQ/cherry-studio
[github-contributors-link]: https://github.com/CherryHQ/cherry-studio/graphs/contributors
<!-- Links & Images -->
[license-shield]: https://img.shields.io/badge/License-AGPLv3-important.svg?style=plastic&logo=gnu
[license-link]: https://www.gnu.org/licenses/agpl-3.0
[commercial-shield]: https://img.shields.io/badge/License-Contact-white.svg?style=plastic&logoColor=white&logo=telegram&color=blue
[commercial-link]: mailto:license@cherry-ai.com?subject=Commercial%20License%20Inquiry
[sponsor-shield]: https://img.shields.io/badge/Sponsor-FF6699.svg?style=plastic&logo=githubsponsors&logoColor=white
[sponsor-link]: https://github.com/CherryHQ/cherry-studio/blob/main/docs/sponsor.md
[![Star History Chart](https://api.star-history.com/svg?repos=kangfenmao/cherry-studio&type=Timeline)](https://star-history.com/#kangfenmao/cherry-studio&Timeline)

View File

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

View File

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

View File

@@ -1,214 +0,0 @@
# 如何为 AI Provider 编写中间件
本文档旨在指导开发者如何为我们的 AI Provider 框架创建和集成自定义中间件。中间件提供了一种强大而灵活的方式来增强、修改或观察 Provider 方法的调用过程,例如日志记录、缓存、请求/响应转换、错误处理等。
## 架构概览
我们的中间件架构借鉴了 Redux 的三段式设计,并结合了 JavaScript Proxy 来动态地将中间件应用于 Provider 的方法。
- **Proxy**: 拦截对 Provider 方法的调用,并将调用引导至中间件链。
- **中间件链**: 一系列按顺序执行的中间件函数。每个中间件都可以处理请求/响应,然后将控制权传递给链中的下一个中间件,或者在某些情况下提前终止链。
- **上下文 (Context)**: 一个在中间件之间传递的对象携带了关于当前调用的信息如方法名、原始参数、Provider 实例、以及中间件自定义的数据)。
## 中间件的类型
目前主要支持两种类型的中间件,它们共享相似的结构但针对不同的场景:
1. **`CompletionsMiddleware`**: 专门为 `completions` 方法设计。这是最常用的中间件类型,因为它允许对 AI 模型的核心聊天/文本生成功能进行精细控制。
2. **`ProviderMethodMiddleware`**: 通用中间件,可以应用于 Provider 上的任何其他方法(例如,`translate`, `summarize` 等,如果这些方法也通过中间件系统包装)。
## 编写一个 `CompletionsMiddleware`
`CompletionsMiddleware` 的基本签名TypeScript 类型)如下:
```typescript
import { AiProviderMiddlewareCompletionsContext, CompletionsParams, MiddlewareAPI } from './AiProviderMiddlewareTypes' // 假设类型定义文件路径
export type CompletionsMiddleware = (
api: MiddlewareAPI<AiProviderMiddlewareCompletionsContext, [CompletionsParams]>
) => (
next: (context: AiProviderMiddlewareCompletionsContext, params: CompletionsParams) => Promise<any> // next 返回 Promise<any> 代表原始SDK响应或下游中间件的结果
) => (context: AiProviderMiddlewareCompletionsContext, params: CompletionsParams) => Promise<void> // 最内层函数通常返回 Promise<void>,因为结果通过 onChunk 或 context 副作用传递
```
让我们分解这个三段式结构:
1. **第一层函数 `(api) => { ... }`**:
- 接收一个 `api` 对象。
- `api` 对象提供了以下方法:
- `api.getContext()`: 获取当前调用的上下文对象 (`AiProviderMiddlewareCompletionsContext`)。
- `api.getOriginalArgs()`: 获取传递给 `completions` 方法的原始参数数组 (即 `[CompletionsParams]`)。
- `api.getProviderId()`: 获取当前 Provider 的 ID。
- `api.getProviderInstance()`: 获取原始的 Provider 实例。
- 此函数通常用于进行一次性的设置或获取所需的服务/配置。它返回第二层函数。
2. **第二层函数 `(next) => { ... }`**:
- 接收一个 `next` 函数。
- `next` 函数代表了中间件链中的下一个环节。调用 `next(context, params)` 会将控制权传递给下一个中间件,或者如果当前中间件是链中的最后一个,则会调用核心的 Provider 方法逻辑 (例如,实际的 SDK 调用)。
- `next` 函数接收当前的 `context``params` (这些可能已被上游中间件修改)。
- **重要的是**`next` 的返回类型通常是 `Promise<any>`。对于 `completions` 方法,如果 `next` 调用了实际的 SDK它将返回原始的 SDK 响应例如OpenAI 的流对象或 JSON 对象)。你需要处理这个响应。
- 此函数返回第三层(也是最核心的)函数。
3. **第三层函数 `(context, params) => { ... }`**:
- 这是执行中间件主要逻辑的地方。
- 它接收当前的 `context` (`AiProviderMiddlewareCompletionsContext`) 和 `params` (`CompletionsParams`)。
- 在此函数中,你可以:
- **在调用 `next` 之前**:
- 读取或修改 `params`。例如,添加默认参数、转换消息格式。
- 读取或修改 `context`。例如,设置一个时间戳用于后续计算延迟。
- 执行某些检查,如果不满足条件,可以不调用 `next` 而直接返回或抛出错误(例如,参数校验失败)。
- **调用 `await next(context, params)`**:
- 这是将控制权传递给下游的关键步骤。
- `next` 的返回值是原始的 SDK 响应或下游中间件的结果,你需要根据情况处理它(例如,如果是流,则开始消费流)。
- **在调用 `next` 之后**:
- 处理 `next` 的返回结果。例如,如果 `next` 返回了一个流,你可以在这里开始迭代处理这个流,并通过 `context.onChunk` 发送数据块。
- 基于 `context` 的变化或 `next` 的结果执行进一步操作。例如,计算总耗时、记录日志。
- 修改最终结果(尽管对于 `completions`,结果通常通过 `onChunk` 副作用发出)。
### 示例:一个简单的日志中间件
```typescript
import {
AiProviderMiddlewareCompletionsContext,
CompletionsParams,
MiddlewareAPI,
OnChunkFunction // 假设 OnChunkFunction 类型被导出
} from './AiProviderMiddlewareTypes' // 调整路径
import { ChunkType } from '@renderer/types' // 调整路径
export const createSimpleLoggingMiddleware = (): CompletionsMiddleware => {
return (api: MiddlewareAPI<AiProviderMiddlewareCompletionsContext, [CompletionsParams]>) => {
// console.log(`[LoggingMiddleware] Initialized for provider: ${api.getProviderId()}`);
return (next: (context: AiProviderMiddlewareCompletionsContext, params: CompletionsParams) => Promise<any>) => {
return async (context: AiProviderMiddlewareCompletionsContext, params: CompletionsParams): Promise<void> => {
const startTime = Date.now()
// 从 context 中获取 onChunk (它最初来自 params.onChunk)
const onChunk = context.onChunk
console.log(
`[LoggingMiddleware] Request for ${context.methodName} with params:`,
params.messages?.[params.messages.length - 1]?.content
)
try {
// 调用下一个中间件或核心逻辑
// `rawSdkResponse` 是来自下游的原始响应 (例如 OpenAIStream 或 ChatCompletion 对象)
const rawSdkResponse = await next(context, params)
// 此处简单示例不处理 rawSdkResponse假设下游中间件 (如 StreamingResponseHandler)
// 会处理它并通过 onChunk 发送数据。
// 如果这个日志中间件在 StreamingResponseHandler 之后,那么流已经被处理。
// 如果在之前,那么它需要自己处理 rawSdkResponse 或确保下游会处理。
const duration = Date.now() - startTime
console.log(`[LoggingMiddleware] Request for ${context.methodName} completed in ${duration}ms.`)
// 假设下游已经通过 onChunk 发送了所有数据。
// 如果这个中间件是链的末端,并且需要确保 BLOCK_COMPLETE 被发送,
// 它可能需要更复杂的逻辑来跟踪何时所有数据都已发送。
} catch (error) {
const duration = Date.now() - startTime
console.error(`[LoggingMiddleware] Request for ${context.methodName} failed after ${duration}ms:`, error)
// 如果 onChunk 可用,可以尝试发送一个错误块
if (onChunk) {
onChunk({
type: ChunkType.ERROR,
error: { message: (error as Error).message, name: (error as Error).name, stack: (error as Error).stack }
})
// 考虑是否还需要发送 BLOCK_COMPLETE 来结束流
onChunk({ type: ChunkType.BLOCK_COMPLETE, response: {} })
}
throw error // 重新抛出错误,以便上层或全局错误处理器可以捕获
}
}
}
}
}
```
### `AiProviderMiddlewareCompletionsContext` 的重要性
`AiProviderMiddlewareCompletionsContext` 是在中间件之间传递状态和数据的核心。它通常包含:
- `methodName`: 当前调用的方法名 (总是 `'completions'`)。
- `originalArgs`: 传递给 `completions` 的原始参数数组。
- `providerId`: Provider 的 ID。
- `_providerInstance`: Provider 实例。
- `onChunk`: 从原始 `CompletionsParams` 传入的回调函数,用于流式发送数据块。**所有中间件都应该通过 `context.onChunk` 来发送数据。**
- `messages`, `model`, `assistant`, `mcpTools`: 从原始 `CompletionsParams` 中提取的常用字段,方便访问。
- **自定义字段**: 中间件可以向上下文中添加自定义字段,以供后续中间件使用。例如,一个缓存中间件可能会添加 `context.cacheHit = true`
**关键**: 当你在中间件中修改 `params``context` 时,这些修改会向下游中间件传播(如果它们在 `next` 调用之前修改)。
### 中间件的顺序
中间件的执行顺序非常重要。它们在 `AiProviderMiddlewareConfig` 的数组中定义的顺序就是它们的执行顺序。
- 请求首先通过第一个中间件,然后是第二个,依此类推。
- 响应(或 `next` 的调用结果)则以相反的顺序"冒泡"回来。
例如,如果链是 `[AuthMiddleware, CacheMiddleware, LoggingMiddleware]`
1. `AuthMiddleware` 先执行其 "调用 `next` 之前" 的逻辑。
2. 然后 `CacheMiddleware` 执行其 "调用 `next` 之前" 的逻辑。
3. 然后 `LoggingMiddleware` 执行其 "调用 `next` 之前" 的逻辑。
4. 核心SDK调用或链的末端
5. `LoggingMiddleware` 先接收到结果,执行其 "调用 `next` 之后" 的逻辑。
6. 然后 `CacheMiddleware` 接收到结果(可能已被 LoggingMiddleware 修改的上下文),执行其 "调用 `next` 之后" 的逻辑(例如,存储结果)。
7. 最后 `AuthMiddleware` 接收到结果,执行其 "调用 `next` 之后" 的逻辑。
### 注册中间件
中间件在 `src/renderer/src/providers/middleware/register.ts` (或其他类似的配置文件) 中进行注册。
```typescript
// register.ts
import { AiProviderMiddlewareConfig } from './AiProviderMiddlewareTypes'
import { createSimpleLoggingMiddleware } from './common/SimpleLoggingMiddleware' // 假设你创建了这个文件
import { createCompletionsLoggingMiddleware } from './common/CompletionsLoggingMiddleware' // 已有的
const middlewareConfig: AiProviderMiddlewareConfig = {
completions: [
createSimpleLoggingMiddleware(), // 你新加的中间件
createCompletionsLoggingMiddleware() // 已有的日志中间件
// ... 其他 completions 中间件
],
methods: {
// translate: [createGenericLoggingMiddleware()],
// ... 其他方法的中间件
}
}
export default middlewareConfig
```
### 最佳实践
1. **单一职责**: 每个中间件应专注于一个特定的功能(例如,日志、缓存、转换特定数据)。
2. **无副作用 (尽可能)**: 除了通过 `context``onChunk` 明确的副作用外,尽量避免修改全局状态或产生其他隐蔽的副作用。
3. **错误处理**:
- 在中间件内部使用 `try...catch` 来处理可能发生的错误。
- 决定是自行处理错误(例如,通过 `onChunk` 发送错误块)还是将错误重新抛出给上游。
- 如果重新抛出,确保错误对象包含足够的信息。
4. **性能考虑**: 中间件会增加请求处理的开销。避免在中间件中执行非常耗时的同步操作。对于IO密集型操作确保它们是异步的。
5. **可配置性**: 使中间件的行为可通过参数或配置进行调整。例如,日志中间件可以接受一个日志级别参数。
6. **上下文管理**:
- 谨慎地向 `context` 添加数据。避免污染 `context` 或添加过大的对象。
- 明确你添加到 `context` 的字段的用途和生命周期。
7. **`next` 的调用**:
- 除非你有充分的理由提前终止请求(例如,缓存命中、授权失败),否则**总是确保调用 `await next(context, params)`**。否则,下游的中间件和核心逻辑将不会执行。
- 理解 `next` 的返回值并正确处理它,特别是当它是一个流时。你需要负责消费这个流或将其传递给另一个能够消费它的组件/中间件。
8. **命名清晰**: 给你的中间件和它们创建的函数起描述性的名字。
9. **文档和注释**: 对复杂的中间件逻辑添加注释,解释其工作原理和目的。
### 调试技巧
- 在中间件的关键点使用 `console.log` 或调试器来检查 `params``context` 的状态以及 `next` 的返回值。
- 暂时简化中间件链,只保留你正在调试的中间件和最简单的核心逻辑,以隔离问题。
- 编写单元测试来独立验证每个中间件的行为。
通过遵循这些指南,你应该能够有效地为我们的系统创建强大且可维护的中间件。如果你有任何疑问或需要进一步的帮助,请咨询团队。

View File

@@ -11,82 +11,52 @@ electronLanguages:
- en # for macOS
directories:
buildResources: build
protocols:
- name: Cherry Studio
schemes:
- cherrystudio
files:
- '**/*'
- '!**/{.vscode,.yarn,.yarn-lock,.github,.cursorrules,.prettierrc}'
- '!electron.vite.config.{js,ts,mjs,cjs}}'
- '!**/{.eslintignore,.eslintrc.js,.eslintrc.json,.eslintcache,root.eslint.config.js,eslint.config.js,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,eslint.config.mjs,dev-app-update.yml,CHANGELOG.md,README.md}'
- '!**/{.env,.env.*,.npmrc,pnpm-lock.yaml}'
- '!**/{tsconfig.json,tsconfig.tsbuildinfo,tsconfig.node.json,tsconfig.web.json}'
- '!**/{.editorconfig,.jekyll-metadata}'
- '!{.vscode,.yarn,.github}'
- '!electron.vite.config.{js,ts,mjs,cjs}'
- '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
- '!src'
- '!scripts'
- '!local'
- '!docs'
- '!packages'
- '!.swc'
- '!.bin'
- '!._*'
- '!*.log'
- '!stats.html'
- '!*.md'
- '!**/*.{iml,o,hprof,orig,pyc,pyo,rbc,swp,csproj,sln,xproj}'
- '!**/*.{map,ts,tsx,jsx,less,scss,sass,css.d.ts,d.cts,d.mts,md,markdown,yaml,yml}'
- '!**/{test,tests,__tests__,powered-test,coverage}/**'
- '!**/{example,examples}/**'
- '!**/{test,tests,__tests__,coverage}/**'
- '!**/*.{spec,test}.{js,jsx,ts,tsx}'
- '!**/*.min.*.map'
- '!**/*.d.ts'
- '!**/dist/es6/**'
- '!**/dist/demo/**'
- '!**/amd/**'
- '!**/{.DS_Store,Thumbs.db,thumbs.db,__pycache__}'
- '!**/{LICENSE,license,LICENSE.*,*.LICENSE.txt,NOTICE.txt,README.md,readme.md,CHANGELOG.md}'
- '!**/{.DS_Store,Thumbs.db}'
- '!**/{LICENSE,LICENSE.txt,LICENSE-MIT.txt,*.LICENSE.txt,NOTICE.txt,README.md,CHANGELOG.md}'
- '!node_modules/rollup-plugin-visualizer'
- '!node_modules/js-tiktoken'
- '!node_modules/@tavily/core/node_modules/js-tiktoken'
- '!node_modules/pdf-parse/lib/pdf.js/{v1.9.426,v1.10.88,v2.0.550}'
- '!node_modules/mammoth/{mammoth.browser.js,mammoth.browser.min.js}'
- '!node_modules/selection-hook/prebuilds/**/*' # we rebuild .node, don't use prebuilds
- '!node_modules/pdfjs-dist/web/**/*'
- '!node_modules/pdfjs-dist/legacy/web/*'
- '!node_modules/selection-hook/node_modules' # we don't need what in the node_modules dir
- '!node_modules/selection-hook/src' # we don't need source files
- '!**/*.{h,iobj,ipdb,tlog,recipe,vcxproj,vcxproj.filters,Makefile,*.Makefile}' # filter .node build files
asarUnpack:
- resources/**
- '**/*.{metal,exp,lib}'
- '**/*.{node,dll,metal,exp,lib}'
win:
executableName: Cherry Studio
artifactName: ${productName}-${version}-${arch}-setup.${ext}
artifactName: ${productName}-${version}-portable.${ext}
target:
- target: nsis
- target: portable
signtoolOptions:
sign: scripts/win-sign.js
verifyUpdateCodeSignature: false
nsis:
artifactName: ${productName}-${version}-${arch}-setup.${ext}
artifactName: ${productName}-${version}-setup.${ext}
shortcutName: ${productName}
uninstallDisplayName: ${productName}
createDesktopShortcut: always
allowToChangeInstallationDirectory: true
oneClick: false
include: build/nsis-installer.nsh
buildUniversalInstaller: false
portable:
artifactName: ${productName}-${version}-${arch}-portable.${ext}
buildUniversalInstaller: false
mac:
entitlementsInherit: build/entitlements.mac.plist
notarize: false
artifactName: ${productName}-${version}-${arch}.${ext}
minimumSystemVersion: '20.1.0' # 最低支持 macOS 11.0
extendInfo:
- NSCameraUsageDescription: Application requests access to the device's camera.
- NSMicrophoneUsageDescription: Application requests access to the device's microphone.
@@ -94,12 +64,20 @@ mac:
- NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
target:
- target: dmg
arch:
- arm64
- x64
- target: zip
arch:
- arm64
- x64
linux:
artifactName: ${productName}-${version}-${arch}.${ext}
target:
- target: AppImage
- target: deb
arch:
- arm64
- x64
maintainer: electronjs.org
category: Utility
desktop:
@@ -108,18 +86,19 @@ linux:
mimeTypes:
- x-scheme-handler/cherrystudio
publish:
provider: generic
url: https://releases.cherry-ai.com
# provider: generic
# url: https://cherrystudio.ocool.online
provider: github
repo: cherry-studio
owner: CherryHQ
electronDownload:
mirror: https://npmmirror.com/mirrors/electron/
afterPack: scripts/after-pack.js
afterSign: scripts/notarize.js
artifactBuildCompleted: scripts/artifact-build-completed.js
releaseInfo:
releaseNotes: |
划词助手:支持 macOS 系统
文档处理:增加 MinerU、Doc2xMistral 等服务商支持
知识库:新的知识库界面,增加扫描版 PDF 支持
OCRmacOS 增加系统 OCR 支持
服务商:支持一键添加服务商,新增 PH8 大模型开放平台, 支持 PPIO OAuth 登录
修复Linux下数据目录移动问题
新增划词助手
助手支持分组
支持主题颜色切换
划词助手支持应用过滤
翻译模块功能改进

View File

@@ -1,6 +1,6 @@
import react from '@vitejs/plugin-react-swc'
import { CodeInspectorPlugin } from 'code-inspector-plugin'
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
import fs from 'fs'
import { resolve } from 'path'
import { visualizer } from 'rollup-plugin-visualizer'
@@ -10,7 +10,22 @@ const visualizerPlugin = (type: 'renderer' | 'main') => {
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin(), ...visualizerPlugin('main')],
plugins: [externalizeDepsPlugin({
exclude: [
'@cherrystudio/embedjs',
'@cherrystudio/embedjs-openai',
'@cherrystudio/embedjs-loader-web',
'@cherrystudio/embedjs-loader-markdown',
'@cherrystudio/embedjs-loader-msoffice',
'@cherrystudio/embedjs-loader-xml',
'@cherrystudio/embedjs-loader-pdf',
'@cherrystudio/embedjs-loader-sitemap',
'@cherrystudio/embedjs-libsql',
'@cherrystudio/embedjs-loader-image',
'p-queue',
'webdav'
]
}), ...visualizerPlugin('main')],
resolve: {
alias: {
'@main': resolve('src/main'),
@@ -20,18 +35,28 @@ export default defineConfig({
},
build: {
rollupOptions: {
external: ['@libsql/client', 'bufferutil', 'utf-8-validate', '@cherrystudio/mac-system-ocr'],
output: {
// 彻底禁用代码分割 - 返回 null 强制单文件打包
manualChunks: undefined,
// 内联所有动态导入,这是关键配置
inlineDynamicImports: true
}
},
sourcemap: process.env.NODE_ENV === 'development'
},
optimizeDeps: {
noDiscovery: process.env.NODE_ENV === 'development'
external: ['@libsql/client', 'bufferutil', 'utf-8-validate'],
plugins: [
{
name: 'inject-windows7-polyfill',
generateBundle(_, bundle) {
// 遍历所有生成的文件
for (const fileName in bundle) {
const chunk = bundle[fileName]
if (
chunk.type === 'chunk' &&
chunk.isEntry &&
chunk.fileName.includes('index.js') // 匹配主进程入口文件
) {
const code = fs.readFileSync('src/main/polyfill/windows7-patch.js', 'utf-8')
// 在文件末尾插入自定义代码
chunk.code = code + '\r\n' + chunk.code
}
}
}
}
]
}
}
},
preload: {
@@ -46,6 +71,10 @@ export default defineConfig({
}
},
renderer: {
define: {
// 使用方法 (Windows CMD): set CUSTOM_APP_NAME=AppName && yarn run dev
'process.env.CUSTOM_APP_NAME': JSON.stringify(process.env.CUSTOM_APP_NAME)
},
plugins: [
react({
plugins: [
@@ -60,14 +89,6 @@ export default defineConfig({
]
]
}),
// 只在开发环境下启用 CodeInspectorPlugin
...(process.env.NODE_ENV === 'development'
? [
CodeInspectorPlugin({
bundler: 'vite'
})
]
: []),
...visualizerPlugin('renderer')
],
resolve: {
@@ -77,16 +98,12 @@ export default defineConfig({
}
},
optimizeDeps: {
exclude: ['pyodide'],
esbuildOptions: {
target: 'esnext' // for dev
}
exclude: ['pyodide']
},
worker: {
format: 'es'
},
build: {
target: 'esnext', // for build
rollupOptions: {
input: {
index: resolve(__dirname, 'src/renderer/index.html'),

View File

@@ -1,6 +1,6 @@
{
"name": "CherryStudio",
"version": "1.4.8",
"version": "1.4.1",
"private": true,
"description": "A powerful AI assistant for producer.",
"main": "./out/main/index.js",
@@ -27,6 +27,7 @@
"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:win7": "xcopy \"src\\patch\\windows7\\\" \"node_modules\\\" /E /Y && dotenv npm run build && electron-builder --win --x64",
"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",
@@ -58,25 +59,6 @@
"prepare": "husky"
},
"dependencies": {
"@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",
"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",
"turndown": "7.2.0"
},
"devDependencies": {
"@agentic/exa": "^7.3.3",
"@agentic/searxng": "^7.3.3",
"@agentic/tavily": "^7.3.3",
"@ant-design/v5-patch-for-react-19": "^1.0.3",
"@anthropic-ai/sdk": "^0.41.0",
"@cherrystudio/embedjs": "^0.1.31",
"@cherrystudio/embedjs-libsql": "^0.1.31",
"@cherrystudio/embedjs-loader-csv": "^0.1.31",
@@ -87,31 +69,74 @@
"@cherrystudio/embedjs-loader-sitemap": "^0.1.31",
"@cherrystudio/embedjs-loader-web": "^0.1.31",
"@cherrystudio/embedjs-loader-xml": "^0.1.31",
"@cherrystudio/embedjs-ollama": "^0.1.31",
"@cherrystudio/embedjs-openai": "^0.1.31",
"@electron-toolkit/utils": "^3.0.0",
"@langchain/community": "^0.3.36",
"@libsql/client": "^0.15.2",
"@libsql/win32-x64-msvc": "^0.5.4",
"@mozilla/readability": "^0.6.0",
"@notionhq/client": "^2.2.15",
"@peculiar/webcrypto": "^1.5.0",
"@strongtz/win32-arm64-msvc": "^0.4.7",
"@tanstack/react-query": "^5.27.0",
"@types/react-infinite-scroll-component": "^5.0.0",
"archiver": "^7.0.1",
"async-mutex": "^0.5.0",
"blob-polyfill": "^9.0.20240710",
"bufferutil": "^4.0.9",
"color": "^5.0.0",
"diff": "^7.0.0",
"docx": "^9.0.2",
"domexception": "^4.0.0",
"electron-log": "^5.1.5",
"electron-store": "^8.2.0",
"electron-updater": "6.6.2",
"electron-window-state": "^5.0.3",
"epub": "patch:epub@npm%3A1.3.0#~/.yarn/patches/epub-npm-1.3.0-8325494ffe.patch",
"fast-xml-parser": "^5.2.0",
"franc-min": "^6.2.0",
"fs-extra": "^11.2.0",
"jsdom": "^26.0.0",
"libsql": "^0.5.4",
"markdown-it": "^14.1.0",
"node-fetch": "2",
"node-stream-zip": "^1.15.0",
"officeparser": "^4.1.1",
"os-proxy-config": "^1.1.2",
"proxy-agent": "^6.5.0",
"selection-hook": "^0.9.21",
"tar": "^7.4.3",
"turndown": "^7.2.0",
"turndown-plugin-gfm": "^1.0.2",
"undici": "^7.4.0",
"web-streams-polyfill": "^4.1.0",
"webdav": "^5.8.0",
"zipread": "^1.3.3"
},
"devDependencies": {
"@agentic/exa": "^7.3.3",
"@agentic/searxng": "^7.3.3",
"@agentic/tavily": "^7.3.3",
"@ant-design/v5-patch-for-react-19": "^1.0.3",
"@anthropic-ai/sdk": "^0.41.0",
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
"@electron-toolkit/eslint-config-ts": "^3.0.0",
"@electron-toolkit/preload": "^3.0.0",
"@electron-toolkit/tsconfig": "^1.0.1",
"@electron-toolkit/utils": "^3.0.0",
"@electron/notarize": "^2.5.0",
"@emotion/is-prop-valid": "^1.3.1",
"@eslint-react/eslint-plugin": "^1.36.1",
"@eslint/js": "^9.22.0",
"@google/genai": "patch:@google/genai@npm%3A1.0.1#~/.yarn/patches/@google-genai-npm-1.0.1-e26f0f9af7.patch",
"@google/genai": "^1.0.1",
"@hello-pangea/dnd": "^16.6.0",
"@kangfenmao/keyv-storage": "^0.1.0",
"@langchain/community": "^0.3.36",
"@langchain/ollama": "^0.2.1",
"@mistralai/mistralai": "^1.6.0",
"@modelcontextprotocol/sdk": "^1.11.4",
"@mozilla/readability": "^0.6.0",
"@notionhq/client": "^2.2.15",
"@playwright/test": "^1.52.0",
"@peculiar/webcrypto": "^1.5.0",
"@reduxjs/toolkit": "^2.2.5",
"@shikijs/markdown-it": "^3.7.0",
"@shikijs/markdown-it": "^3.4.2",
"@swc/plugin-styled-components": "^7.1.5",
"@tanstack/react-query": "^5.27.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
@@ -128,50 +153,34 @@
"@types/react-infinite-scroll-component": "^5.0.0",
"@types/react-window": "^1",
"@types/tinycolor2": "^1",
"@types/word-extractor": "^1",
"@uiw/codemirror-extensions-langs": "^4.23.14",
"@uiw/codemirror-themes-all": "^4.23.14",
"@uiw/react-codemirror": "^4.23.14",
"@uiw/codemirror-extensions-langs": "^4.23.12",
"@uiw/codemirror-themes-all": "^4.23.12",
"@uiw/react-codemirror": "^4.23.12",
"@vitejs/plugin-react-swc": "^3.9.0",
"@vitest/browser": "^3.1.4",
"@vitest/coverage-v8": "^3.1.4",
"@vitest/ui": "^3.1.4",
"@vitest/web-worker": "^3.1.4",
"@xyflow/react": "^12.4.4",
"antd": "patch:antd@npm%3A5.24.7#~/.yarn/patches/antd-npm-5.24.7-356a553ae5.patch",
"archiver": "^7.0.1",
"async-mutex": "^0.5.0",
"antd": "^5.22.5",
"axios": "^1.7.3",
"browser-image-compression": "^2.0.2",
"code-inspector-plugin": "^0.20.14",
"color": "^5.0.0",
"country-flag-emoji-polyfill": "0.1.8",
"dayjs": "^1.11.11",
"dexie": "^4.0.8",
"dexie-react-hooks": "^1.1.7",
"diff": "^7.0.0",
"docx": "^9.0.2",
"dotenv-cli": "^7.4.2",
"electron": "35.6.0",
"electron-builder": "26.0.15",
"electron": "22.3.23",
"electron-builder": "^24.9.1",
"electron-devtools-installer": "^3.2.0",
"electron-log": "^5.1.5",
"electron-store": "^8.2.0",
"electron-updater": "6.6.4",
"electron-vite": "^3.1.0",
"electron-window-state": "^5.0.3",
"emittery": "^1.0.3",
"emoji-picker-element": "^1.22.1",
"epub": "patch:epub@npm%3A1.3.0#~/.yarn/patches/epub-npm-1.3.0-8325494ffe.patch",
"eslint": "^9.22.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-unused-imports": "^4.1.4",
"fast-diff": "^1.3.0",
"fast-xml-parser": "^5.2.0",
"franc-min": "^6.2.0",
"fs-extra": "^11.2.0",
"google-auth-library": "^9.15.1",
"html-to-image": "^1.11.13",
"husky": "^9.1.7",
"i18next": "^23.11.5",
@@ -180,24 +189,21 @@
"lodash": "^4.17.21",
"lru-cache": "^11.1.0",
"lucide-react": "^0.487.0",
"markdown-it": "^14.1.0",
"mermaid": "^11.7.0",
"mermaid": "^11.6.0",
"mime": "^4.0.4",
"motion": "^12.10.5",
"npx-scope-finder": "^1.2.0",
"officeparser": "^4.1.1",
"openai": "patch:openai@npm%3A5.1.0#~/.yarn/patches/openai-npm-5.1.0-0e7b3ccb07.patch",
"p-queue": "^8.1.0",
"playwright": "^1.52.0",
"prettier": "^3.5.3",
"proxy-agent": "^6.5.0",
"rc-virtual-list": "^3.18.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hotkeys-hook": "^4.6.1",
"react-i18next": "^14.1.2",
"react-infinite-scroll-component": "^6.1.0",
"react-markdown": "^10.1.0",
"react-markdown": "^9.0.1",
"react-redux": "^9.1.2",
"react-router": "6",
"react-router-dom": "6",
@@ -206,30 +212,22 @@
"redux": "^5.0.1",
"redux-persist": "^6.0.0",
"rehype-katex": "^7.0.1",
"rehype-mathjax": "^7.1.0",
"rehype-mathjax": "^7.0.0",
"rehype-raw": "^7.0.0",
"remark-cjk-friendly": "^1.2.0",
"remark-gfm": "^4.0.1",
"remark-cjk-friendly": "^1.1.0",
"remark-gfm": "^4.0.0",
"remark-math": "^6.0.0",
"remove-markdown": "^0.6.2",
"rollup-plugin-visualizer": "^5.12.0",
"sass": "^1.88.0",
"shiki": "^3.7.0",
"shiki": "^3.4.2",
"string-width": "^7.2.0",
"styled-components": "^6.1.11",
"tar": "^7.4.3",
"tiny-pinyin": "^1.3.2",
"tokenx": "^1.1.0",
"tokenx": "^0.4.1",
"typescript": "^5.6.2",
"uuid": "^10.0.0",
"vite": "6.2.6",
"vitest": "^3.1.4",
"webdav": "^5.8.0",
"word-extractor": "^1.0.4",
"zipread": "^1.3.3"
},
"optionalDependencies": {
"@cherrystudio/mac-system-ocr": "^0.2.2"
"vitest": "^3.1.4"
},
"resolutions": {
"pdf-parse@npm:1.1.1": "patch:pdf-parse@npm%3A1.1.1#~/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch",
@@ -238,6 +236,9 @@
"libsql@npm:^0.4.4": "patch:libsql@npm%3A0.4.7#~/.yarn/patches/libsql-npm-0.4.7-444e260fb1.patch",
"openai@npm:^4.77.0": "patch:openai@npm%3A5.1.0#~/.yarn/patches/openai-npm-5.1.0-0e7b3ccb07.patch",
"pkce-challenge@npm:^4.1.0": "patch:pkce-challenge@npm%3A4.1.0#~/.yarn/patches/pkce-challenge-npm-4.1.0-fbc51695a3.patch",
"@types/domexception": "^4",
"electron": "22.3.23",
"electron-builder": "^24.9.1",
"app-builder-lib@npm:26.0.13": "patch:app-builder-lib@npm%3A26.0.13#~/.yarn/patches/app-builder-lib-npm-26.0.13-a064c9e1d0.patch",
"openai@npm:^4.87.3": "patch:openai@npm%3A5.1.0#~/.yarn/patches/openai-npm-5.1.0-0e7b3ccb07.patch",
"app-builder-lib@npm:26.0.15": "patch:app-builder-lib@npm%3A26.0.15#~/.yarn/patches/app-builder-lib-npm-26.0.15-360e5b0476.patch",

View File

@@ -3,8 +3,6 @@ export enum IpcChannel {
App_ClearCache = 'app:clear-cache',
App_SetLaunchOnBoot = 'app:set-launch-on-boot',
App_SetLanguage = 'app:set-language',
App_SetEnableSpellCheck = 'app:set-enable-spell-check',
App_SetSpellCheckLanguages = 'app:set-spell-check-languages',
App_ShowUpdateDialog = 'app:show-update-dialog',
App_CheckForUpdate = 'app:check-for-update',
App_Reload = 'app:reload',
@@ -15,33 +13,17 @@ export enum IpcChannel {
App_SetTrayOnClose = 'app:set-tray-on-close',
App_SetTheme = 'app:set-theme',
App_SetAutoUpdate = 'app:set-auto-update',
App_SetTestPlan = 'app:set-test-plan',
App_SetTestChannel = 'app:set-test-channel',
App_HandleZoomFactor = 'app:handle-zoom-factor',
App_Select = 'app:select',
App_HasWritePermission = 'app:has-write-permission',
App_Copy = 'app:copy',
App_SetStopQuitApp = 'app:set-stop-quit-app',
App_SetAppDataPath = 'app:set-app-data-path',
App_GetDataPathFromArgs = 'app:get-data-path-from-args',
App_FlushAppData = 'app:flush-app-data',
App_IsNotEmptyDir = 'app:is-not-empty-dir',
App_RelaunchApp = 'app:relaunch-app',
App_IsBinaryExist = 'app:is-binary-exist',
App_GetBinaryPath = 'app:get-binary-path',
App_InstallUvBinary = 'app:install-uv-binary',
App_InstallBunBinary = 'app:install-bun-binary',
App_MacIsProcessTrusted = 'app:mac-is-process-trusted',
App_MacRequestProcessTrust = 'app:mac-request-process-trust',
App_QuoteToMain = 'app:quote-to-main',
Notification_Send = 'notification:send',
Notification_OnClick = 'notification:on-click',
Webview_SetOpenLinkExternal = 'webview:set-open-link-external',
Webview_SetSpellCheckEnabled = 'webview:set-spell-check-enabled',
// Open
Open_Path = 'open:path',
@@ -74,9 +56,6 @@ export enum IpcChannel {
Mcp_ServersUpdated = 'mcp:servers-updated',
Mcp_CheckConnectivity = 'mcp:check-connectivity',
// Python
Python_Execute = 'python:execute',
//copilot
Copilot_GetAuthMessage = 'copilot:get-auth-message',
Copilot_GetCopilotToken = 'copilot:get-copilot-token',
@@ -104,10 +83,6 @@ export enum IpcChannel {
Gemini_ListFiles = 'gemini:list-files',
Gemini_DeleteFile = 'gemini:delete-file',
// VertexAI
VertexAI_GetAuthHeaders = 'vertexai:get-auth-headers',
VertexAI_ClearAuthCache = 'vertexai:clear-auth-cache',
Windows_ResetMinimumSize = 'window:reset-minimum-size',
Windows_SetMinimumSize = 'window:set-minimum-size',
@@ -118,7 +93,6 @@ export enum IpcChannel {
KnowledgeBase_Remove = 'knowledge-base:remove',
KnowledgeBase_Search = 'knowledge-base:search',
KnowledgeBase_Rerank = 'knowledge-base:rerank',
KnowledgeBase_Check_Quota = 'knowledge-base:check-quota',
//file
File_Open = 'file:open',
@@ -129,10 +103,9 @@ export enum IpcChannel {
File_Clear = 'file:clear',
File_Read = 'file:read',
File_Delete = 'file:delete',
File_DeleteDir = 'file:deleteDir',
File_Get = 'file:get',
File_SelectFolder = 'file:selectFolder',
File_CreateTempFile = 'file:createTempFile',
File_Create = 'file:create',
File_Write = 'file:write',
File_WriteWithId = 'file:writeWithId',
File_SaveImage = 'file:saveImage',
@@ -142,15 +115,8 @@ export enum IpcChannel {
File_Copy = 'file:copy',
File_BinaryImage = 'file:binaryImage',
File_Base64File = 'file:base64File',
File_GetPdfInfo = 'file:getPdfInfo',
Fs_Read = 'fs:read',
// file service
FileService_Upload = 'file-service:upload',
FileService_List = 'file-service:list',
FileService_Delete = 'file-service:delete',
FileService_Retrieve = 'file-service:retrieve',
Export_Word = 'export:word',
Shortcuts_Update = 'shortcuts:update',

View File

@@ -1,7 +1,7 @@
export const imageExts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp']
export const videoExts = ['.mp4', '.avi', '.mov', '.wmv', '.flv', '.mkv']
export const audioExts = ['.mp3', '.wav', '.ogg', '.flac', '.aac']
export const documentExts = ['.pdf', '.doc', '.docx', '.pptx', '.xlsx', '.odt', '.odp', '.ods']
export const documentExts = ['.pdf', '.docx', '.pptx', '.xlsx', '.odt', '.odp', '.ods']
export const thirdPartyApplicationExts = ['.draftsExport']
export const bookExts = ['.epub']
const textExtsByCategory = new Map([
@@ -403,19 +403,3 @@ export const KB = 1024
export const MB = 1024 * KB
export const GB = 1024 * MB
export const defaultLanguage = 'en-US'
export enum FeedUrl {
PRODUCTION = 'https://releases.cherry-ai.com',
GITHUB_LATEST = 'https://github.com/CherryHQ/cherry-studio/releases/latest/download',
PRERELEASE_LOWEST = 'https://github.com/CherryHQ/cherry-studio/releases/download/v1.4.0'
}
export enum UpgradeChannel {
LATEST = 'latest', // 最新稳定版本
RC = 'rc', // 公测版本
BETA = 'beta' // 预览版本
}
export const defaultTimeout = 10 * 1000 * 60
export const occupiedDirs = ['logs', 'Network', 'Partitions/webview/Network']

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,6 @@
import { ProcessingStatus } from '@types'
export type LoaderReturn = {
entriesAdded: number
uniqueId: string
uniqueIds: string[]
loaderType: string
status?: ProcessingStatus
message?: string
messageSource?: 'preprocess' | 'embedding'
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -2,12 +2,12 @@ const fs = require('fs')
const path = require('path')
const os = require('os')
const { execSync } = require('child_process')
const StreamZip = require('node-stream-zip')
const AdmZip = require('adm-zip')
const { downloadWithRedirects } = require('./download')
// Base URL for downloading bun binaries
const BUN_RELEASE_BASE_URL = 'https://gitcode.com/CherryHQ/bun/releases/download'
const DEFAULT_BUN_VERSION = '1.2.17' // Default fallback version
const DEFAULT_BUN_VERSION = '1.2.9' // Default fallback version
// Mapping of platform+arch to binary package name
const BUN_PACKAGES = {
@@ -66,36 +66,35 @@ async function downloadBunBinary(platform, arch, version = DEFAULT_BUN_VERSION,
// Extract the zip file using adm-zip
console.log(`Extracting ${packageName} to ${binDir}...`)
const zip = new StreamZip.async({ file: tempFilename })
const zip = new AdmZip(tempFilename)
zip.extractAllTo(tempdir, true)
// Get all entries in the zip file
const entries = await zip.entries()
// Move files using Node.js fs
const sourceDir = path.join(tempdir, packageName.split('.')[0])
const files = fs.readdirSync(sourceDir)
// Extract files directly to binDir, flattening the directory structure
for (const entry of Object.values(entries)) {
if (!entry.isDirectory) {
// Get just the filename without path
const filename = path.basename(entry.name)
const outputPath = path.join(binDir, filename)
for (const file of files) {
const sourcePath = path.join(sourceDir, file)
const destPath = path.join(binDir, file)
console.log(`Extracting ${entry.name} -> ${filename}`)
await zip.extract(entry.name, outputPath)
// Make executable files executable on Unix-like systems
if (platform !== 'win32') {
try {
fs.chmodSync(outputPath, 0o755)
} catch (chmodError) {
console.error(`Warning: Failed to set executable permissions on ${filename}`)
return false
}
fs.copyFileSync(sourcePath, destPath)
fs.unlinkSync(sourcePath)
// Set executable permissions for non-Windows platforms
if (platform !== 'win32') {
try {
// 755 permission: rwxr-xr-x
fs.chmodSync(destPath, '755')
} catch (error) {
console.warn(`Warning: Failed to set executable permissions: ${error.message}`)
}
console.log(`Extracted ${entry.name} -> ${outputPath}`)
}
}
await zip.close()
// Clean up
fs.unlinkSync(tempFilename)
fs.rmSync(sourceDir, { recursive: true })
console.log(`Successfully installed bun ${version} for ${platformKey}`)
return true
} catch (error) {

View File

@@ -2,33 +2,34 @@ const fs = require('fs')
const path = require('path')
const os = require('os')
const { execSync } = require('child_process')
const StreamZip = require('node-stream-zip')
const tar = require('tar')
const AdmZip = require('adm-zip')
const { downloadWithRedirects } = require('./download')
// Base URL for downloading uv binaries
const UV_RELEASE_BASE_URL = 'https://gitcode.com/CherryHQ/uv/releases/download'
const DEFAULT_UV_VERSION = '0.7.13'
const DEFAULT_UV_VERSION = '0.6.14'
// Mapping of platform+arch to binary package name
const UV_PACKAGES = {
'darwin-arm64': 'uv-aarch64-apple-darwin.zip',
'darwin-x64': 'uv-x86_64-apple-darwin.zip',
'darwin-arm64': 'uv-aarch64-apple-darwin.tar.gz',
'darwin-x64': 'uv-x86_64-apple-darwin.tar.gz',
'win32-arm64': 'uv-aarch64-pc-windows-msvc.zip',
'win32-ia32': 'uv-i686-pc-windows-msvc.zip',
'win32-x64': 'uv-x86_64-pc-windows-msvc.zip',
'linux-arm64': 'uv-aarch64-unknown-linux-gnu.zip',
'linux-ia32': 'uv-i686-unknown-linux-gnu.zip',
'linux-ppc64': 'uv-powerpc64-unknown-linux-gnu.zip',
'linux-ppc64le': 'uv-powerpc64le-unknown-linux-gnu.zip',
'linux-s390x': 'uv-s390x-unknown-linux-gnu.zip',
'linux-x64': 'uv-x86_64-unknown-linux-gnu.zip',
'linux-armv7l': 'uv-armv7-unknown-linux-gnueabihf.zip',
'linux-arm64': 'uv-aarch64-unknown-linux-gnu.tar.gz',
'linux-ia32': 'uv-i686-unknown-linux-gnu.tar.gz',
'linux-ppc64': 'uv-powerpc64-unknown-linux-gnu.tar.gz',
'linux-ppc64le': 'uv-powerpc64le-unknown-linux-gnu.tar.gz',
'linux-s390x': 'uv-s390x-unknown-linux-gnu.tar.gz',
'linux-x64': 'uv-x86_64-unknown-linux-gnu.tar.gz',
'linux-armv7l': 'uv-armv7-unknown-linux-gnueabihf.tar.gz',
// MUSL variants
'linux-musl-arm64': 'uv-aarch64-unknown-linux-musl.zip',
'linux-musl-ia32': 'uv-i686-unknown-linux-musl.zip',
'linux-musl-x64': 'uv-x86_64-unknown-linux-musl.zip',
'linux-musl-armv6l': 'uv-arm-unknown-linux-musleabihf.zip',
'linux-musl-armv7l': 'uv-armv7-unknown-linux-musleabihf.zip'
'linux-musl-arm64': 'uv-aarch64-unknown-linux-musl.tar.gz',
'linux-musl-ia32': 'uv-i686-unknown-linux-musl.tar.gz',
'linux-musl-x64': 'uv-x86_64-unknown-linux-musl.tar.gz',
'linux-musl-armv6l': 'uv-arm-unknown-linux-musleabihf.tar.gz',
'linux-musl-armv7l': 'uv-armv7-unknown-linux-musleabihf.tar.gz'
}
/**
@@ -65,35 +66,46 @@ async function downloadUvBinary(platform, arch, version = DEFAULT_UV_VERSION, is
console.log(`Extracting ${packageName} to ${binDir}...`)
const zip = new StreamZip.async({ file: tempFilename })
// 根据文件扩展名选择解压方法
if (packageName.endsWith('.zip')) {
// 使用 adm-zip 处理 zip 文件
const zip = new AdmZip(tempFilename)
zip.extractAllTo(binDir, true)
fs.unlinkSync(tempFilename)
console.log(`Successfully installed uv ${version} for ${platform}-${arch}`)
return true
} else {
// tar.gz 文件的处理保持不变
await tar.x({
file: tempFilename,
cwd: tempdir,
z: true
})
// Get all entries in the zip file
const entries = await zip.entries()
// Move files using Node.js fs
const sourceDir = path.join(tempdir, packageName.split('.')[0])
const files = fs.readdirSync(sourceDir)
for (const file of files) {
const sourcePath = path.join(sourceDir, file)
const destPath = path.join(binDir, file)
fs.copyFileSync(sourcePath, destPath)
fs.unlinkSync(sourcePath)
// Extract files directly to binDir, flattening the directory structure
for (const entry of Object.values(entries)) {
if (!entry.isDirectory) {
// Get just the filename without path
const filename = path.basename(entry.name)
const outputPath = path.join(binDir, filename)
console.log(`Extracting ${entry.name} -> ${filename}`)
await zip.extract(entry.name, outputPath)
// Make executable files executable on Unix-like systems
// Set executable permissions for non-Windows platforms
if (platform !== 'win32') {
try {
fs.chmodSync(outputPath, 0o755)
} catch (chmodError) {
console.error(`Warning: Failed to set executable permissions on ${filename}`)
return false
fs.chmodSync(destPath, '755')
} catch (error) {
console.warn(`Warning: Failed to set executable permissions: ${error.message}`)
}
}
console.log(`Extracted ${entry.name} -> ${outputPath}`)
}
// Clean up
fs.unlinkSync(tempFilename)
fs.rmSync(sourceDir, { recursive: true })
}
await zip.close()
fs.unlinkSync(tempFilename)
console.log(`Successfully installed uv ${version} for ${platform}-${arch}`)
return true
} catch (error) {

View File

@@ -23,9 +23,6 @@ exports.default = async function (context) {
const node_modules_path = path.join(context.appOutDir, 'resources', 'app.asar.unpacked', 'node_modules')
const _arch = arch === Arch.arm64 ? ['linux-arm64-gnu', 'linux-arm64-musl'] : ['linux-x64-gnu', 'linux-x64-musl']
keepPackageNodeFiles(node_modules_path, '@libsql', _arch)
// 删除 macOS 专用的 OCR 包
removeMacOnlyPackages(node_modules_path)
}
if (platform === 'windows') {
@@ -38,30 +35,7 @@ exports.default = async function (context) {
keepPackageNodeFiles(node_modules_path, '@strongtz', ['win32-x64-msvc'])
keepPackageNodeFiles(node_modules_path, '@libsql', ['win32-x64-msvc'])
}
removeMacOnlyPackages(node_modules_path)
}
if (platform === 'windows') {
fs.rmSync(path.join(context.appOutDir, 'LICENSE.electron.txt'), { force: true })
fs.rmSync(path.join(context.appOutDir, 'LICENSES.chromium.html'), { force: true })
}
}
/**
* 删除 macOS 专用的包
* @param {string} nodeModulesPath
*/
function removeMacOnlyPackages(nodeModulesPath) {
const macOnlyPackages = ['@cherrystudio/mac-system-ocr']
macOnlyPackages.forEach((packageName) => {
const packagePath = path.join(nodeModulesPath, packageName)
if (fs.existsSync(packagePath)) {
fs.rmSync(packagePath, { recursive: true, force: true })
console.log(`[After Pack] Removed macOS-only package: ${packageName}`)
}
})
}
/**

View File

@@ -1,19 +1,16 @@
/**
* 使用 OpenAI 兼容的模型生成 i18n 文本,并更新到 translate 目录
*
* API_KEY=sk-xxxx BASE_URL=xxxx MODEL=xxxx ts-node scripts/update-i18n.ts
* Paratera_API_KEY=sk-abcxxxxxxxxxxxxxxxxxxxxxxx123 ts-node scripts/update-i18n.ts
*/
const API_KEY = process.env.API_KEY
const BASE_URL = process.env.BASE_URL || 'https://llmapi.paratera.com/v1'
const MODEL = process.env.MODEL || 'Qwen3-235B-A22B'
// OCOOL API KEY
const Paratera_API_KEY = process.env.Paratera_API_KEY
const INDEX = [
// 语言的名称代码用来翻译的模型
{ name: 'France', code: 'fr-fr', model: MODEL },
{ name: 'Spanish', code: 'es-es', model: MODEL },
{ name: 'Portuguese', code: 'pt-pt', model: MODEL },
{ name: 'Greek', code: 'el-gr', model: MODEL }
// 语言的名称 代码 用来翻译的模型
{ name: 'France', code: 'fr-fr', model: 'Qwen3-235B-A22B' },
{ name: 'Spanish', code: 'es-es', model: 'Qwen3-235B-A22B' },
{ name: 'Portuguese', code: 'pt-pt', model: 'Qwen3-235B-A22B' },
{ name: 'Greek', code: 'el-gr', model: 'Qwen3-235B-A22B' }
]
const fs = require('fs')
@@ -22,8 +19,8 @@ import OpenAI from 'openai'
const zh = JSON.parse(fs.readFileSync('src/renderer/src/i18n/locales/zh-cn.json', 'utf8')) as object
const openai = new OpenAI({
apiKey: API_KEY,
baseURL: BASE_URL
apiKey: Paratera_API_KEY,
baseURL: 'https://llmapi.paratera.com/v1'
})
// 递归遍历翻译

View File

@@ -1,33 +0,0 @@
import { occupiedDirs } from '@shared/config/constant'
import { app } from 'electron'
import fs from 'fs'
import path from 'path'
import { initAppDataDir } from './utils/file'
app.isPackaged && initAppDataDir()
// 在主进程中复制 appData 中某些一直被占用的文件
// 在renderer进程还没有启动时主进程可以复制这些文件到新的appData中
function copyOccupiedDirsInMainProcess() {
const newAppDataPath = process.argv
.slice(1)
.find((arg) => arg.startsWith('--new-data-path='))
?.split('--new-data-path=')[1]
if (!newAppDataPath) {
return
}
if (process.platform === 'win32') {
const appDataPath = app.getPath('userData')
occupiedDirs.forEach((dir) => {
const dirPath = path.join(appDataPath, dir)
const newDirPath = path.join(newAppDataPath, dir)
if (fs.existsSync(dirPath)) {
fs.cpSync(dirPath, newDirPath, { recursive: true })
}
})
}
}
copyOccupiedDirsInMainProcess()

View File

@@ -1,6 +1,8 @@
import { app } from 'electron'
import { getDataPath } from './utils'
import { isWindows7 } from './utils/runtime'
const isDev = process.env.NODE_ENV === 'development'
if (isDev) {
@@ -11,12 +13,12 @@ export const DATA_PATH = getDataPath()
export const titleBarOverlayDark = {
height: 40,
color: 'rgba(255,255,255,0)',
symbolColor: '#fff'
color: isWindows7() ? '#1c1c1c' : 'rgba(0,0,0,0)',
symbolColor: '#ffffff'
}
export const titleBarOverlayLight = {
height: 40,
color: 'rgba(255,255,255,0)',
symbolColor: '#000'
color: isWindows7() ? '#f4f4f4' : 'rgba(255,255,255,0)',
symbolColor: '#000000'
}

View File

@@ -1,6 +1,6 @@
interface IFilterList {
WINDOWS: string[]
MAC: string[]
MAC?: string[]
}
interface IFinetunedList {
@@ -20,7 +20,6 @@ interface IFinetunedList {
*************************************************************************/
export const SELECTION_PREDEFINED_BLACKLIST: IFilterList = {
WINDOWS: [
'explorer.exe',
// Screenshot
'snipaste.exe',
'pixpin.exe',
@@ -45,17 +44,14 @@ export const SELECTION_PREDEFINED_BLACKLIST: IFilterList = {
'sldworks.exe',
// Remote Desktop
'mstsc.exe'
],
MAC: []
]
}
export const SELECTION_FINETUNED_LIST: IFinetunedList = {
EXCLUDE_CLIPBOARD_CURSOR_DETECT: {
WINDOWS: ['acrobat.exe', 'wps.exe', 'cajviewer.exe'],
MAC: []
WINDOWS: ['acrobat.exe', 'wps.exe', 'cajviewer.exe']
},
INCLUDE_CLIPBOARD_DELAY_READ: {
WINDOWS: ['acrobat.exe', 'wps.exe', 'cajviewer.exe', 'foxitphantom.exe'],
MAC: []
WINDOWS: ['acrobat.exe', 'wps.exe', 'cajviewer.exe', 'foxitphantom.exe']
}
}

View File

@@ -5,15 +5,8 @@ import EmbeddingsFactory from './EmbeddingsFactory'
export default class Embeddings {
private sdk: BaseEmbeddings
constructor({ model, provider, apiKey, apiVersion, baseURL, dimensions }: KnowledgeBaseParams) {
this.sdk = EmbeddingsFactory.create({
model,
provider,
apiKey,
apiVersion,
baseURL,
dimensions
} as KnowledgeBaseParams)
constructor({ model, apiKey, apiVersion, baseURL, dimensions }: KnowledgeBaseParams) {
this.sdk = EmbeddingsFactory.create({ model, apiKey, apiVersion, baseURL, dimensions } as KnowledgeBaseParams)
}
public async init(): Promise<void> {
return this.sdk.init()

View File

@@ -0,0 +1,38 @@
import type { BaseEmbeddings } from '@cherrystudio/embedjs-interfaces'
import { OpenAiEmbeddings } from '@cherrystudio/embedjs-openai'
import { AzureOpenAiEmbeddings } from '@cherrystudio/embedjs-openai/src/azure-openai-embeddings'
import { getInstanceName } from '@main/utils'
import { KnowledgeBaseParams } from '@types'
import VoyageEmbeddings from './VoyageEmbeddings'
export default class EmbeddingsFactory {
static create({ model, apiKey, apiVersion, baseURL, dimensions }: KnowledgeBaseParams): BaseEmbeddings {
const batchSize = 10
if (model.includes('voyage')) {
return new VoyageEmbeddings({
modelName: model,
apiKey,
outputDimension: dimensions,
batchSize: 8
})
}
if (apiVersion !== undefined) {
return new AzureOpenAiEmbeddings({
azureOpenAIApiKey: apiKey,
azureOpenAIApiVersion: apiVersion,
azureOpenAIApiDeploymentName: model,
azureOpenAIApiInstanceName: getInstanceName(baseURL),
dimensions,
batchSize
})
}
return new OpenAiEmbeddings({
model,
apiKey,
dimensions,
batchSize,
configuration: { baseURL }
})
}
}

View File

@@ -1,20 +1,16 @@
import { BaseEmbeddings } from '@cherrystudio/embedjs-interfaces'
import { VoyageEmbeddings as _VoyageEmbeddings } from '@langchain/community/embeddings/voyage'
/**
*
*/
export const SUPPORTED_DIM_MODELS = ['voyage-3-large', 'voyage-3.5', 'voyage-3.5-lite', 'voyage-code-3']
export class VoyageEmbeddings extends BaseEmbeddings {
export default 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.outputDimension) {
throw new Error('You need to pass in the optional dimensions parameter for this model')
}
this.model = new _VoyageEmbeddings(this.configuration)
}
override async getDimensions(): Promise<number> {

View File

@@ -1,8 +1,3 @@
// don't reorder this file, it's used to initialize the app data dir and
// other which should be run before the main process is ready
// eslint-disable-next-line
import './bootstrap'
import '@main/config'
import { electronApp, optimizer } from '@electron-toolkit/utils'
@@ -25,6 +20,7 @@ import selectionService, { initSelectionService } from './services/SelectionServ
import { registerShortcuts } from './services/ShortcutService'
import { TrayService } from './services/TrayService'
import { windowService } from './services/WindowService'
import { setUserDataDir } from './utils/file'
Logger.initialize()
@@ -38,26 +34,6 @@ if (isWin) {
app.commandLine.appendSwitch('wm-window-animations-disabled')
}
// Enable features for unresponsive renderer js call stacks
app.commandLine.appendSwitch('enable-features', 'DocumentPolicyIncludeJSCallStacksInCrashReports')
app.on('web-contents-created', (_, webContents) => {
webContents.session.webRequest.onHeadersReceived((details, callback) => {
callback({
responseHeaders: {
...details.responseHeaders,
'Document-Policy': ['include-js-call-stacks-in-crash-reports']
}
})
})
webContents.on('unresponsive', async () => {
// Interrupt execution and collect call stack from unresponsive renderer
Logger.error('Renderer unresponsive start')
const callStack = await webContents.mainFrame.collectJavaScriptCallStack()
Logger.error('Renderer unresponsive js call stack\n', callStack)
})
})
// in production mode, handle uncaught exception and unhandled rejection globally
if (!isDev) {
// handle uncaught exception
@@ -76,6 +52,9 @@ if (!app.requestSingleInstanceLock()) {
app.quit()
process.exit(0)
} else {
// Portable dir must be setup before app ready
setUserDataDir()
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
@@ -124,27 +103,19 @@ if (!app.requestSingleInstanceLock()) {
registerProtocolClient(app)
// macOS specific: handle protocol when app is already running
app.on('open-url', (event, url) => {
event.preventDefault()
handleProtocolUrl(url)
})
const handleOpenUrl = (args: string[]) => {
const url = args.find((arg) => arg.startsWith(CHERRY_STUDIO_PROTOCOL + '://'))
if (url) handleProtocolUrl(url)
}
// for windows to start with url
handleOpenUrl(process.argv)
// Listen for second instance
app.on('second-instance', (_event, argv) => {
windowService.showMainWindow()
// Protocol handler for Windows/Linux
// The commandLine is an array of strings where the last item might be the URL
handleOpenUrl(argv)
const url = argv.find((arg) => arg.startsWith(CHERRY_STUDIO_PROTOCOL + '://'))
if (url) handleProtocolUrl(url)
})
app.on('browser-window-created', (_, window) => {

File diff suppressed because one or more lines are too long

View File

@@ -1,14 +1,12 @@
import fs from 'node:fs'
import { arch } from 'node:os'
import path from 'node:path'
import { isLinux, isMac, isWin } from '@main/constant'
import { isMac, isWin } from '@main/constant'
import { getBinaryPath, isBinaryExists, runInstallScript } from '@main/utils/process'
import { handleZoomFactor } from '@main/utils/zoom'
import { UpgradeChannel } from '@shared/config/constant'
import { IpcChannel } from '@shared/IpcChannel'
import { FileMetadata, Provider, Shortcut, ThemeMode } from '@types'
import { BrowserWindow, dialog, ipcMain, session, shell, systemPreferences, webContents } from 'electron'
import { Shortcut, ThemeMode } from '@types'
import { BrowserWindow, ipcMain, session, shell } from 'electron'
import log from 'electron-log'
import { Notification } from 'src/renderer/src/types/notification'
@@ -17,42 +15,35 @@ import BackupManager from './services/BackupManager'
import { configManager } from './services/ConfigManager'
import CopilotService from './services/CopilotService'
import { ExportService } from './services/ExportService'
import FileService from './services/FileService'
import FileStorage from './services/FileStorage'
import FileService from './services/FileSystemService'
import KnowledgeService from './services/KnowledgeService'
import mcpService from './services/MCPService'
import NotificationService from './services/NotificationService'
import * as NutstoreService from './services/NutstoreService'
import ObsidianVaultService from './services/ObsidianVaultService'
import { ProxyConfig, proxyManager } from './services/ProxyManager'
import { pythonService } from './services/PythonService'
import { FileServiceManager } from './services/remotefile/FileServiceManager'
import { searchService } from './services/SearchService'
import { SelectionService } from './services/SelectionService'
import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService'
import storeSyncService from './services/StoreSyncService'
import { themeService } from './services/ThemeService'
import VertexAIService from './services/VertexAIService'
import { setOpenLinkExternal } from './services/WebviewService'
import { windowService } from './services/WindowService'
import { calculateDirectorySize, getResourcePath } from './utils'
import { decrypt, encrypt } from './utils/aes'
import { getCacheDir, getConfigDir, getFilesDir, hasWritePermission, updateAppDataConfig } from './utils/file'
import { getCacheDir, getConfigDir, getFilesDir } from './utils/file'
import { compress, decompress } from './utils/zip'
const fileManager = new FileStorage()
const backupManager = new BackupManager()
const exportService = new ExportService(fileManager)
const obsidianVaultService = new ObsidianVaultService()
const vertexAIService = VertexAIService.getInstance()
export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
const appUpdater = new AppUpdater(mainWindow)
const notificationService = new NotificationService(mainWindow)
// Initialize Python service with main window
pythonService.setMainWindow(mainWindow)
ipcMain.handle(IpcChannel.App_Info, () => ({
version: app.getVersion(),
isPackaged: app.isPackaged,
@@ -63,8 +54,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
resourcesPath: getResourcePath(),
logsPath: log.transports.file.getFile().path,
arch: arch(),
isPortable: isWin && 'PORTABLE_EXECUTABLE_DIR' in process.env,
installPath: path.dirname(app.getPath('exe'))
isPortable: isWin && 'PORTABLE_EXECUTABLE_DIR' in process.env
}))
ipcMain.handle(IpcChannel.App_Proxy, async (_, proxy: string) => {
@@ -92,27 +82,6 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
configManager.setLanguage(language)
})
// spell check
ipcMain.handle(IpcChannel.App_SetEnableSpellCheck, (_, isEnable: boolean) => {
// disable spell check for all webviews
const webviews = webContents.getAllWebContents()
webviews.forEach((webview) => {
webview.session.setSpellCheckerEnabled(isEnable)
})
})
// spell check languages
ipcMain.handle(IpcChannel.App_SetSpellCheckLanguages, (_, languages: string[]) => {
if (languages.length === 0) {
return
}
const windows = BrowserWindow.getAllWindows()
windows.forEach((window) => {
window.webContents.session.setSpellCheckerLanguages(languages)
})
configManager.set('spellCheckLanguages', languages)
})
// launch on boot
ipcMain.handle(IpcChannel.App_SetLaunchOnBoot, (_, openAtLogin: boolean) => {
// Set login item settings for windows and mac
@@ -143,34 +112,6 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
configManager.setAutoUpdate(isActive)
})
ipcMain.handle(IpcChannel.App_SetTestPlan, async (_, isActive: boolean) => {
log.info('set test plan', isActive)
if (isActive !== configManager.getTestPlan()) {
appUpdater.cancelDownload()
configManager.setTestPlan(isActive)
}
})
ipcMain.handle(IpcChannel.App_SetTestChannel, async (_, channel: UpgradeChannel) => {
log.info('set test channel', channel)
if (channel !== configManager.getTestChannel()) {
appUpdater.cancelDownload()
configManager.setTestChannel(channel)
}
})
//only for mac
if (isMac) {
ipcMain.handle(IpcChannel.App_MacIsProcessTrusted, (): boolean => {
return systemPreferences.isTrustedAccessibilityClient(false)
})
//return is only the current state, not the new state
ipcMain.handle(IpcChannel.App_MacRequestProcessTrust, (): boolean => {
return systemPreferences.isTrustedAccessibilityClient(true)
})
}
ipcMain.handle(IpcChannel.Config_Set, (_, key: string, value: any, isNotify: boolean = false) => {
configManager.set(key, value, isNotify)
})
@@ -227,113 +168,6 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
}
})
let preventQuitListener: ((event: Electron.Event) => void) | null = null
ipcMain.handle(IpcChannel.App_SetStopQuitApp, (_, stop: boolean = false, reason: string = '') => {
if (stop) {
// Only add listener if not already added
if (!preventQuitListener) {
preventQuitListener = (event: Electron.Event) => {
event.preventDefault()
notificationService.sendNotification({
title: reason,
message: reason
} as Notification)
}
app.on('before-quit', preventQuitListener)
}
} else {
// Remove listener if it exists
if (preventQuitListener) {
app.removeListener('before-quit', preventQuitListener)
preventQuitListener = null
}
}
})
// Select app data path
ipcMain.handle(IpcChannel.App_Select, async (_, options: Electron.OpenDialogOptions) => {
try {
const { canceled, filePaths } = await dialog.showOpenDialog(options)
if (canceled || filePaths.length === 0) {
return null
}
return filePaths[0]
} catch (error: any) {
log.error('Failed to select app data path:', error)
return null
}
})
ipcMain.handle(IpcChannel.App_HasWritePermission, async (_, filePath: string) => {
return hasWritePermission(filePath)
})
// Set app data path
ipcMain.handle(IpcChannel.App_SetAppDataPath, async (_, filePath: string) => {
updateAppDataConfig(filePath)
app.setPath('userData', filePath)
})
ipcMain.handle(IpcChannel.App_GetDataPathFromArgs, () => {
return process.argv
.slice(1)
.find((arg) => arg.startsWith('--new-data-path='))
?.split('--new-data-path=')[1]
})
ipcMain.handle(IpcChannel.App_FlushAppData, () => {
BrowserWindow.getAllWindows().forEach((w) => {
w.webContents.session.flushStorageData()
w.webContents.session.cookies.flushStore()
w.webContents.session.closeAllConnections()
})
session.defaultSession.flushStorageData()
session.defaultSession.cookies.flushStore()
session.defaultSession.closeAllConnections()
})
ipcMain.handle(IpcChannel.App_IsNotEmptyDir, async (_, path: string) => {
return fs.readdirSync(path).length > 0
})
// Copy user data to new location
ipcMain.handle(IpcChannel.App_Copy, async (_, oldPath: string, newPath: string, occupiedDirs: string[] = []) => {
try {
await fs.promises.cp(oldPath, newPath, {
recursive: true,
filter: (src) => {
if (occupiedDirs.some((dir) => src.startsWith(path.resolve(dir)))) {
return false
}
return true
}
})
return { success: true }
} catch (error: any) {
log.error('Failed to copy user data:', error)
return { success: false, error: error.message }
}
})
// Relaunch app
ipcMain.handle(IpcChannel.App_RelaunchApp, (_, options?: Electron.RelaunchOptions) => {
// Fix for .AppImage
if (isLinux && process.env.APPIMAGE) {
log.info('Relaunching app with options:', process.env.APPIMAGE, options)
// On Linux, we need to use the APPIMAGE environment variable to relaunch
// https://github.com/electron-userland/electron-builder/issues/1727#issuecomment-769896927
options = options || {}
options.execPath = process.env.APPIMAGE
options.args = options.args || []
options.args.unshift('--appimage-extract-and-run')
}
app.relaunch(options)
app.exit(0)
})
// check for update
ipcMain.handle(IpcChannel.App_CheckForUpdate, async () => {
return await appUpdater.checkForUpdates()
@@ -378,42 +212,19 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.File_Clear, fileManager.clear)
ipcMain.handle(IpcChannel.File_Read, fileManager.readFile)
ipcMain.handle(IpcChannel.File_Delete, fileManager.deleteFile)
ipcMain.handle('file:deleteDir', fileManager.deleteDir)
ipcMain.handle(IpcChannel.File_Get, fileManager.getFile)
ipcMain.handle(IpcChannel.File_SelectFolder, fileManager.selectFolder)
ipcMain.handle(IpcChannel.File_CreateTempFile, fileManager.createTempFile)
ipcMain.handle(IpcChannel.File_Create, fileManager.createTempFile)
ipcMain.handle(IpcChannel.File_Write, fileManager.writeFile)
ipcMain.handle(IpcChannel.File_WriteWithId, fileManager.writeFileWithId)
ipcMain.handle(IpcChannel.File_SaveImage, fileManager.saveImage)
ipcMain.handle(IpcChannel.File_Base64Image, fileManager.base64Image)
ipcMain.handle(IpcChannel.File_SaveBase64Image, fileManager.saveBase64Image)
ipcMain.handle(IpcChannel.File_Base64File, fileManager.base64File)
ipcMain.handle(IpcChannel.File_GetPdfInfo, fileManager.pdfPageCount)
ipcMain.handle(IpcChannel.File_Download, fileManager.downloadFile)
ipcMain.handle(IpcChannel.File_Copy, fileManager.copyFile)
ipcMain.handle(IpcChannel.File_BinaryImage, fileManager.binaryImage)
// file service
ipcMain.handle(IpcChannel.FileService_Upload, async (_, provider: Provider, file: FileMetadata) => {
const service = FileServiceManager.getInstance().getService(provider)
return await service.uploadFile(file)
})
ipcMain.handle(IpcChannel.FileService_List, async (_, provider: Provider) => {
const service = FileServiceManager.getInstance().getService(provider)
return await service.listFiles()
})
ipcMain.handle(IpcChannel.FileService_Delete, async (_, provider: Provider, fileId: string) => {
const service = FileServiceManager.getInstance().getService(provider)
return await service.deleteFile(fileId)
})
ipcMain.handle(IpcChannel.FileService_Retrieve, async (_, provider: Provider, fileId: string) => {
const service = FileServiceManager.getInstance().getService(provider)
return await service.retrieveFile(fileId)
})
// fs
ipcMain.handle(IpcChannel.Fs_Read, FileService.readFile)
@@ -443,7 +254,6 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.KnowledgeBase_Remove, KnowledgeService.remove)
ipcMain.handle(IpcChannel.KnowledgeBase_Search, KnowledgeService.search)
ipcMain.handle(IpcChannel.KnowledgeBase_Rerank, KnowledgeService.rerank)
ipcMain.handle(IpcChannel.KnowledgeBase_Check_Quota, KnowledgeService.checkQuota)
// window
ipcMain.handle(IpcChannel.Windows_SetMinimumSize, (_, width: number, height: number) => {
@@ -458,15 +268,6 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
}
})
// VertexAI
ipcMain.handle(IpcChannel.VertexAI_GetAuthHeaders, async (_, params) => {
return vertexAIService.getAuthHeaders(params)
})
ipcMain.handle(IpcChannel.VertexAI_ClearAuthCache, async (_, projectId: string, clientEmail?: string) => {
vertexAIService.clearAuthCache(projectId, clientEmail)
})
// mini window
ipcMain.handle(IpcChannel.MiniWindow_Show, () => windowService.showMiniWindow())
ipcMain.handle(IpcChannel.MiniWindow_Hide, () => windowService.hideMiniWindow())
@@ -495,14 +296,6 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.Mcp_GetInstallInfo, mcpService.getInstallInfo)
ipcMain.handle(IpcChannel.Mcp_CheckConnectivity, mcpService.checkMcpConnectivity)
// Register Python execution handler
ipcMain.handle(
IpcChannel.Python_Execute,
async (_, script: string, context?: Record<string, any>, timeout?: number) => {
return await pythonService.executeScript(script, context, timeout)
}
)
ipcMain.handle(IpcChannel.App_IsBinaryExist, (_, name: string) => isBinaryExists(name))
ipcMain.handle(IpcChannel.App_GetBinaryPath, (_, name: string) => getBinaryPath(name))
ipcMain.handle(IpcChannel.App_InstallUvBinary, () => runInstallScript('install-uv.js'))
@@ -548,17 +341,9 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
setOpenLinkExternal(webviewId, isExternal)
)
ipcMain.handle(IpcChannel.Webview_SetSpellCheckEnabled, (_, webviewId: number, isEnable: boolean) => {
const webview = webContents.fromId(webviewId)
if (!webview) return
webview.session.setSpellCheckerEnabled(isEnable)
})
// store sync
storeSyncService.registerIpcHandler()
// selection assistant
SelectionService.registerIpcHandler()
ipcMain.handle(IpcChannel.App_QuoteToMain, (_, text: string) => windowService.quoteToMainWindow(text))
}

View File

@@ -1,67 +0,0 @@
import type { BaseEmbeddings } from '@cherrystudio/embedjs-interfaces'
import { OllamaEmbeddings } from '@cherrystudio/embedjs-ollama'
import { OpenAiEmbeddings } from '@cherrystudio/embedjs-openai'
import { AzureOpenAiEmbeddings } from '@cherrystudio/embedjs-openai/src/azure-openai-embeddings'
import { getInstanceName } from '@main/utils'
import { KnowledgeBaseParams } from '@types'
import { SUPPORTED_DIM_MODELS as VOYAGE_SUPPORTED_DIM_MODELS, 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
})
}
}
if (provider === 'ollama') {
if (baseURL.includes('v1/')) {
return new OllamaEmbeddings({
model: model,
baseUrl: baseURL.replace('v1/', ''),
requestOptions: {
// @ts-ignore expected
'encoding-format': 'float'
}
})
}
return new OllamaEmbeddings({
model: model,
baseUrl: baseURL,
requestOptions: {
// @ts-ignore expected
'encoding-format': 'float'
}
})
}
if (apiVersion !== undefined) {
return new AzureOpenAiEmbeddings({
azureOpenAIApiKey: apiKey,
azureOpenAIApiVersion: apiVersion,
azureOpenAIApiDeploymentName: model,
azureOpenAIApiInstanceName: getInstanceName(baseURL),
dimensions,
batchSize
})
}
return new OpenAiEmbeddings({
model,
apiKey,
dimensions,
batchSize,
configuration: { baseURL }
})
}
}

View File

@@ -1,44 +0,0 @@
import { BaseLoader } from '@cherrystudio/embedjs-interfaces'
import { cleanString } from '@cherrystudio/embedjs-utils'
import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters'
import md5 from 'md5'
export class NoteLoader extends BaseLoader<{ type: 'NoteLoader' }> {
private readonly text: string
private readonly sourceUrl?: string
constructor({
text,
sourceUrl,
chunkSize,
chunkOverlap
}: {
text: string
sourceUrl?: string
chunkSize?: number
chunkOverlap?: number
}) {
super(`NoteLoader_${md5(text + (sourceUrl || ''))}`, { text, sourceUrl }, chunkSize ?? 2000, chunkOverlap ?? 0)
this.text = text
this.sourceUrl = sourceUrl
}
override async *getUnfilteredChunks() {
const chunker = new RecursiveCharacterTextSplitter({
chunkSize: this.chunkSize,
chunkOverlap: this.chunkOverlap
})
const chunks = await chunker.splitText(cleanString(this.text))
for (const chunk of chunks) {
yield {
pageContent: chunk,
metadata: {
type: 'NoteLoader' as const,
source: this.sourceUrl || 'note'
}
}
}
}
}

View File

@@ -4,7 +4,7 @@ import { JsonLoader, LocalPathLoader, RAGApplication, TextLoader } from '@cherry
import type { AddLoaderReturn } from '@cherrystudio/embedjs-interfaces'
import { WebLoader } from '@cherrystudio/embedjs-loader-web'
import { LoaderReturn } from '@shared/config/types'
import { FileMetadata, KnowledgeBaseParams } from '@types'
import { FileType, KnowledgeBaseParams } from '@types'
import Logger from 'electron-log'
import { DraftsExportLoader } from './draftsExportLoader'
@@ -16,7 +16,6 @@ const FILE_LOADER_MAP: Record<string, string> = {
// 内置类型
'.pdf': 'common',
'.csv': 'common',
'.doc': 'common',
'.docx': 'common',
'.pptx': 'common',
'.xlsx': 'common',
@@ -39,7 +38,7 @@ const FILE_LOADER_MAP: Record<string, string> = {
export async function addOdLoader(
ragApplication: RAGApplication,
file: FileMetadata,
file: FileType,
base: KnowledgeBaseParams,
forceReload: boolean
): Promise<AddLoaderReturn> {
@@ -65,7 +64,7 @@ export async function addOdLoader(
export async function addFileLoader(
ragApplication: RAGApplication,
file: FileMetadata,
file: FileType,
base: KnowledgeBaseParams,
forceReload: boolean
): Promise<LoaderReturn> {

View File

@@ -6,7 +6,6 @@ import DifyKnowledgeServer from './dify-knowledge'
import FetchServer from './fetch'
import FileSystemServer from './filesystem'
import MemoryServer from './memory'
import PythonServer from './python'
import ThinkingServer from './sequentialthinking'
export function createInMemoryMCPServer(name: string, args: string[] = [], envs: Record<string, string> = {}): Server {
@@ -32,9 +31,6 @@ export function createInMemoryMCPServer(name: string, args: string[] = [], envs:
const difyKey = envs.DIFY_KEY
return new DifyKnowledgeServer(difyKey, args).server
}
case '@cherry/python': {
return new PythonServer().server
}
default:
throw new Error(`Unknown in-memory MCP server: ${name}`)
}

View File

@@ -1,113 +0,0 @@
import { pythonService } from '@main/services/PythonService'
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } from '@modelcontextprotocol/sdk/types.js'
import Logger from 'electron-log'
/**
* Python MCP Server for executing Python code using Pyodide
*/
class PythonServer {
public server: Server
constructor() {
this.server = new Server(
{
name: 'python-server',
version: '1.0.0'
},
{
capabilities: {
tools: {}
}
}
)
this.setupRequestHandlers()
}
private setupRequestHandlers() {
// List available tools
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: 'python_execute',
description: `Execute Python code using Pyodide in a sandboxed environment. Supports most Python standard library and scientific packages.
The code will be executed with Python 3.12.
Dependencies may be defined via PEP 723 script metadata, e.g. to install "pydantic", the script should start
with a comment of the form:
# /// script
# dependencies = ['pydantic']
# ///
print('python code here')`,
inputSchema: {
type: 'object',
properties: {
code: {
type: 'string',
description: 'The Python code to execute'
},
context: {
type: 'object',
description: 'Optional context variables to pass to the Python execution environment',
additionalProperties: true
},
timeout: {
type: 'number',
description: 'Timeout in milliseconds (default: 60000)',
default: 60000
}
},
required: ['code']
}
}
]
}
})
// Handle tool calls
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params
if (name !== 'python_execute') {
throw new McpError(ErrorCode.MethodNotFound, `Tool ${name} not found`)
}
try {
const {
code,
context = {},
timeout = 60000
} = args as {
code: string
context?: Record<string, any>
timeout?: number
}
if (!code || typeof code !== 'string') {
throw new McpError(ErrorCode.InvalidParams, 'Code parameter is required and must be a string')
}
Logger.info('Executing Python code via Pyodide')
const result = await pythonService.executeScript(code, context, timeout)
return {
content: [
{
type: 'text',
text: result
}
]
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
Logger.error('Python execution error:', errorMessage)
throw new McpError(ErrorCode.InternalError, `Python execution failed: ${errorMessage}`)
}
})
}
}
export default PythonServer

View File

@@ -106,7 +106,6 @@ class SequentialThinkingServer {
type: 'text',
text: JSON.stringify(
{
thought: validatedInput.thought,
thoughtNumber: validatedInput.thoughtNumber,
totalThoughts: validatedInput.totalThoughts,
nextThoughtNeeded: validatedInput.nextThoughtNeeded,

View File

@@ -1,122 +0,0 @@
import fs from 'node:fs'
import path from 'node:path'
import { windowService } from '@main/services/WindowService'
import { getFileExt } from '@main/utils/file'
import { FileMetadata, OcrProvider } from '@types'
import { app } from 'electron'
import { TypedArray } from 'pdfjs-dist/types/src/display/api'
export default abstract class BaseOcrProvider {
protected provider: OcrProvider
public storageDir = path.join(app.getPath('userData'), 'Data', 'Files')
constructor(provider: OcrProvider) {
if (!provider) {
throw new Error('OCR provider is not set')
}
this.provider = provider
}
abstract parseFile(sourceId: string, file: FileMetadata): Promise<{ processedFile: FileMetadata; quota?: number }>
/**
* 检查文件是否已经被预处理过
* 统一检测方法:如果 Data/Files/{file.id} 是目录,说明已被预处理
* @param file 文件信息
* @returns 如果已处理返回处理后的文件信息否则返回null
*/
public async checkIfAlreadyProcessed(file: FileMetadata): Promise<FileMetadata | null> {
try {
// 检查 Data/Files/{file.id} 是否是目录
const preprocessDirPath = path.join(this.storageDir, file.id)
if (fs.existsSync(preprocessDirPath)) {
const stats = await fs.promises.stat(preprocessDirPath)
// 如果是目录,说明已经被预处理过
if (stats.isDirectory()) {
// 查找目录中的处理结果文件
const files = await fs.promises.readdir(preprocessDirPath)
// 查找主要的处理结果文件(.md 或 .txt
const processedFile = files.find((fileName) => fileName.endsWith('.md') || fileName.endsWith('.txt'))
if (processedFile) {
const processedFilePath = path.join(preprocessDirPath, processedFile)
const processedStats = await fs.promises.stat(processedFilePath)
const ext = getFileExt(processedFile)
return {
...file,
name: file.name.replace(file.ext, ext),
path: processedFilePath,
ext: ext,
size: processedStats.size,
created_at: processedStats.birthtime.toISOString()
}
}
}
}
return null
} catch (error) {
// 如果检查过程中出现错误返回null表示未处理
return null
}
}
/**
* 辅助方法:延迟执行
*/
public delay = (ms: number): Promise<void> => {
return new Promise((resolve) => setTimeout(resolve, ms))
}
public async readPdf(
source: string | URL | TypedArray,
passwordCallback?: (fn: (password: string) => void, reason: string) => string
) {
const { getDocument } = await import('pdfjs-dist/legacy/build/pdf.mjs')
const documentLoadingTask = getDocument(source)
if (passwordCallback) {
documentLoadingTask.onPassword = passwordCallback
}
const document = await documentLoadingTask.promise
return document
}
public async sendOcrProgress(sourceId: string, progress: number): Promise<void> {
const mainWindow = windowService.getMainWindow()
mainWindow?.webContents.send('file-ocr-progress', {
itemId: sourceId,
progress: progress
})
}
/**
* 将文件移动到附件目录
* @param fileId 文件id
* @param filePaths 需要移动的文件路径数组
* @returns 移动后的文件路径数组
*/
public moveToAttachmentsDir(fileId: string, filePaths: string[]): string[] {
const attachmentsPath = path.join(this.storageDir, fileId)
if (!fs.existsSync(attachmentsPath)) {
fs.mkdirSync(attachmentsPath, { recursive: true })
}
const movedPaths: string[] = []
for (const filePath of filePaths) {
if (fs.existsSync(filePath)) {
const fileName = path.basename(filePath)
const destPath = path.join(attachmentsPath, fileName)
fs.copyFileSync(filePath, destPath)
fs.unlinkSync(filePath) // 删除原文件,实现"移动"
movedPaths.push(destPath)
}
}
return movedPaths
}
}

View File

@@ -1,12 +0,0 @@
import { FileMetadata, OcrProvider } from '@types'
import BaseOcrProvider from './BaseOcrProvider'
export default class DefaultOcrProvider extends BaseOcrProvider {
constructor(provider: OcrProvider) {
super(provider)
}
public parseFile(): Promise<{ processedFile: FileMetadata }> {
throw new Error('Method not implemented.')
}
}

View File

@@ -1,128 +0,0 @@
import { isMac } from '@main/constant'
import { FileMetadata, OcrProvider } from '@types'
import Logger from 'electron-log'
import * as fs from 'fs'
import * as path from 'path'
import { TextItem } from 'pdfjs-dist/types/src/display/api'
import BaseOcrProvider from './BaseOcrProvider'
export default class MacSysOcrProvider extends BaseOcrProvider {
private readonly MIN_TEXT_LENGTH = 1000
private MacOCR: any
private async initMacOCR() {
if (!isMac) {
throw new Error('MacSysOcrProvider is only available on macOS')
}
if (!this.MacOCR) {
try {
// @ts-ignore This module is optional and only installed/available on macOS. Runtime checks prevent execution on other platforms.
const module = await import('@cherrystudio/mac-system-ocr')
this.MacOCR = module.default
} catch (error) {
Logger.error('[OCR] Failed to load mac-system-ocr:', error)
throw error
}
}
return this.MacOCR
}
private getRecognitionLevel(level?: number) {
return level === 0 ? this.MacOCR.RECOGNITION_LEVEL_FAST : this.MacOCR.RECOGNITION_LEVEL_ACCURATE
}
constructor(provider: OcrProvider) {
super(provider)
}
private async processPages(
results: any,
totalPages: number,
sourceId: string,
writeStream: fs.WriteStream
): Promise<void> {
await this.initMacOCR()
// TODO: 下个版本后面使用批处理以及p-queue来优化
for (let i = 0; i < totalPages; i++) {
// Convert pages to buffers
const pageNum = i + 1
const pageBuffer = await results.getPage(pageNum)
// Process batch
const ocrResult = await this.MacOCR.recognizeFromBuffer(pageBuffer, {
ocrOptions: {
recognitionLevel: this.getRecognitionLevel(this.provider.options?.recognitionLevel),
minConfidence: this.provider.options?.minConfidence || 0.5
}
})
// Write results in order
writeStream.write(ocrResult.text + '\n')
// Update progress
await this.sendOcrProgress(sourceId, (pageNum / totalPages) * 100)
}
}
public async isScanPdf(buffer: Buffer): Promise<boolean> {
const doc = await this.readPdf(new Uint8Array(buffer))
const pageLength = doc.numPages
let counts = 0
const pagesToCheck = Math.min(pageLength, 10)
for (let i = 0; i < pagesToCheck; i++) {
const page = await doc.getPage(i + 1)
const pageData = await page.getTextContent()
const pageText = pageData.items.map((item) => (item as TextItem).str).join('')
counts += pageText.length
if (counts >= this.MIN_TEXT_LENGTH) {
return false
}
}
return true
}
public async parseFile(sourceId: string, file: FileMetadata): Promise<{ processedFile: FileMetadata }> {
Logger.info(`[OCR] Starting OCR process for file: ${file.name}`)
if (file.ext === '.pdf') {
try {
const { pdf } = await import('@cherrystudio/pdf-to-img-napi')
const pdfBuffer = await fs.promises.readFile(file.path)
const results = await pdf(pdfBuffer, {
scale: 2
})
const totalPages = results.length
const baseDir = path.dirname(file.path)
const baseName = path.basename(file.path, path.extname(file.path))
const txtFileName = `${baseName}.txt`
const txtFilePath = path.join(baseDir, txtFileName)
const writeStream = fs.createWriteStream(txtFilePath)
await this.processPages(results, totalPages, sourceId, writeStream)
await new Promise<void>((resolve, reject) => {
writeStream.end(() => {
Logger.info(`[OCR] OCR process completed successfully for ${file.origin_name}`)
resolve()
})
writeStream.on('error', reject)
})
const movedPaths = this.moveToAttachmentsDir(file.id, [txtFilePath])
return {
processedFile: {
...file,
name: txtFileName,
path: movedPaths[0],
ext: '.txt',
size: fs.statSync(movedPaths[0]).size
}
}
} catch (error) {
Logger.error('[OCR] Error during OCR process:', error)
throw error
}
}
return { processedFile: file }
}
}

View File

@@ -1,26 +0,0 @@
import { FileMetadata, OcrProvider as Provider } from '@types'
import BaseOcrProvider from './BaseOcrProvider'
import OcrProviderFactory from './OcrProviderFactory'
export default class OcrProvider {
private sdk: BaseOcrProvider
constructor(provider: Provider) {
this.sdk = OcrProviderFactory.create(provider)
}
public async parseFile(
sourceId: string,
file: FileMetadata
): Promise<{ processedFile: FileMetadata; quota?: number }> {
return this.sdk.parseFile(sourceId, file)
}
/**
* 检查文件是否已经被预处理过
* @param file 文件信息
* @returns 如果已处理返回处理后的文件信息否则返回null
*/
public async checkIfAlreadyProcessed(file: FileMetadata): Promise<FileMetadata | null> {
return this.sdk.checkIfAlreadyProcessed(file)
}
}

View File

@@ -1,20 +0,0 @@
import { isMac } from '@main/constant'
import { OcrProvider } from '@types'
import Logger from 'electron-log'
import BaseOcrProvider from './BaseOcrProvider'
import DefaultOcrProvider from './DefaultOcrProvider'
import MacSysOcrProvider from './MacSysOcrProvider'
export default class OcrProviderFactory {
static create(provider: OcrProvider): BaseOcrProvider {
switch (provider.id) {
case 'system':
if (!isMac) {
Logger.warn('[OCR] System OCR provider is only available on macOS')
}
return new MacSysOcrProvider(provider)
default:
return new DefaultOcrProvider(provider)
}
}
}

View File

@@ -0,0 +1,36 @@
console.info('inject polyfill win7')
// fix for node_modules\@libsql\isomorphic-fetch\node.cjs
if (!globalThis.fetch) {
globalThis.fetch = require('node-fetch')
}
if (!globalThis.Request) {
const { Request, Headers } = require('node-fetch')
globalThis.Request = Request
globalThis.Headers = Headers
}
// fix for node_modules/undici/lib/web/fetch/webidl.js
if (!globalThis.Blob) {
const { Blob } = require('blob-polyfill')
globalThis.Blob = Blob
}
// fix for node_modules/undici/lib/web/fetch/webidl.js
if (!globalThis.ReadableStream) {
const { ReadableStream, TransformStream } = require('web-streams-polyfill')
globalThis.ReadableStream = ReadableStream
globalThis.TransformStream = TransformStream
console.log('ReadableStream', ReadableStream)
}
if (!globalThis.DOMException) {
globalThis.DOMException = require('domexception')
}
if (!globalThis.crypto) {
const { Crypto } = require('@peculiar/webcrypto')
globalThis.crypto = new Crypto()
}
console.info('inject polyfill win7 ok')

View File

@@ -1,126 +0,0 @@
import fs from 'node:fs'
import path from 'node:path'
import { windowService } from '@main/services/WindowService'
import { getFileExt } from '@main/utils/file'
import { FileMetadata, PreprocessProvider } from '@types'
import { app } from 'electron'
import { TypedArray } from 'pdfjs-dist/types/src/display/api'
export default abstract class BasePreprocessProvider {
protected provider: PreprocessProvider
protected userId?: string
public storageDir = path.join(app.getPath('userData'), 'Data', 'Files')
constructor(provider: PreprocessProvider, userId?: string) {
if (!provider) {
throw new Error('Preprocess provider is not set')
}
this.provider = provider
this.userId = userId
}
abstract parseFile(sourceId: string, file: FileMetadata): Promise<{ processedFile: FileMetadata; quota?: number }>
abstract checkQuota(): Promise<number>
/**
* 检查文件是否已经被预处理过
* 统一检测方法:如果 Data/Files/{file.id} 是目录,说明已被预处理
* @param file 文件信息
* @returns 如果已处理返回处理后的文件信息否则返回null
*/
public async checkIfAlreadyProcessed(file: FileMetadata): Promise<FileMetadata | null> {
try {
// 检查 Data/Files/{file.id} 是否是目录
const preprocessDirPath = path.join(this.storageDir, file.id)
if (fs.existsSync(preprocessDirPath)) {
const stats = await fs.promises.stat(preprocessDirPath)
// 如果是目录,说明已经被预处理过
if (stats.isDirectory()) {
// 查找目录中的处理结果文件
const files = await fs.promises.readdir(preprocessDirPath)
// 查找主要的处理结果文件(.md 或 .txt
const processedFile = files.find((fileName) => fileName.endsWith('.md') || fileName.endsWith('.txt'))
if (processedFile) {
const processedFilePath = path.join(preprocessDirPath, processedFile)
const processedStats = await fs.promises.stat(processedFilePath)
const ext = getFileExt(processedFile)
return {
...file,
name: file.name.replace(file.ext, ext),
path: processedFilePath,
ext: ext,
size: processedStats.size,
created_at: processedStats.birthtime.toISOString()
}
}
}
}
return null
} catch (error) {
// 如果检查过程中出现错误返回null表示未处理
return null
}
}
/**
* 辅助方法:延迟执行
*/
public delay = (ms: number): Promise<void> => {
return new Promise((resolve) => setTimeout(resolve, ms))
}
public async readPdf(
source: string | URL | TypedArray,
passwordCallback?: (fn: (password: string) => void, reason: string) => string
) {
const { getDocument } = await import('pdfjs-dist/legacy/build/pdf.mjs')
const documentLoadingTask = getDocument(source)
if (passwordCallback) {
documentLoadingTask.onPassword = passwordCallback
}
const document = await documentLoadingTask.promise
return document
}
public async sendPreprocessProgress(sourceId: string, progress: number): Promise<void> {
const mainWindow = windowService.getMainWindow()
mainWindow?.webContents.send('file-preprocess-progress', {
itemId: sourceId,
progress: progress
})
}
/**
* 将文件移动到附件目录
* @param fileId 文件id
* @param filePaths 需要移动的文件路径数组
* @returns 移动后的文件路径数组
*/
public moveToAttachmentsDir(fileId: string, filePaths: string[]): string[] {
const attachmentsPath = path.join(this.storageDir, fileId)
if (!fs.existsSync(attachmentsPath)) {
fs.mkdirSync(attachmentsPath, { recursive: true })
}
const movedPaths: string[] = []
for (const filePath of filePaths) {
if (fs.existsSync(filePath)) {
const fileName = path.basename(filePath)
const destPath = path.join(attachmentsPath, fileName)
fs.copyFileSync(filePath, destPath)
fs.unlinkSync(filePath) // 删除原文件,实现"移动"
movedPaths.push(destPath)
}
}
return movedPaths
}
}

View File

@@ -1,16 +0,0 @@
import { FileMetadata, PreprocessProvider } from '@types'
import BasePreprocessProvider from './BasePreprocessProvider'
export default class DefaultPreprocessProvider extends BasePreprocessProvider {
constructor(provider: PreprocessProvider) {
super(provider)
}
public parseFile(): Promise<{ processedFile: FileMetadata }> {
throw new Error('Method not implemented.')
}
public checkQuota(): Promise<number> {
throw new Error('Method not implemented.')
}
}

View File

@@ -1,329 +0,0 @@
import fs from 'node:fs'
import path from 'node:path'
import { FileMetadata, PreprocessProvider } from '@types'
import AdmZip from 'adm-zip'
import axios, { AxiosRequestConfig } from 'axios'
import Logger from 'electron-log'
import BasePreprocessProvider from './BasePreprocessProvider'
type ApiResponse<T> = {
code: string
data: T
message?: string
}
type PreuploadResponse = {
uid: string
url: string
}
type StatusResponse = {
status: string
progress: number
}
type ParsedFileResponse = {
status: string
url: string
}
export default class Doc2xPreprocessProvider extends BasePreprocessProvider {
constructor(provider: PreprocessProvider) {
super(provider)
}
private async validateFile(filePath: string): Promise<void> {
const pdfBuffer = await fs.promises.readFile(filePath)
const doc = await this.readPdf(new Uint8Array(pdfBuffer))
// 文件页数小于1000页
if (doc.numPages >= 1000) {
throw new Error(`PDF page count (${doc.numPages}) exceeds the limit of 1000 pages`)
}
// 文件大小小于300MB
if (pdfBuffer.length >= 300 * 1024 * 1024) {
const fileSizeMB = Math.round(pdfBuffer.length / (1024 * 1024))
throw new Error(`PDF file size (${fileSizeMB}MB) exceeds the limit of 300MB`)
}
}
public async parseFile(sourceId: string, file: FileMetadata): Promise<{ processedFile: FileMetadata }> {
try {
Logger.info(`Preprocess processing started: ${file.path}`)
// 步骤1: 准备上传
const { uid, url } = await this.preupload()
Logger.info(`Preprocess preupload completed: uid=${uid}`)
await this.validateFile(file.path)
// 步骤2: 上传文件
await this.putFile(file.path, url)
// 步骤3: 等待处理完成
await this.waitForProcessing(sourceId, uid)
Logger.info(`Preprocess parsing completed successfully for: ${file.path}`)
// 步骤4: 导出文件
const { path: outputPath } = await this.exportFile(file, uid)
// 步骤5: 创建处理后的文件信息
return {
processedFile: this.createProcessedFileInfo(file, outputPath)
}
} catch (error) {
Logger.error(
`Preprocess processing failed for ${file.path}: ${error instanceof Error ? error.message : String(error)}`
)
throw error
}
}
private createProcessedFileInfo(file: FileMetadata, outputPath: string): FileMetadata {
const outputFilePath = `${outputPath}/${file.name.split('.').slice(0, -1).join('.')}.md`
return {
...file,
name: file.name.replace('.pdf', '.md'),
path: outputFilePath,
ext: '.md',
size: fs.statSync(outputFilePath).size
}
}
/**
* 导出文件
* @param file 文件信息
* @param uid 预上传响应的uid
* @returns 导出文件的路径
*/
public async exportFile(file: FileMetadata, uid: string): Promise<{ path: string }> {
Logger.info(`Exporting file: ${file.path}`)
// 步骤1: 转换文件
await this.convertFile(uid, file.path)
Logger.info(`File conversion completed for: ${file.path}`)
// 步骤2: 等待导出并获取URL
const exportUrl = await this.waitForExport(uid)
// 步骤3: 下载并解压文件
return this.downloadFile(exportUrl, file)
}
/**
* 等待处理完成
* @param sourceId 源文件ID
* @param uid 预上传响应的uid
*/
private async waitForProcessing(sourceId: string, uid: string): Promise<void> {
while (true) {
await this.delay(1000)
const { status, progress } = await this.getStatus(uid)
await this.sendPreprocessProgress(sourceId, progress)
Logger.info(`Preprocess processing status: ${status}, progress: ${progress}%`)
if (status === 'success') {
return
} else if (status === 'failed') {
throw new Error('Preprocess processing failed')
}
}
}
/**
* 等待导出完成
* @param uid 预上传响应的uid
* @returns 导出文件的url
*/
private async waitForExport(uid: string): Promise<string> {
while (true) {
await this.delay(1000)
const { status, url } = await this.getParsedFile(uid)
Logger.info(`Export status: ${status}`)
if (status === 'success' && url) {
return url
} else if (status === 'failed') {
throw new Error('Export failed')
}
}
}
/**
* 预上传文件
* @returns 预上传响应的url和uid
*/
private async preupload(): Promise<PreuploadResponse> {
const config = this.createAuthConfig()
const endpoint = `${this.provider.apiHost}/api/v2/parse/preupload`
try {
const { data } = await axios.post<ApiResponse<PreuploadResponse>>(endpoint, null, config)
if (data.code === 'success' && data.data) {
return data.data
} else {
throw new Error(`API returned error: ${data.message || JSON.stringify(data)}`)
}
} catch (error) {
Logger.error(`Failed to get preupload URL: ${error instanceof Error ? error.message : String(error)}`)
throw new Error('Failed to get preupload URL')
}
}
/**
* 上传文件
* @param filePath 文件路径
* @param url 预上传响应的url
*/
private async putFile(filePath: string, url: string): Promise<void> {
try {
const fileStream = fs.createReadStream(filePath)
const response = await axios.put(url, fileStream)
if (response.status !== 200) {
throw new Error(`HTTP status ${response.status}: ${response.statusText}`)
}
} catch (error) {
Logger.error(`Failed to upload file ${filePath}: ${error instanceof Error ? error.message : String(error)}`)
throw new Error('Failed to upload file')
}
}
private async getStatus(uid: string): Promise<StatusResponse> {
const config = this.createAuthConfig()
const endpoint = `${this.provider.apiHost}/api/v2/parse/status?uid=${uid}`
try {
const response = await axios.get<ApiResponse<StatusResponse>>(endpoint, config)
if (response.data.code === 'success' && response.data.data) {
return response.data.data
} else {
throw new Error(`API returned error: ${response.data.message || JSON.stringify(response.data)}`)
}
} catch (error) {
Logger.error(`Failed to get status for uid ${uid}: ${error instanceof Error ? error.message : String(error)}`)
throw new Error('Failed to get processing status')
}
}
/**
* Preprocess文件
* @param uid 预上传响应的uid
* @param filePath 文件路径
*/
private async convertFile(uid: string, filePath: string): Promise<void> {
const fileName = path.basename(filePath).split('.')[0]
const config = {
...this.createAuthConfig(),
headers: {
...this.createAuthConfig().headers,
'Content-Type': 'application/json'
}
}
const payload = {
uid,
to: 'md',
formula_mode: 'normal',
filename: fileName
}
const endpoint = `${this.provider.apiHost}/api/v2/convert/parse`
try {
const response = await axios.post<ApiResponse<any>>(endpoint, payload, config)
if (response.data.code !== 'success') {
throw new Error(`API returned error: ${response.data.message || JSON.stringify(response.data)}`)
}
} catch (error) {
Logger.error(`Failed to convert file ${filePath}: ${error instanceof Error ? error.message : String(error)}`)
throw new Error('Failed to convert file')
}
}
/**
* 获取解析后的文件信息
* @param uid 预上传响应的uid
* @returns 解析后的文件信息
*/
private async getParsedFile(uid: string): Promise<ParsedFileResponse> {
const config = this.createAuthConfig()
const endpoint = `${this.provider.apiHost}/api/v2/convert/parse/result?uid=${uid}`
try {
const response = await axios.get<ApiResponse<ParsedFileResponse>>(endpoint, config)
if (response.status === 200 && response.data.data) {
return response.data.data
} else {
throw new Error(`HTTP status ${response.status}: ${response.statusText}`)
}
} catch (error) {
Logger.error(
`Failed to get parsed file for uid ${uid}: ${error instanceof Error ? error.message : String(error)}`
)
throw new Error('Failed to get parsed file information')
}
}
/**
* 下载文件
* @param url 导出文件的url
* @param file 文件信息
* @returns 下载文件的路径
*/
private async downloadFile(url: string, file: FileMetadata): Promise<{ path: string }> {
const dirPath = this.storageDir
// 使用统一的存储路径Data/Files/{file.id}/
const extractPath = path.join(dirPath, file.id)
const zipPath = path.join(dirPath, `${file.id}.zip`)
// 确保目录存在
fs.mkdirSync(dirPath, { recursive: true })
fs.mkdirSync(extractPath, { recursive: true })
Logger.info(`Downloading to export path: ${zipPath}`)
try {
// 下载文件
const response = await axios.get(url, { responseType: 'arraybuffer' })
fs.writeFileSync(zipPath, response.data)
// 确保提取目录存在
if (!fs.existsSync(extractPath)) {
fs.mkdirSync(extractPath, { recursive: true })
}
// 解压文件
const zip = new AdmZip(zipPath)
zip.extractAllTo(extractPath, true)
Logger.info(`Extracted files to: ${extractPath}`)
// 删除临时ZIP文件
fs.unlinkSync(zipPath)
return { path: extractPath }
} catch (error) {
Logger.error(`Failed to download and extract file: ${error instanceof Error ? error.message : String(error)}`)
throw new Error('Failed to download and extract file')
}
}
private createAuthConfig(): AxiosRequestConfig {
return {
headers: {
Authorization: `Bearer ${this.provider.apiKey}`
}
}
}
public checkQuota(): Promise<number> {
throw new Error('Method not implemented.')
}
}

View File

@@ -1,399 +0,0 @@
import fs from 'node:fs'
import path from 'node:path'
import { FileMetadata, PreprocessProvider } from '@types'
import AdmZip from 'adm-zip'
import axios from 'axios'
import Logger from 'electron-log'
import BasePreprocessProvider from './BasePreprocessProvider'
type ApiResponse<T> = {
code: number
data: T
msg?: string
trace_id?: string
}
type BatchUploadResponse = {
batch_id: string
file_urls: string[]
}
type ExtractProgress = {
extracted_pages: number
total_pages: number
start_time: string
}
type ExtractFileResult = {
file_name: string
state: 'done' | 'waiting-file' | 'pending' | 'running' | 'converting' | 'failed'
err_msg: string
full_zip_url?: string
extract_progress?: ExtractProgress
}
type ExtractResultResponse = {
batch_id: string
extract_result: ExtractFileResult[]
}
type QuotaResponse = {
code: number
data: {
user_left_quota: number
total_left_quota: number
}
msg?: string
trace_id?: string
}
export default class MineruPreprocessProvider extends BasePreprocessProvider {
constructor(provider: PreprocessProvider, userId?: string) {
super(provider, userId)
// todo免费期结束后删除
this.provider.apiKey = this.provider.apiKey || import.meta.env.MAIN_VITE_MINERU_API_KEY
}
public async parseFile(
sourceId: string,
file: FileMetadata
): Promise<{ processedFile: FileMetadata; quota: number }> {
try {
Logger.info(`MinerU preprocess processing started: ${file.path}`)
await this.validateFile(file.path)
// 1. 获取上传URL并上传文件
const batchId = await this.uploadFile(file)
Logger.info(`MinerU file upload completed: batch_id=${batchId}`)
// 2. 等待处理完成并获取结果
const extractResult = await this.waitForCompletion(sourceId, batchId, file.origin_name)
Logger.info(`MinerU processing completed for batch: ${batchId}`)
// 3. 下载并解压文件
const { path: outputPath } = await this.downloadAndExtractFile(extractResult.full_zip_url!, file)
// 4. check quota
const quota = await this.checkQuota()
// 5. 创建处理后的文件信息
return {
processedFile: this.createProcessedFileInfo(file, outputPath),
quota
}
} catch (error: any) {
Logger.error(`MinerU preprocess processing failed for ${file.path}: ${error.message}`)
throw new Error(error.message)
}
}
public async checkQuota() {
try {
const quota = await fetch(`${this.provider.apiHost}/api/v4/quota`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.provider.apiKey}`,
token: this.userId ?? ''
}
})
if (!quota.ok) {
throw new Error(`HTTP ${quota.status}: ${quota.statusText}`)
}
const response: QuotaResponse = await quota.json()
return response.data.user_left_quota
} catch (error) {
console.error('Error checking quota:', error)
throw error
}
}
private async validateFile(filePath: string): Promise<void> {
const quota = await this.checkQuota()
const pdfBuffer = await fs.promises.readFile(filePath)
const doc = await this.readPdf(new Uint8Array(pdfBuffer))
// 文件页数小于600页
if (doc.numPages >= 600) {
throw new Error(`PDF page count (${doc.numPages}) exceeds the limit of 600 pages`)
}
// 文件大小小于200MB
if (pdfBuffer.length >= 200 * 1024 * 1024) {
const fileSizeMB = Math.round(pdfBuffer.length / (1024 * 1024))
throw new Error(`PDF file size (${fileSizeMB}MB) exceeds the limit of 200MB`)
}
// 检查配额
if (quota <= 0 || quota - doc.numPages <= 0) {
throw new Error('MinerU解析配额不足请申请企业账户或自行部署剩余额度' + quota)
}
}
private createProcessedFileInfo(file: FileMetadata, outputPath: string): FileMetadata {
// 查找解压后的主要文件
let finalPath = ''
let finalName = file.origin_name.replace('.pdf', '.md')
try {
const files = fs.readdirSync(outputPath)
const mdFile = files.find((f) => f.endsWith('.md'))
if (mdFile) {
const originalMdPath = path.join(outputPath, mdFile)
const newMdPath = path.join(outputPath, finalName)
// 重命名文件为原始文件名
try {
fs.renameSync(originalMdPath, newMdPath)
finalPath = newMdPath
Logger.info(`Renamed markdown file from ${mdFile} to ${finalName}`)
} catch (renameError) {
Logger.warn(`Failed to rename file ${mdFile} to ${finalName}: ${renameError}`)
// 如果重命名失败,使用原文件
finalPath = originalMdPath
finalName = mdFile
}
}
} catch (error) {
Logger.warn(`Failed to read output directory ${outputPath}: ${error}`)
finalPath = path.join(outputPath, `${file.id}.md`)
}
return {
...file,
name: finalName,
path: finalPath,
ext: '.md',
size: fs.existsSync(finalPath) ? fs.statSync(finalPath).size : 0
}
}
private async downloadAndExtractFile(zipUrl: string, file: FileMetadata): Promise<{ path: string }> {
const dirPath = this.storageDir
const zipPath = path.join(dirPath, `${file.id}.zip`)
const extractPath = path.join(dirPath, `${file.id}`)
Logger.info(`Downloading MinerU result to: ${zipPath}`)
try {
// 下载ZIP文件
const response = await axios.get(zipUrl, { responseType: 'arraybuffer' })
fs.writeFileSync(zipPath, response.data)
Logger.info(`Downloaded ZIP file: ${zipPath}`)
// 确保提取目录存在
if (!fs.existsSync(extractPath)) {
fs.mkdirSync(extractPath, { recursive: true })
}
// 解压文件
const zip = new AdmZip(zipPath)
zip.extractAllTo(extractPath, true)
Logger.info(`Extracted files to: ${extractPath}`)
// 删除临时ZIP文件
fs.unlinkSync(zipPath)
return { path: extractPath }
} catch (error: any) {
Logger.error(`Failed to download and extract file: ${error.message}`)
throw new Error(error.message)
}
}
private async uploadFile(file: FileMetadata): Promise<string> {
try {
// 步骤1: 获取上传URL
const { batchId, fileUrls } = await this.getBatchUploadUrls(file)
Logger.info(`Got upload URLs for batch: ${batchId}`)
console.log('batchId:', batchId, 'fileurls:', fileUrls)
// 步骤2: 上传文件到获取的URL
await this.putFileToUrl(file.path, fileUrls[0])
Logger.info(`File uploaded successfully: ${file.path}`)
return batchId
} catch (error: any) {
Logger.error(`Failed to upload file ${file.path}: ${error.message}`)
throw new Error(error.message)
}
}
private async getBatchUploadUrls(file: FileMetadata): Promise<{ batchId: string; fileUrls: string[] }> {
const endpoint = `${this.provider.apiHost}/api/v4/file-urls/batch`
const payload = {
language: 'auto',
enable_formula: true,
enable_table: true,
files: [
{
name: file.origin_name,
is_ocr: true,
data_id: file.id
}
]
}
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.provider.apiKey}`,
token: this.userId ?? '',
Accept: '*/*'
},
body: JSON.stringify(payload)
})
if (response.ok) {
const data: ApiResponse<BatchUploadResponse> = await response.json()
if (data.code === 0 && data.data) {
const { batch_id, file_urls } = data.data
return {
batchId: batch_id,
fileUrls: file_urls
}
} else {
throw new Error(`API returned error: ${data.msg || JSON.stringify(data)}`)
}
} else {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
} catch (error: any) {
Logger.error(`Failed to get batch upload URLs: ${error.message}`)
throw new Error(error.message)
}
}
private async putFileToUrl(filePath: string, uploadUrl: string): Promise<void> {
try {
const fileBuffer = await fs.promises.readFile(filePath)
const response = await fetch(uploadUrl, {
method: 'PUT',
body: fileBuffer,
headers: {
'Content-Type': 'application/pdf'
}
// headers: {
// 'Content-Length': fileBuffer.length.toString()
// }
})
if (!response.ok) {
// 克隆 response 以避免消费 body stream
const responseClone = response.clone()
try {
const responseBody = await responseClone.text()
const errorInfo = {
status: response.status,
statusText: response.statusText,
url: response.url,
type: response.type,
redirected: response.redirected,
headers: Object.fromEntries(response.headers.entries()),
body: responseBody
}
console.error('Response details:', errorInfo)
throw new Error(`Upload failed with status ${response.status}: ${responseBody}`)
} catch (parseError) {
throw new Error(`Upload failed with status ${response.status}. Could not parse response body.`)
}
}
Logger.info(`File uploaded successfully to: ${uploadUrl}`)
} catch (error: any) {
Logger.error(`Failed to upload file to URL ${uploadUrl}: ${error}`)
throw new Error(error.message)
}
}
private async getExtractResults(batchId: string): Promise<ExtractResultResponse> {
const endpoint = `${this.provider.apiHost}/api/v4/extract-results/batch/${batchId}`
try {
const response = await fetch(endpoint, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.provider.apiKey}`,
token: this.userId ?? ''
}
})
if (response.ok) {
const data: ApiResponse<ExtractResultResponse> = await response.json()
if (data.code === 0 && data.data) {
return data.data
} else {
throw new Error(`API returned error: ${data.msg || JSON.stringify(data)}`)
}
} else {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
} catch (error: any) {
Logger.error(`Failed to get extract results for batch ${batchId}: ${error.message}`)
throw new Error(error.message)
}
}
private async waitForCompletion(
sourceId: string,
batchId: string,
fileName: string,
maxRetries: number = 60,
intervalMs: number = 5000
): Promise<ExtractFileResult> {
let retries = 0
while (retries < maxRetries) {
try {
const result = await this.getExtractResults(batchId)
// 查找对应文件的处理结果
const fileResult = result.extract_result.find((item) => item.file_name === fileName)
if (!fileResult) {
throw new Error(`File ${fileName} not found in batch results`)
}
// 检查处理状态
if (fileResult.state === 'done' && fileResult.full_zip_url) {
Logger.info(`Processing completed for file: ${fileName}`)
return fileResult
} else if (fileResult.state === 'failed') {
throw new Error(`Processing failed for file: ${fileName}, error: ${fileResult.err_msg}`)
} else if (fileResult.state === 'running') {
// 发送进度更新
if (fileResult.extract_progress) {
const progress = Math.round(
(fileResult.extract_progress.extracted_pages / fileResult.extract_progress.total_pages) * 100
)
await this.sendPreprocessProgress(sourceId, progress)
Logger.info(`File ${fileName} processing progress: ${progress}%`)
} else {
// 如果没有具体进度信息,发送一个通用进度
await this.sendPreprocessProgress(sourceId, 50)
Logger.info(`File ${fileName} is still processing...`)
}
}
} catch (error) {
Logger.warn(`Failed to check status for batch ${batchId}, retry ${retries + 1}/${maxRetries}`)
if (retries === maxRetries - 1) {
throw error
}
}
retries++
await new Promise((resolve) => setTimeout(resolve, intervalMs))
}
throw new Error(`Processing timeout for batch: ${batchId}`)
}
}

View File

@@ -1,187 +0,0 @@
import fs from 'node:fs'
import { MistralClientManager } from '@main/services/MistralClientManager'
import { MistralService } from '@main/services/remotefile/MistralService'
import { Mistral } from '@mistralai/mistralai'
import { DocumentURLChunk } from '@mistralai/mistralai/models/components/documenturlchunk'
import { ImageURLChunk } from '@mistralai/mistralai/models/components/imageurlchunk'
import { OCRResponse } from '@mistralai/mistralai/models/components/ocrresponse'
import { FileMetadata, FileTypes, PreprocessProvider, Provider } from '@types'
import Logger from 'electron-log'
import path from 'path'
import BasePreprocessProvider from './BasePreprocessProvider'
type PreuploadResponse = DocumentURLChunk | ImageURLChunk
export default class MistralPreprocessProvider extends BasePreprocessProvider {
private sdk: Mistral
private fileService: MistralService
constructor(provider: PreprocessProvider) {
super(provider)
const clientManager = MistralClientManager.getInstance()
const aiProvider: Provider = {
id: provider.id,
type: 'mistral',
name: provider.name,
apiKey: provider.apiKey!,
apiHost: provider.apiHost!,
models: []
}
clientManager.initializeClient(aiProvider)
this.sdk = clientManager.getClient()
this.fileService = new MistralService(aiProvider)
}
private async preupload(file: FileMetadata): Promise<PreuploadResponse> {
let document: PreuploadResponse
Logger.info(`preprocess preupload started for local file: ${file.path}`)
if (file.ext.toLowerCase() === '.pdf') {
const uploadResponse = await this.fileService.uploadFile(file)
if (uploadResponse.status === 'failed') {
Logger.error('File upload failed:', uploadResponse)
throw new Error('Failed to upload file: ' + uploadResponse.displayName)
}
await this.sendPreprocessProgress(file.id, 15)
const fileUrl = await this.sdk.files.getSignedUrl({
fileId: uploadResponse.fileId
})
Logger.info('Got signed URL:', fileUrl)
await this.sendPreprocessProgress(file.id, 20)
document = {
type: 'document_url',
documentUrl: fileUrl.url
}
} else {
const base64Image = Buffer.from(fs.readFileSync(file.path)).toString('base64')
document = {
type: 'image_url',
imageUrl: `data:image/png;base64,${base64Image}`
}
}
if (!document) {
throw new Error('Unsupported file type')
}
return document
}
public async parseFile(sourceId: string, file: FileMetadata): Promise<{ processedFile: FileMetadata }> {
try {
const document = await this.preupload(file)
const result = await this.sdk.ocr.process({
model: this.provider.model!,
document: document,
includeImageBase64: true
})
if (result) {
await this.sendPreprocessProgress(sourceId, 100)
const processedFile = this.convertFile(result, file)
return {
processedFile
}
} else {
throw new Error('preprocess processing failed: OCR response is empty')
}
} catch (error) {
throw new Error('preprocess processing failed: ' + error)
}
}
private convertFile(result: OCRResponse, file: FileMetadata): FileMetadata {
// 使用统一的存储路径Data/Files/{file.id}/
const conversionId = file.id
const outputPath = path.join(this.storageDir, file.id)
// const outputPath = this.storageDir
const outputFileName = path.basename(file.path, path.extname(file.path))
fs.mkdirSync(outputPath, { recursive: true })
const markdownParts: string[] = []
let counter = 0
// Process each page
result.pages.forEach((page) => {
let pageMarkdown = page.markdown
// Process images from this page
page.images.forEach((image) => {
if (image.imageBase64) {
let imageFormat = 'jpeg' // default format
let imageBase64Data = image.imageBase64
// Check for data URL prefix more efficiently
const prefixEnd = image.imageBase64.indexOf(';base64,')
if (prefixEnd > 0) {
const prefix = image.imageBase64.substring(0, prefixEnd)
const formatIndex = prefix.indexOf('image/')
if (formatIndex >= 0) {
imageFormat = prefix.substring(formatIndex + 6)
}
imageBase64Data = image.imageBase64.substring(prefixEnd + 8)
}
const imageFileName = `img-${counter}.${imageFormat}`
const imagePath = path.join(outputPath, imageFileName)
// Save image file
try {
fs.writeFileSync(imagePath, Buffer.from(imageBase64Data, 'base64'))
// Update image reference in markdown
// Use relative path for better portability
const relativeImagePath = `./${imageFileName}`
// Find the start and end of the image markdown
const imgStart = pageMarkdown.indexOf(image.imageBase64)
if (imgStart >= 0) {
// Find the markdown image syntax around this base64
const mdStart = pageMarkdown.lastIndexOf('![', imgStart)
const mdEnd = pageMarkdown.indexOf(')', imgStart)
if (mdStart >= 0 && mdEnd >= 0) {
// Replace just this specific image reference
pageMarkdown =
pageMarkdown.substring(0, mdStart) +
`![Image ${counter}](${relativeImagePath})` +
pageMarkdown.substring(mdEnd + 1)
}
}
counter++
} catch (error) {
Logger.error(`Failed to save image ${imageFileName}:`, error)
}
}
})
markdownParts.push(pageMarkdown)
})
// Combine all markdown content with double newlines for readability
const combinedMarkdown = markdownParts.join('\n\n')
// Write the markdown content to a file
const mdFileName = `${outputFileName}.md`
const mdFilePath = path.join(outputPath, mdFileName)
fs.writeFileSync(mdFilePath, combinedMarkdown)
return {
id: conversionId,
name: file.name.replace(/\.[^/.]+$/, '.md'),
origin_name: file.origin_name,
path: mdFilePath,
created_at: new Date().toISOString(),
type: FileTypes.DOCUMENT,
ext: '.md',
size: fs.statSync(mdFilePath).size,
count: 1
} as FileMetadata
}
public checkQuota(): Promise<number> {
throw new Error('Method not implemented.')
}
}

View File

@@ -1,30 +0,0 @@
import { FileMetadata, PreprocessProvider as Provider } from '@types'
import BasePreprocessProvider from './BasePreprocessProvider'
import PreprocessProviderFactory from './PreprocessProviderFactory'
export default class PreprocessProvider {
private sdk: BasePreprocessProvider
constructor(provider: Provider, userId?: string) {
this.sdk = PreprocessProviderFactory.create(provider, userId)
}
public async parseFile(
sourceId: string,
file: FileMetadata
): Promise<{ processedFile: FileMetadata; quota?: number }> {
return this.sdk.parseFile(sourceId, file)
}
public async checkQuota(): Promise<number> {
return this.sdk.checkQuota()
}
/**
* 检查文件是否已经被预处理过
* @param file 文件信息
* @returns 如果已处理返回处理后的文件信息否则返回null
*/
public async checkIfAlreadyProcessed(file: FileMetadata): Promise<FileMetadata | null> {
return this.sdk.checkIfAlreadyProcessed(file)
}
}

View File

@@ -1,21 +0,0 @@
import { PreprocessProvider } from '@types'
import BasePreprocessProvider from './BasePreprocessProvider'
import DefaultPreprocessProvider from './DefaultPreprocessProvider'
import Doc2xPreprocessProvider from './Doc2xPreprocessProvider'
import MineruPreprocessProvider from './MineruPreprocessProvider'
import MistralPreprocessProvider from './MistralPreprocessProvider'
export default class PreprocessProviderFactory {
static create(provider: PreprocessProvider, userId?: string): BasePreprocessProvider {
switch (provider.id) {
case 'doc2x':
return new Doc2xPreprocessProvider(provider)
case 'mistral':
return new MistralPreprocessProvider(provider)
case 'mineru':
return new MineruPreprocessProvider(provider, userId)
default:
return new DefaultPreprocessProvider(provider)
}
}
}

View File

@@ -17,17 +17,14 @@ export default abstract class BaseReranker {
* Get Rerank Request Url
*/
protected getRerankUrl() {
if (this.base.rerankModelProvider === 'bailian') {
if (this.base.rerankModelProvider === 'dashscope') {
return 'https://dashscope.aliyuncs.com/api/v1/services/rerank/text-rerank/text-rerank'
}
let baseURL = this.base.rerankBaseURL
if (baseURL && baseURL.endsWith('/')) {
// `/` 结尾强制使用rerankBaseURL
return `${baseURL}rerank`
}
let baseURL = this.base?.rerankBaseURL?.endsWith('/')
? this.base.rerankBaseURL.slice(0, -1)
: this.base.rerankBaseURL
// 必须携带/v1否则会404
if (baseURL && !baseURL.endsWith('/v1')) {
baseURL = `${baseURL}/v1`
}
@@ -50,7 +47,7 @@ export default abstract class BaseReranker {
documents,
top_k: topN
}
} else if (provider === 'bailian') {
} else if (provider === 'dashscope') {
return {
model: this.base.rerankModel,
input: {
@@ -61,12 +58,6 @@ export default abstract class BaseReranker {
top_n: topN
}
}
} else if (provider?.includes('tei')) {
return {
query,
texts: documents,
return_text: true
}
} else {
return {
model: this.base.rerankModel,
@@ -82,17 +73,10 @@ export default abstract class BaseReranker {
*/
protected extractRerankResult(data: any) {
const provider = this.base.rerankModelProvider
if (provider === 'bailian') {
if (provider === 'dashscope') {
return data.output.results
} else if (provider === 'voyageai') {
return data.data
} else if (provider?.includes('tei')) {
return data.map((item: any) => {
return {
index: item.index,
relevance_score: item.score
}
})
} else {
return data.results
}

View File

@@ -1,13 +1,10 @@
import { isWin } from '@main/constant'
import { locales } from '@main/utils/locales'
import { generateUserAgent } from '@main/utils/systemInfo'
import { FeedUrl, UpgradeChannel } from '@shared/config/constant'
import { IpcChannel } from '@shared/IpcChannel'
import { CancellationToken, UpdateInfo } from 'builder-util-runtime'
import { UpdateInfo } from 'builder-util-runtime'
import { app, BrowserWindow, dialog } from 'electron'
import logger from 'electron-log'
import { AppUpdater as _AppUpdater, autoUpdater, NsisUpdater, UpdateCheckResult } from 'electron-updater'
import path from 'path'
import { AppUpdater as _AppUpdater, autoUpdater } from 'electron-updater'
import icon from '../../../build/icon.png?asset'
import { configManager } from './ConfigManager'
@@ -15,8 +12,6 @@ import { configManager } from './ConfigManager'
export default class AppUpdater {
autoUpdater: _AppUpdater = autoUpdater
private releaseInfo: UpdateInfo | undefined
private cancellationToken: CancellationToken = new CancellationToken()
private updateCheckResult: UpdateCheckResult | null = null
constructor(mainWindow: BrowserWindow) {
logger.transports.file.level = 'info'
@@ -25,11 +20,8 @@ export default class AppUpdater {
autoUpdater.forceDevUpdateConfig = !app.isPackaged
autoUpdater.autoDownload = configManager.getAutoUpdate()
autoUpdater.autoInstallOnAppQuit = configManager.getAutoUpdate()
autoUpdater.requestHeaders = {
...autoUpdater.requestHeaders,
'User-Agent': generateUserAgent()
}
// 检测下载错误
autoUpdater.on('error', (error) => {
// 简单记录错误信息和时间戳
logger.error('更新异常', {
@@ -62,139 +54,14 @@ export default class AppUpdater {
logger.info('下载完成', releaseInfo)
})
if (isWin) {
;(autoUpdater as NsisUpdater).installDirectory = path.dirname(app.getPath('exe'))
}
this.autoUpdater = autoUpdater
}
private async _getPreReleaseVersionFromGithub(channel: UpgradeChannel) {
try {
logger.info('get pre release version from github', channel)
const responses = await fetch('https://api.github.com/repos/CherryHQ/cherry-studio/releases?per_page=8', {
headers: {
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'Accept-Language': 'en-US,en;q=0.9'
}
})
const data = (await responses.json()) as GithubReleaseInfo[]
const release: GithubReleaseInfo | undefined = data.find((item: GithubReleaseInfo) => {
return item.prerelease && item.tag_name.includes(`-${channel}.`)
})
logger.info('release info', release)
if (!release) {
return null
}
logger.info('release info', release.tag_name)
return `https://github.com/CherryHQ/cherry-studio/releases/download/${release.tag_name}`
} catch (error) {
logger.error('Failed to get latest not draft version from github:', error)
return null
}
}
private async _getIpCountry() {
try {
// add timeout using AbortController
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 5000)
const ipinfo = await fetch('https://ipinfo.io/json', {
signal: controller.signal,
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
'Accept-Language': 'en-US,en;q=0.9'
}
})
clearTimeout(timeoutId)
const data = await ipinfo.json()
return data.country || 'CN'
} catch (error) {
logger.error('Failed to get ipinfo:', error)
return 'CN'
}
}
public setAutoUpdate(isActive: boolean) {
autoUpdater.autoDownload = isActive
autoUpdater.autoInstallOnAppQuit = isActive
}
private _getChannelByVersion(version: string) {
if (version.includes(`-${UpgradeChannel.BETA}.`)) {
return UpgradeChannel.BETA
}
if (version.includes(`-${UpgradeChannel.RC}.`)) {
return UpgradeChannel.RC
}
return UpgradeChannel.LATEST
}
private _getTestChannel() {
const currentChannel = this._getChannelByVersion(app.getVersion())
const savedChannel = configManager.getTestChannel()
if (currentChannel === UpgradeChannel.LATEST) {
return savedChannel || UpgradeChannel.RC
}
if (savedChannel === currentChannel) {
return savedChannel
}
// if the upgrade channel is not equal to the current channel, use the latest channel
return UpgradeChannel.LATEST
}
private async _setFeedUrl() {
const testPlan = configManager.getTestPlan()
if (testPlan) {
const channel = this._getTestChannel()
if (channel === UpgradeChannel.LATEST) {
this.autoUpdater.channel = UpgradeChannel.LATEST
this.autoUpdater.setFeedURL(FeedUrl.GITHUB_LATEST)
return
}
const preReleaseUrl = await this._getPreReleaseVersionFromGithub(channel)
if (preReleaseUrl) {
this.autoUpdater.setFeedURL(preReleaseUrl)
this.autoUpdater.channel = channel
return
}
// if no prerelease url, use lowest prerelease version to avoid error
this.autoUpdater.setFeedURL(FeedUrl.PRERELEASE_LOWEST)
this.autoUpdater.channel = UpgradeChannel.LATEST
return
}
this.autoUpdater.channel = UpgradeChannel.LATEST
this.autoUpdater.setFeedURL(FeedUrl.PRODUCTION)
const ipCountry = await this._getIpCountry()
logger.info('ipCountry', ipCountry)
if (ipCountry.toLowerCase() !== 'cn') {
this.autoUpdater.setFeedURL(FeedUrl.GITHUB_LATEST)
}
}
public cancelDownload() {
this.cancellationToken.cancel()
this.cancellationToken = new CancellationToken()
if (this.autoUpdater.autoDownload) {
this.updateCheckResult?.cancellationToken?.cancel()
}
}
public async checkForUpdates() {
if (isWin && 'PORTABLE_EXECUTABLE_DIR' in process.env) {
return {
@@ -203,26 +70,17 @@ export default class AppUpdater {
}
}
await this._setFeedUrl()
// disable downgrade after change the channel
this.autoUpdater.allowDowngrade = false
// github and gitcode don't support multiple range download
this.autoUpdater.disableDifferentialDownload = true
try {
this.updateCheckResult = await this.autoUpdater.checkForUpdates()
if (this.updateCheckResult?.isUpdateAvailable && !this.autoUpdater.autoDownload) {
const update = await this.autoUpdater.checkForUpdates()
if (update?.isUpdateAvailable && !this.autoUpdater.autoDownload) {
// 如果 autoDownload 为 false则需要再调用下面的函数触发下
// do not use await, because it will block the return of this function
logger.info('downloadUpdate manual by check for updates', this.cancellationToken)
this.autoUpdater.downloadUpdate(this.cancellationToken)
this.autoUpdater.downloadUpdate()
}
return {
currentVersion: this.autoUpdater.currentVersion,
updateInfo: this.updateCheckResult?.updateInfo
updateInfo: update?.updateInfo
}
} catch (error) {
logger.error('Failed to check for update:', error)
@@ -278,11 +136,7 @@ export default class AppUpdater {
return releaseNotes.map((note) => note.note).join('\n')
}
}
interface GithubReleaseInfo {
draft: boolean
prerelease: boolean
tag_name: string
}
interface ReleaseNoteInfo {
readonly version: string
readonly note: string | null

View File

@@ -9,7 +9,6 @@ import StreamZip from 'node-stream-zip'
import * as path from 'path'
import { CreateDirectoryOptions, FileStat } from 'webdav'
import { getDataPath } from '../utils'
import WebDav from './WebDav'
import { windowService } from './WindowService'
@@ -254,7 +253,7 @@ class BackupManager {
Logger.log('[backup] step 3: restore Data directory')
// 恢复 Data 目录
const sourcePath = path.join(this.tempDir, 'Data')
const destPath = getDataPath()
const destPath = path.join(app.getPath('userData'), 'Data')
const dataExists = await fs.pathExists(sourcePath)
const dataFiles = dataExists ? await fs.readdir(sourcePath) : []
@@ -296,12 +295,10 @@ class BackupManager {
async backupToWebdav(_: Electron.IpcMainInvokeEvent, data: string, webdavConfig: WebDavConfig) {
const filename = webdavConfig.fileName || 'cherry-studio.backup.zip'
const backupedFilePath = await this.backup(_, filename, data, undefined, webdavConfig.skipBackupFile)
const contentLength = (await fs.stat(backupedFilePath)).size
const webdavClient = new WebDav(webdavConfig)
try {
const result = await webdavClient.putFileContents(filename, fs.createReadStream(backupedFilePath), {
overwrite: true,
contentLength
overwrite: true
})
// 上传成功后删除本地备份文件
await fs.remove(backupedFilePath)

View File

@@ -1,4 +1,4 @@
import { defaultLanguage, UpgradeChannel, ZOOM_SHORTCUTS } from '@shared/config/constant'
import { defaultLanguage, ZOOM_SHORTCUTS } from '@shared/config/constant'
import { LanguageVarious, Shortcut, ThemeMode } from '@types'
import { app } from 'electron'
import Store from 'electron-store'
@@ -16,8 +16,6 @@ export enum ConfigKeys {
ClickTrayToShowQuickAssistant = 'clickTrayToShowQuickAssistant',
EnableQuickAssistant = 'enableQuickAssistant',
AutoUpdate = 'autoUpdate',
TestPlan = 'testPlan',
TestChannel = 'testChannel',
EnableDataCollection = 'enableDataCollection',
SelectionAssistantEnabled = 'selectionAssistantEnabled',
SelectionAssistantTriggerMode = 'selectionAssistantTriggerMode',
@@ -143,22 +141,6 @@ export class ConfigManager {
this.set(ConfigKeys.AutoUpdate, value)
}
getTestPlan(): boolean {
return this.get<boolean>(ConfigKeys.TestPlan, false)
}
setTestPlan(value: boolean) {
this.set(ConfigKeys.TestPlan, value)
}
getTestChannel(): UpgradeChannel {
return this.get<UpgradeChannel>(ConfigKeys.TestChannel)
}
setTestChannel(value: UpgradeChannel) {
this.set(ConfigKeys.TestChannel, value)
}
getEnableDataCollection(): boolean {
return this.get<boolean>(ConfigKeys.EnableDataCollection, true)
}
@@ -169,7 +151,7 @@ export class ConfigManager {
// Selection Assistant: is enabled the selection assistant
getSelectionAssistantEnabled(): boolean {
return this.get<boolean>(ConfigKeys.SelectionAssistantEnabled, false)
return this.get<boolean>(ConfigKeys.SelectionAssistantEnabled, true)
}
setSelectionAssistantEnabled(value: boolean) {

View File

@@ -4,29 +4,18 @@ import { locales } from '../utils/locales'
import { configManager } from './ConfigManager'
class ContextMenu {
public contextMenu(w: Electron.WebContents) {
w.on('context-menu', (_event, properties) => {
public contextMenu(w: Electron.BrowserWindow) {
w.webContents.on('context-menu', (_event, properties) => {
const template: MenuItemConstructorOptions[] = this.createEditMenuItems(properties)
const filtered = template.filter((item) => item.visible !== false)
if (filtered.length > 0) {
let template = [...filtered, ...this.createInspectMenuItems(w)]
const dictionarySuggestions = this.createDictionarySuggestions(properties, w)
if (dictionarySuggestions.length > 0) {
template = [
...dictionarySuggestions,
{ type: 'separator' },
this.createSpellCheckMenuItem(properties, w),
{ type: 'separator' },
...template
]
}
const menu = Menu.buildFromTemplate(template)
const menu = Menu.buildFromTemplate([...filtered, ...this.createInspectMenuItems(w)])
menu.popup()
}
})
}
private createInspectMenuItems(w: Electron.WebContents): MenuItemConstructorOptions[] {
private createInspectMenuItems(w: Electron.BrowserWindow): MenuItemConstructorOptions[] {
const locale = locales[configManager.getLanguage()]
const { common } = locale.translation
const template: MenuItemConstructorOptions[] = [
@@ -34,7 +23,7 @@ class ContextMenu {
id: 'inspect',
label: common.inspect,
click: () => {
w.toggleDevTools()
w.webContents.toggleDevTools()
},
enabled: true
}
@@ -83,53 +72,6 @@ class ContextMenu {
return template
}
private createSpellCheckMenuItem(
properties: Electron.ContextMenuParams,
w: Electron.WebContents
): MenuItemConstructorOptions {
const hasText = properties.selectionText.length > 0
return {
id: 'learnSpelling',
label: '&Learn Spelling',
visible: Boolean(properties.isEditable && hasText && properties.misspelledWord),
click: () => {
w.session.addWordToSpellCheckerDictionary(properties.misspelledWord)
}
}
}
private createDictionarySuggestions(
properties: Electron.ContextMenuParams,
w: Electron.WebContents
): MenuItemConstructorOptions[] {
const hasText = properties.selectionText.length > 0
if (!hasText || !properties.misspelledWord) {
return []
}
if (properties.dictionarySuggestions.length === 0) {
return [
{
id: 'dictionarySuggestions',
label: 'No Guesses Found',
visible: true,
enabled: false
}
]
}
return properties.dictionarySuggestions.map((suggestion) => ({
id: 'dictionarySuggestions',
label: suggestion,
visible: Boolean(properties.isEditable && hasText && properties.misspelledWord),
click: (menuItem: Electron.MenuItem) => {
w.replaceMisspelling(menuItem.label)
}
}))
}
}
export const contextMenu = new ContextMenu()

View File

@@ -0,0 +1,7 @@
import fs from 'node:fs'
export default class FileService {
public static async readFile(_: Electron.IpcMainInvokeEvent, path: string) {
return fs.readFileSync(path, 'utf8')
}
}

View File

@@ -1,6 +1,6 @@
import { getFilesDir, getFileType, getTempDir } from '@main/utils/file'
import { documentExts, imageExts, MB } from '@shared/config/constant'
import { FileMetadata } from '@types'
import { FileType } from '@types'
import * as crypto from 'crypto'
import {
dialog,
@@ -15,11 +15,9 @@ import * as fs from 'fs'
import { writeFileSync } from 'fs'
import { readFile } from 'fs/promises'
import officeParser from 'officeparser'
import { getDocument } from 'officeparser/pdfjs-dist-build/pdf.js'
import * as path from 'path'
import { chdir } from 'process'
import { v4 as uuidv4 } from 'uuid'
import WordExtractor from 'word-extractor'
class FileStorage {
private storageDir = getFilesDir()
@@ -53,9 +51,8 @@ class FileStorage {
})
}
findDuplicateFile = async (filePath: string): Promise<FileMetadata | null> => {
findDuplicateFile = async (filePath: string): Promise<FileType | null> => {
const stats = fs.statSync(filePath)
console.log('stats', stats, filePath)
const fileSize = stats.size
const files = await fs.promises.readdir(this.storageDir)
@@ -93,7 +90,7 @@ class FileStorage {
public selectFile = async (
_: Electron.IpcMainInvokeEvent,
options?: OpenDialogOptions
): Promise<FileMetadata[] | null> => {
): Promise<FileType[] | null> => {
const defaultOptions: OpenDialogOptions = {
properties: ['openFile']
}
@@ -152,7 +149,7 @@ class FileStorage {
}
}
public uploadFile = async (_: Electron.IpcMainInvokeEvent, file: FileMetadata): Promise<FileMetadata> => {
public uploadFile = async (_: Electron.IpcMainInvokeEvent, file: FileType): Promise<FileType> => {
const duplicateFile = await this.findDuplicateFile(file.path)
if (duplicateFile) {
@@ -176,7 +173,7 @@ class FileStorage {
const stats = await fs.promises.stat(destPath)
const fileType = getFileType(ext)
const fileMetadata: FileMetadata = {
const fileMetadata: FileType = {
id: uuid,
origin_name,
name: uuid + ext,
@@ -191,7 +188,7 @@ class FileStorage {
return fileMetadata
}
public getFile = async (_: Electron.IpcMainInvokeEvent, filePath: string): Promise<FileMetadata | null> => {
public getFile = async (_: Electron.IpcMainInvokeEvent, filePath: string): Promise<FileType | null> => {
if (!fs.existsSync(filePath)) {
return null
}
@@ -200,7 +197,7 @@ class FileStorage {
const ext = path.extname(filePath)
const fileType = getFileType(ext)
const fileInfo: FileMetadata = {
const fileInfo: FileType = {
id: uuidv4(),
origin_name: path.basename(filePath),
name: path.basename(filePath),
@@ -216,36 +213,16 @@ class FileStorage {
}
public deleteFile = async (_: Electron.IpcMainInvokeEvent, id: string): Promise<void> => {
if (!fs.existsSync(path.join(this.storageDir, id))) {
return
}
await fs.promises.unlink(path.join(this.storageDir, id))
}
public deleteDir = async (_: Electron.IpcMainInvokeEvent, id: string): Promise<void> => {
if (!fs.existsSync(path.join(this.storageDir, id))) {
return
}
await fs.promises.rm(path.join(this.storageDir, id), { recursive: true })
}
public readFile = async (_: Electron.IpcMainInvokeEvent, id: string): Promise<string> => {
const filePath = path.join(this.storageDir, id)
const fileExtension = path.extname(filePath)
if (documentExts.includes(fileExtension)) {
if (documentExts.includes(path.extname(filePath))) {
const originalCwd = process.cwd()
try {
chdir(this.tempDir)
if (fileExtension === '.doc') {
const extractor = new WordExtractor()
const extracted = await extractor.extract(filePath)
chdir(originalCwd)
return extracted.getBody()
}
const data = await officeParser.parseOfficeAsync(filePath)
chdir(originalCwd)
return data
@@ -263,8 +240,8 @@ class FileStorage {
if (!fs.existsSync(this.tempDir)) {
fs.mkdirSync(this.tempDir, { recursive: true })
}
return path.join(this.tempDir, `temp_file_${uuidv4()}_${fileName}`)
const tempFilePath = path.join(this.tempDir, `temp_file_${uuidv4()}_${fileName}`)
return tempFilePath
}
public writeFile = async (
@@ -291,7 +268,7 @@ class FileStorage {
}
}
public saveBase64Image = async (_: Electron.IpcMainInvokeEvent, base64Data: string): Promise<FileMetadata> => {
public saveBase64Image = async (_: Electron.IpcMainInvokeEvent, base64Data: string): Promise<FileType> => {
try {
if (!base64Data) {
throw new Error('Base64 data is required')
@@ -317,7 +294,7 @@ class FileStorage {
await fs.promises.writeFile(destPath, buffer)
const fileMetadata: FileMetadata = {
const fileMetadata: FileType = {
id: uuid,
origin_name: uuid + ext,
name: uuid + ext,
@@ -344,16 +321,6 @@ class FileStorage {
return { data: base64, mime }
}
public pdfPageCount = async (_: Electron.IpcMainInvokeEvent, id: string): Promise<number> => {
const filePath = path.join(this.storageDir, id)
const buffer = await fs.promises.readFile(filePath)
const doc = await getDocument({ data: buffer }).promise
const pages = doc.numPages
await doc.destroy()
return pages
}
public binaryImage = async (_: Electron.IpcMainInvokeEvent, id: string): Promise<{ data: Buffer; mime: string }> => {
const filePath = path.join(this.storageDir, id)
const data = await fs.promises.readFile(filePath)
@@ -374,7 +341,7 @@ class FileStorage {
public open = async (
_: Electron.IpcMainInvokeEvent,
options: OpenDialogOptions
): Promise<{ fileName: string; filePath: string; content?: Buffer; size: number } | null> => {
): Promise<{ fileName: string; filePath: string; content: Buffer } | null> => {
try {
const result: OpenDialogReturnValue = await dialog.showOpenDialog({
title: '打开文件',
@@ -386,16 +353,8 @@ class FileStorage {
if (!result.canceled && result.filePaths.length > 0) {
const filePath = result.filePaths[0]
const fileName = filePath.split('/').pop() || ''
const stats = await fs.promises.stat(filePath)
// If the file is less than 2GB, read the content
if (stats.size < 2 * 1024 * 1024 * 1024) {
const content = await readFile(filePath)
return { fileName, filePath, content, size: stats.size }
}
// For large files, only return file information, do not read content
return { fileName, filePath, size: stats.size }
const content = await readFile(filePath)
return { fileName, filePath, content }
}
return null
@@ -414,7 +373,7 @@ class FileStorage {
fileName: string,
content: string,
options?: SaveDialogOptions
): Promise<string> => {
): Promise<string | null | undefined> => {
try {
const result: SaveDialogReturnValue = await dialog.showSaveDialog({
title: '保存文件',
@@ -476,7 +435,7 @@ class FileStorage {
_: Electron.IpcMainInvokeEvent,
url: string,
isUseContentType?: boolean
): Promise<FileMetadata> => {
): Promise<FileType> => {
try {
const response = await fetch(url)
if (!response.ok) {
@@ -518,7 +477,7 @@ class FileStorage {
const stats = await fs.promises.stat(destPath)
const fileType = getFileType(ext)
const fileMetadata: FileMetadata = {
const fileMetadata: FileType = {
id: uuid,
origin_name: filename,
name: uuid + ext,

View File

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

View File

@@ -16,24 +16,21 @@
import * as fs from 'node:fs'
import path from 'node:path'
import { RAGApplication, RAGApplicationBuilder } from '@cherrystudio/embedjs'
import { RAGApplication, RAGApplicationBuilder, TextLoader } from '@cherrystudio/embedjs'
import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
import { LibSqlDb } from '@cherrystudio/embedjs-libsql'
import { SitemapLoader } from '@cherrystudio/embedjs-loader-sitemap'
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 Reranker from '@main/knowledage/reranker/Reranker'
import OcrProvider from '@main/ocr/OcrProvider'
import PreprocessProvider from '@main/preprocess/PreprocessProvider'
import Embeddings from '@main/embeddings/Embeddings'
import { addFileLoader } from '@main/loader'
import Reranker from '@main/reranker/Reranker'
import { windowService } from '@main/services/WindowService'
import { getDataPath } from '@main/utils'
import { getAllFiles } from '@main/utils/file'
import { MB } from '@shared/config/constant'
import type { LoaderReturn } from '@shared/config/types'
import { IpcChannel } from '@shared/IpcChannel'
import { FileMetadata, KnowledgeBaseParams, KnowledgeItem } from '@types'
import { FileType, KnowledgeBaseParams, KnowledgeItem } from '@types'
import { app } from 'electron'
import Logger from 'electron-log'
import { v4 as uuidv4 } from 'uuid'
@@ -41,14 +38,12 @@ export interface KnowledgeBaseAddItemOptions {
base: KnowledgeBaseParams
item: KnowledgeItem
forceReload?: boolean
userId?: string
}
interface KnowledgeBaseAddItemOptionsNonNullableAttribute {
base: KnowledgeBaseParams
item: KnowledgeItem
forceReload: boolean
userId: string
}
interface EvaluateTaskWorkload {
@@ -93,20 +88,14 @@ const loaderTaskIntoOfSet = (loaderTask: LoaderTask): LoaderTaskOfSet => {
}
class KnowledgeService {
private storageDir = path.join(getDataPath(), 'KnowledgeBase')
private storageDir = path.join(app.getPath('userData'), 'Data', 'KnowledgeBase')
// Byte based
private workload = 0
private processingItemCount = 0
private knowledgeItemProcessingQueueMappingPromise: Map<LoaderTaskOfSet, () => void> = new Map()
private static MAXIMUM_WORKLOAD = 80 * MB
private static MAXIMUM_PROCESSING_ITEM_COUNT = 30
private static ERROR_LOADER_RETURN: LoaderReturn = {
entriesAdded: 0,
uniqueId: '',
uniqueIds: [''],
loaderType: '',
status: 'failed'
}
private static ERROR_LOADER_RETURN: LoaderReturn = { entriesAdded: 0, uniqueId: '', uniqueIds: [''], loaderType: '' }
constructor() {
this.initStorageDir()
@@ -121,21 +110,13 @@ class KnowledgeService {
private getRagApplication = async ({
id,
model,
provider,
apiKey,
apiVersion,
baseURL,
dimensions
}: KnowledgeBaseParams): Promise<RAGApplication> => {
let ragApplication: RAGApplication
const embeddings = new Embeddings({
model,
provider,
apiKey,
apiVersion,
baseURL,
dimensions
} as KnowledgeBaseParams)
const embeddings = new Embeddings({ model, apiKey, apiVersion, baseURL, dimensions } as KnowledgeBaseParams)
try {
ragApplication = await new RAGApplicationBuilder()
.setModel('NO_MODEL')
@@ -154,13 +135,12 @@ class KnowledgeService {
this.getRagApplication(base)
}
public reset = async (_: Electron.IpcMainInvokeEvent, base: KnowledgeBaseParams): Promise<void> => {
public reset = async (_: Electron.IpcMainInvokeEvent, { base }: { base: KnowledgeBaseParams }): Promise<void> => {
const ragApplication = await this.getRagApplication(base)
await ragApplication.reset()
}
public delete = async (_: Electron.IpcMainInvokeEvent, id: string): Promise<void> => {
console.log('id', id)
const dbPath = path.join(this.storageDir, id)
if (fs.existsSync(dbPath)) {
fs.rmSync(dbPath, { recursive: true })
@@ -173,49 +153,28 @@ class KnowledgeService {
this.workload >= KnowledgeService.MAXIMUM_WORKLOAD
)
}
private fileTask(
ragApplication: RAGApplication,
options: KnowledgeBaseAddItemOptionsNonNullableAttribute
): LoaderTask {
const { base, item, forceReload, userId } = options
const file = item.content as FileMetadata
const { base, item, forceReload } = options
const file = item.content as FileType
const loaderTask: LoaderTask = {
loaderTasks: [
{
state: LoaderTaskItemState.PENDING,
task: async () => {
try {
// 添加预处理逻辑
const fileToProcess: FileMetadata = await this.preprocessing(file, base, item, userId)
// 使用处理后的文件进行加载
return addFileLoader(ragApplication, fileToProcess, base, forceReload)
.then((result) => {
loaderTask.loaderDoneReturn = result
return result
})
.catch((e) => {
Logger.error(`Error in addFileLoader for ${file.name}: ${e}`)
const errorResult: LoaderReturn = {
...KnowledgeService.ERROR_LOADER_RETURN,
message: e.message,
messageSource: 'embedding'
}
loaderTask.loaderDoneReturn = errorResult
return errorResult
})
} catch (e: any) {
Logger.error(`Preprocessing failed for ${file.name}: ${e}`)
const errorResult: LoaderReturn = {
...KnowledgeService.ERROR_LOADER_RETURN,
message: e.message,
messageSource: 'preprocess'
}
loaderTask.loaderDoneReturn = errorResult
return errorResult
}
},
task: () =>
addFileLoader(ragApplication, file, base, forceReload)
.then((result) => {
loaderTask.loaderDoneReturn = result
return result
})
.catch((err) => {
Logger.error(err)
return KnowledgeService.ERROR_LOADER_RETURN
}),
evaluateTaskWorkload: { workload: file.size }
}
],
@@ -224,6 +183,7 @@ class KnowledgeService {
return loaderTask
}
private directoryTask(
ragApplication: RAGApplication,
options: KnowledgeBaseAddItemOptionsNonNullableAttribute
@@ -263,11 +223,7 @@ class KnowledgeService {
})
.catch((err) => {
Logger.error(err)
return {
...KnowledgeService.ERROR_LOADER_RETURN,
message: `Failed to add dir loader: ${err.message}`,
messageSource: 'embedding'
}
return KnowledgeService.ERROR_LOADER_RETURN
}),
evaluateTaskWorkload: { workload: file.size }
})
@@ -313,11 +269,7 @@ class KnowledgeService {
})
.catch((err) => {
Logger.error(err)
return {
...KnowledgeService.ERROR_LOADER_RETURN,
message: `Failed to add url loader: ${err.message}`,
messageSource: 'embedding'
}
return KnowledgeService.ERROR_LOADER_RETURN
})
},
evaluateTaskWorkload: { workload: 2 * MB }
@@ -357,11 +309,7 @@ class KnowledgeService {
})
.catch((err) => {
Logger.error(err)
return {
...KnowledgeService.ERROR_LOADER_RETURN,
message: `Failed to add sitemap loader: ${err.message}`,
messageSource: 'embedding'
}
return KnowledgeService.ERROR_LOADER_RETURN
}),
evaluateTaskWorkload: { workload: 20 * MB }
}
@@ -377,7 +325,6 @@ class KnowledgeService {
): LoaderTask {
const { base, item, forceReload } = options
const content = item.content as string
const sourceUrl = (item as any).sourceUrl
const encoder = new TextEncoder()
const contentBytes = encoder.encode(content)
@@ -387,12 +334,7 @@ class KnowledgeService {
state: LoaderTaskItemState.PENDING,
task: () => {
const loaderReturn = ragApplication.addLoader(
new NoteLoader({
text: content,
sourceUrl,
chunkSize: base.chunkSize,
chunkOverlap: base.chunkOverlap
}),
new TextLoader({ text: content, chunkSize: base.chunkSize, chunkOverlap: base.chunkOverlap }),
forceReload
) as Promise<LoaderReturn>
@@ -407,11 +349,7 @@ class KnowledgeService {
})
.catch((err) => {
Logger.error(err)
return {
...KnowledgeService.ERROR_LOADER_RETURN,
message: `Failed to add note loader: ${err.message}`,
messageSource: 'embedding'
}
return KnowledgeService.ERROR_LOADER_RETURN
})
},
evaluateTaskWorkload: { workload: contentBytes.length }
@@ -477,10 +415,10 @@ class KnowledgeService {
})
}
public add = async (_: Electron.IpcMainInvokeEvent, options: KnowledgeBaseAddItemOptions): Promise<LoaderReturn> => {
public add = (_: Electron.IpcMainInvokeEvent, options: KnowledgeBaseAddItemOptions): Promise<LoaderReturn> => {
return new Promise((resolve) => {
const { base, item, forceReload = false, userId = '' } = options
const optionsNonNullableAttribute = { base, item, forceReload, userId }
const { base, item, forceReload = false } = options
const optionsNonNullableAttribute = { base, item, forceReload }
this.getRagApplication(base)
.then((ragApplication) => {
const task = (() => {
@@ -506,20 +444,12 @@ class KnowledgeService {
})
this.processingQueueHandle()
} else {
resolve({
...KnowledgeService.ERROR_LOADER_RETURN,
message: 'Unsupported item type',
messageSource: 'embedding'
})
resolve(KnowledgeService.ERROR_LOADER_RETURN)
}
})
.catch((err) => {
Logger.error(err)
resolve({
...KnowledgeService.ERROR_LOADER_RETURN,
message: `Failed to add item: ${err.message}`,
messageSource: 'embedding'
})
resolve(KnowledgeService.ERROR_LOADER_RETURN)
})
})
}
@@ -552,69 +482,6 @@ class KnowledgeService {
}
return await new Reranker(base).rerank(search, results)
}
public getStorageDir = (): string => {
return this.storageDir
}
private preprocessing = async (
file: FileMetadata,
base: KnowledgeBaseParams,
item: KnowledgeItem,
userId: string
): Promise<FileMetadata> => {
let fileToProcess: FileMetadata = file
if (base.preprocessOrOcrProvider && file.ext.toLowerCase() === '.pdf') {
try {
let provider: PreprocessProvider | OcrProvider
if (base.preprocessOrOcrProvider.type === 'preprocess') {
provider = new PreprocessProvider(base.preprocessOrOcrProvider.provider, userId)
} else {
provider = new OcrProvider(base.preprocessOrOcrProvider.provider)
}
// 首先检查文件是否已经被预处理过
const alreadyProcessed = await provider.checkIfAlreadyProcessed(file)
if (alreadyProcessed) {
Logger.info(`File already preprocess processed, using cached result: ${file.path}`)
return alreadyProcessed
}
// 执行预处理
Logger.info(`Starting preprocess processing for scanned PDF: ${file.path}`)
const { processedFile, quota } = await provider.parseFile(item.id, file)
fileToProcess = processedFile
const mainWindow = windowService.getMainWindow()
mainWindow?.webContents.send('file-preprocess-finished', {
itemId: item.id,
quota: quota
})
} catch (err) {
Logger.error(`Preprocess processing failed: ${err}`)
// 如果预处理失败,使用原始文件
// fileToProcess = file
throw new Error(`Preprocess processing failed: ${err}`)
}
}
return fileToProcess
}
public checkQuota = async (
_: Electron.IpcMainInvokeEvent,
base: KnowledgeBaseParams,
userId: string
): Promise<number> => {
try {
if (base.preprocessOrOcrProvider && base.preprocessOrOcrProvider.type === 'preprocess') {
const provider = new PreprocessProvider(base.preprocessOrOcrProvider.provider, userId)
return await provider.checkQuota()
}
throw new Error('No preprocess provider configured')
} catch (err) {
Logger.error(`Failed to check quota: ${err}`)
throw new Error(`Failed to check quota: ${err}`)
}
}
}
export default new KnowledgeService()

View File

@@ -1,33 +0,0 @@
import { Mistral } from '@mistralai/mistralai'
import { Provider } from '@types'
export class MistralClientManager {
private static instance: MistralClientManager
private client: Mistral | null = null
// eslint-disable-next-line @typescript-eslint/no-empty-function
private constructor() {}
public static getInstance(): MistralClientManager {
if (!MistralClientManager.instance) {
MistralClientManager.instance = new MistralClientManager()
}
return MistralClientManager.instance
}
public initializeClient(provider: Provider): void {
if (!this.client) {
this.client = new Mistral({
apiKey: provider.apiKey,
serverURL: provider.apiHost
})
}
}
public getClient(): Mistral {
if (!this.client) {
throw new Error('Mistral client not initialized. Call initializeClient first.')
}
return this.client
}
}

View File

@@ -19,7 +19,7 @@ export function registerProtocolClient(app: Electron.App) {
}
}
app.setAsDefaultProtocolClient(CHERRY_STUDIO_PROTOCOL)
app.setAsDefaultProtocolClient('cherrystudio')
}
export function handleProtocolUrl(url: string) {

View File

@@ -1,4 +1,9 @@
import { ProxyConfig as _ProxyConfig, session } from 'electron'
// import { ProxyConfig as _ProxyConfig, session } from 'electron'
import { session } from 'electron'
declare type _ProxyConfig = any
// import { socksDispatcher } from 'fetch-socks'
import { getSystemProxy } from 'os-proxy-config'
import { ProxyAgent as GeneralProxyAgent } from 'proxy-agent'
// import { ProxyAgent, setGlobalDispatcher } from 'undici'

View File

@@ -1,102 +0,0 @@
import { randomUUID } from 'node:crypto'
import { BrowserWindow, ipcMain } from 'electron'
interface PythonExecutionRequest {
id: string
script: string
context: Record<string, any>
timeout: number
}
interface PythonExecutionResponse {
id: string
result?: string
error?: string
}
/**
* Service for executing Python code by communicating with the PyodideService in the renderer process
*/
export class PythonService {
private static instance: PythonService | null = null
private mainWindow: BrowserWindow | null = null
private pendingRequests = new Map<string, { resolve: (value: string) => void; reject: (error: Error) => void }>()
private constructor() {
// Private constructor for singleton pattern
this.setupIpcHandlers()
}
public static getInstance(): PythonService {
if (!PythonService.instance) {
PythonService.instance = new PythonService()
}
return PythonService.instance
}
private setupIpcHandlers() {
// Handle responses from renderer
ipcMain.on('python-execution-response', (_, response: PythonExecutionResponse) => {
const request = this.pendingRequests.get(response.id)
if (request) {
this.pendingRequests.delete(response.id)
if (response.error) {
request.reject(new Error(response.error))
} else {
request.resolve(response.result || '')
}
}
})
}
public setMainWindow(mainWindow: BrowserWindow) {
this.mainWindow = mainWindow
}
/**
* Execute Python code by sending request to renderer PyodideService
*/
public async executeScript(
script: string,
context: Record<string, any> = {},
timeout: number = 60000
): Promise<string> {
if (!this.mainWindow) {
throw new Error('Main window not set in PythonService')
}
return new Promise((resolve, reject) => {
const requestId = randomUUID()
// Store the request
this.pendingRequests.set(requestId, { resolve, reject })
// Set up timeout
const timeoutId = setTimeout(() => {
this.pendingRequests.delete(requestId)
reject(new Error('Python execution timed out'))
}, timeout + 5000) // Add 5s buffer for IPC communication
// Update resolve/reject to clear timeout
const originalResolve = resolve
const originalReject = reject
this.pendingRequests.set(requestId, {
resolve: (value: string) => {
clearTimeout(timeoutId)
originalResolve(value)
},
reject: (error: Error) => {
clearTimeout(timeoutId)
originalReject(error)
}
})
// Send request to renderer
const request: PythonExecutionRequest = { id: requestId, script, context, timeout }
this.mainWindow?.webContents.send('python-execution-request', request)
})
}
}
export const pythonService = PythonService.getInstance()

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 { isDev, isWin } from '@main/constant'
import { IpcChannel } from '@shared/IpcChannel'
import { BrowserWindow, ipcMain, screen, systemPreferences } from 'electron'
import { BrowserWindow, ipcMain, screen } from 'electron'
import Logger from 'electron-log'
import { join } from 'path'
import type {
@@ -14,14 +14,10 @@ import type {
import type { ActionItem } from '../../renderer/src/types/selectionTypes'
import { ConfigKeys, configManager } from './ConfigManager'
import storeSyncService from './StoreSyncService'
const isSupportedOS = isWin || isMac
let SelectionHook: SelectionHookConstructor | null = null
try {
//since selection-hook v1.0.0, it supports macOS
if (isSupportedOS) {
if (isWin) {
SelectionHook = require('selection-hook')
}
} catch (error) {
@@ -43,8 +39,7 @@ type RelativeOrientation =
enum TriggerMode {
Selected = 'selected',
Ctrlkey = 'ctrlkey',
Shortcut = 'shortcut'
Ctrlkey = 'ctrlkey'
}
/** SelectionService is a singleton class that manages the selection hook and the toolbar window
@@ -121,7 +116,7 @@ export class SelectionService {
}
public static getInstance(): SelectionService | null {
if (!isSupportedOS) return null
if (!isWin) return null
if (!SelectionService.instance) {
SelectionService.instance = new SelectionService()
@@ -216,8 +211,6 @@ export class SelectionService {
blacklist: SelectionHook!.FilterMode.EXCLUDE_LIST
}
const predefinedBlacklist = isWin ? SELECTION_PREDEFINED_BLACKLIST.WINDOWS : SELECTION_PREDEFINED_BLACKLIST.MAC
let combinedList: string[] = list
let combinedMode = mode
@@ -226,7 +219,7 @@ export class SelectionService {
switch (mode) {
case 'blacklist':
//combine the predefined blacklist with the user-defined blacklist
combinedList = [...new Set([...list, ...predefinedBlacklist])]
combinedList = [...new Set([...list, ...SELECTION_PREDEFINED_BLACKLIST.WINDOWS])]
break
case 'whitelist':
combinedList = [...list]
@@ -234,7 +227,7 @@ export class SelectionService {
case 'default':
default:
//use the predefined blacklist as the default filter list
combinedList = [...predefinedBlacklist]
combinedList = [...SELECTION_PREDEFINED_BLACKLIST.WINDOWS]
combinedMode = 'blacklist'
break
}
@@ -248,21 +241,14 @@ export class SelectionService {
private setHookFineTunedList() {
if (!this.selectionHook) return
const excludeClipboardCursorDetectList = isWin
? SELECTION_FINETUNED_LIST.EXCLUDE_CLIPBOARD_CURSOR_DETECT.WINDOWS
: SELECTION_FINETUNED_LIST.EXCLUDE_CLIPBOARD_CURSOR_DETECT.MAC
const includeClipboardDelayReadList = isWin
? SELECTION_FINETUNED_LIST.INCLUDE_CLIPBOARD_DELAY_READ.WINDOWS
: SELECTION_FINETUNED_LIST.INCLUDE_CLIPBOARD_DELAY_READ.MAC
this.selectionHook.setFineTunedList(
SelectionHook!.FineTunedListType.EXCLUDE_CLIPBOARD_CURSOR_DETECT,
excludeClipboardCursorDetectList
SELECTION_FINETUNED_LIST.EXCLUDE_CLIPBOARD_CURSOR_DETECT.WINDOWS
)
this.selectionHook.setFineTunedList(
SelectionHook!.FineTunedListType.INCLUDE_CLIPBOARD_DELAY_READ,
includeClipboardDelayReadList
SELECTION_FINETUNED_LIST.INCLUDE_CLIPBOARD_DELAY_READ.WINDOWS
)
}
@@ -271,28 +257,11 @@ export class SelectionService {
* @returns {boolean} Success status of service start
*/
public start(): boolean {
if (!this.selectionHook) {
this.logError(new Error('SelectionService start(): instance is null'))
if (!this.selectionHook || this.started) {
this.logError(new Error('SelectionService start(): instance is null or already started'))
return false
}
if (this.started) {
this.logError(new Error('SelectionService start(): already started'))
return false
}
//On macOS, we need to check if the process is trusted
if (isMac) {
if (!systemPreferences.isTrustedAccessibilityClient(false)) {
this.logError(
new Error(
'SelectionSerice not started: process is not trusted on macOS, please turn on the Accessibility permission'
)
)
return false
}
}
try {
//make sure the toolbar window is ready
this.createToolbarWindow()
@@ -314,7 +283,7 @@ export class SelectionService {
this.processTriggerMode()
this.started = true
this.logInfo('SelectionService Started', true)
this.logInfo('SelectionService Started')
return true
}
@@ -335,7 +304,6 @@ export class SelectionService {
if (!this.selectionHook) return false
this.selectionHook.stop()
this.selectionHook.cleanup() //already remove all listeners
//reset the listener states
@@ -346,11 +314,8 @@ export class SelectionService {
this.toolbarWindow.close()
this.toolbarWindow = null
}
this.closePreloadedActionWindows()
this.started = false
this.logInfo('SelectionService Stopped', true)
this.logInfo('SelectionService Stopped')
return true
}
@@ -366,22 +331,7 @@ export class SelectionService {
this.selectionHook = null
this.initStatus = false
SelectionService.instance = null
this.logInfo('SelectionService Quitted', true)
}
/**
* Toggle the enabled state of the selection service
* Will sync the new enabled store to all renderer windows
*/
public toggleEnabled(enabled: boolean | undefined = undefined) {
if (!this.selectionHook) return
const newEnabled = enabled === undefined ? !configManager.getSelectionAssistantEnabled() : enabled
configManager.setSelectionAssistantEnabled(newEnabled)
//sync the new enabled state to all renderer windows
storeSyncService.syncToRenderer('selectionStore/setSelectionEnabled', newEnabled)
this.logInfo('SelectionService Quitted')
}
/**
@@ -397,29 +347,21 @@ export class SelectionService {
this.toolbarWindow = new BrowserWindow({
width: toolbarWidth,
height: toolbarHeight,
show: false,
frame: false,
transparent: true,
alwaysOnTop: true,
skipTaskbar: true,
autoHideMenuBar: true,
resizable: false,
minimizable: false,
maximizable: false,
fullscreenable: false, // [macOS] must be false
movable: true,
focusable: false,
hasShadow: false,
thickFrame: false,
roundedCorners: true,
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 } : {}),
hiddenInMissionControl: true, // [macOS only]
acceptFirstMouse: true, // [macOS only]
// backgroundMaterial: 'none',
type: 'toolbar',
show: false,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
contextIsolation: true,
@@ -431,29 +373,17 @@ export class SelectionService {
// Hide when losing focus
this.toolbarWindow.on('blur', () => {
if (this.toolbarWindow!.isVisible()) {
this.hideToolbar()
}
this.hideToolbar()
})
// Clean up when closed
this.toolbarWindow.on('closed', () => {
if (!this.toolbarWindow?.isDestroyed()) {
this.toolbarWindow?.destroy()
}
this.toolbarWindow = null
})
// 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', () => {
@@ -504,29 +434,8 @@ export class SelectionService {
x: posX,
y: posY
})
//set the window to always on top (highest level)
//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 })
// }
// [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)
this.toolbarWindow!.show()
this.toolbarWindow!.setOpacity(1)
this.startHideByMouseKeyListener()
}
@@ -536,52 +445,10 @@ export class SelectionService {
public hideToolbar(): void {
if (!this.isToolbarAlive()) return
this.stopHideByMouseKeyListener()
// [Windows] just hide the toolbar window is enough
if (!isMac) {
this.toolbarWindow!.hide()
return
}
/************************************************
* [macOS] the following code is only for macOS
*************************************************/
// [macOS] a HACKY way
// make sure other windows do not bring to front when toolbar is hidden
// get all focusable windows and set them to not focusable
const focusableWindows: BrowserWindow[] = []
for (const window of BrowserWindow.getAllWindows()) {
if (!window.isDestroyed() && window.isVisible()) {
if (window.isFocusable()) {
focusableWindows.push(window)
window.setFocusable(false)
}
}
}
this.toolbarWindow!.setOpacity(0)
this.toolbarWindow!.hide()
// set them back to focusable after 50ms
setTimeout(() => {
for (const window of focusableWindows) {
if (!window.isDestroyed()) {
window.setFocusable(true)
}
}
}, 50)
// [macOS] hacky way
// Because toolbar is not a FOCUSED window, so the hover status will remain when next time show
// so we just send mouseMove event to the toolbar window to make the hover status disappear
this.toolbarWindow!.webContents.sendInputEvent({
type: 'mouseMove',
x: -1,
y: -1
})
return
this.stopHideByMouseKeyListener()
}
/**
@@ -621,71 +488,71 @@ export class SelectionService {
/**
* Calculate optimal toolbar position based on selection context
* Ensures toolbar stays within screen boundaries and follows selection direction
* @param refPoint Reference point for positioning, must be INTEGER
* @param point Reference point for positioning, must be INTEGER
* @param orientation Preferred position relative to reference point
* @returns Calculated screen coordinates for toolbar, INTEGER
*/
private calculateToolbarPosition(refPoint: Point, orientation: RelativeOrientation): Point {
private calculateToolbarPosition(point: Point, orientation: RelativeOrientation): Point {
// Calculate initial position based on the specified anchor
const posPoint: Point = { x: 0, y: 0 }
let posX: number, posY: number
const { toolbarWidth, toolbarHeight } = this.getToolbarRealSize()
switch (orientation) {
case 'topLeft':
posPoint.x = refPoint.x - toolbarWidth
posPoint.y = refPoint.y - toolbarHeight
posX = point.x - toolbarWidth
posY = point.y - toolbarHeight
break
case 'topRight':
posPoint.x = refPoint.x
posPoint.y = refPoint.y - toolbarHeight
posX = point.x
posY = point.y - toolbarHeight
break
case 'topMiddle':
posPoint.x = refPoint.x - toolbarWidth / 2
posPoint.y = refPoint.y - toolbarHeight
posX = point.x - toolbarWidth / 2
posY = point.y - toolbarHeight
break
case 'bottomLeft':
posPoint.x = refPoint.x - toolbarWidth
posPoint.y = refPoint.y
posX = point.x - toolbarWidth
posY = point.y
break
case 'bottomRight':
posPoint.x = refPoint.x
posPoint.y = refPoint.y
posX = point.x
posY = point.y
break
case 'bottomMiddle':
posPoint.x = refPoint.x - toolbarWidth / 2
posPoint.y = refPoint.y
posX = point.x - toolbarWidth / 2
posY = point.y
break
case 'middleLeft':
posPoint.x = refPoint.x - toolbarWidth
posPoint.y = refPoint.y - toolbarHeight / 2
posX = point.x - toolbarWidth
posY = point.y - toolbarHeight / 2
break
case 'middleRight':
posPoint.x = refPoint.x
posPoint.y = refPoint.y - toolbarHeight / 2
posX = point.x
posY = point.y - toolbarHeight / 2
break
case 'center':
posPoint.x = refPoint.x - toolbarWidth / 2
posPoint.y = refPoint.y - toolbarHeight / 2
posX = point.x - toolbarWidth / 2
posY = point.y - toolbarHeight / 2
break
default:
// Default to 'topMiddle' if invalid position
posPoint.x = refPoint.x - toolbarWidth / 2
posPoint.y = refPoint.y - toolbarHeight / 2
posX = point.x - toolbarWidth / 2
posY = point.y - toolbarHeight / 2
}
//use original point to get the display
const display = screen.getDisplayNearestPoint(refPoint)
const display = screen.getDisplayNearestPoint({ x: point.x, y: point.y })
// Ensure toolbar stays within screen boundaries
posPoint.x = Math.round(
Math.max(display.workArea.x, Math.min(posPoint.x, display.workArea.x + display.workArea.width - toolbarWidth))
posX = Math.round(
Math.max(display.workArea.x, Math.min(posX, display.workArea.x + display.workArea.width - toolbarWidth))
)
posPoint.y = Math.round(
Math.max(display.workArea.y, Math.min(posPoint.y, display.workArea.y + display.workArea.height - toolbarHeight))
posY = Math.round(
Math.max(display.workArea.y, Math.min(posY, display.workArea.y + display.workArea.height - toolbarHeight))
)
return posPoint
return { x: posX, y: posY }
}
private isSamePoint(point1: Point, point2: Point): boolean {
@@ -696,21 +563,6 @@ export class SelectionService {
return startTop.y === endTop.y && startBottom.y === endBottom.y
}
/**
* Get the user selected text and process it (trigger by shortcut)
*
* it's a public method used by shortcut service
*/
public processSelectTextByShortcut(): void {
if (!this.selectionHook || !this.started || this.triggerMode !== TriggerMode.Shortcut) return
const selectionData = this.selectionHook.getCurrentSelection()
if (selectionData) {
this.processTextSelection(selectionData)
}
}
/**
* Determine if the text selection should be processed by filter mode&list
* @param selectionData Text selection information and coordinates
@@ -874,11 +726,8 @@ export class SelectionService {
}
if (!isLogical) {
// [macOS] don't need to convert by screenToDipPoint
if (!isMac) {
refPoint = screen.screenToDipPoint(refPoint)
}
//screenToDipPoint can be float, so we need to round it
refPoint = screen.screenToDipPoint(refPoint)
refPoint = { x: Math.round(refPoint.x), y: Math.round(refPoint.y) }
}
@@ -936,8 +785,8 @@ export class SelectionService {
return
}
//data point is physical coordinates, convert to logical coordinates(only for windows/linux)
const mousePoint = isMac ? { x: data.x, y: data.y } : screen.screenToDipPoint({ x: data.x, y: data.y })
//data point is physical coordinates, convert to logical coordinates
const mousePoint = screen.screenToDipPoint({ x: data.x, y: data.y })
const bounds = this.toolbarWindow!.getBounds()
@@ -963,8 +812,8 @@ export class SelectionService {
if (this.triggerMode === TriggerMode.Ctrlkey && this.isCtrlkey(data.vkCode)) {
return
}
//dont hide toolbar when shiftkey or altkey is pressed, because it's used for selection
if (this.isShiftkey(data.vkCode) || this.isAltkey(data.vkCode)) {
//dont hide toolbar when shiftkey is pressed, because it's used for selection
if (this.isShiftkey(data.vkCode)) {
return
}
@@ -992,9 +841,8 @@ export class SelectionService {
//ctrlkey pressed
if (this.lastCtrlkeyDownTime === 0) {
this.lastCtrlkeyDownTime = Date.now()
//add the mouse-wheel&mouse-down listener, detect if user is zooming in/out or multi-selecting
//add the mouse-wheel listener, detect if user is zooming in/out
this.selectionHook!.on('mouse-wheel', this.handleMouseWheelCtrlkeyMode)
this.selectionHook!.on('mouse-down', this.handleMouseDownCtrlkeyMode)
return
}
@@ -1005,6 +853,7 @@ export class SelectionService {
this.lastCtrlkeyDownTime = -1
const selectionData = this.selectionHook!.getCurrentSelection()
if (selectionData) {
this.processTextSelection(selectionData)
}
@@ -1017,9 +866,8 @@ export class SelectionService {
*/
private handleKeyUpCtrlkeyMode = (data: KeyboardEventData) => {
if (!this.isCtrlkey(data.vkCode)) return
//remove the mouse-wheel&mouse-down listener
//remove the mouse-wheel listener
this.selectionHook!.off('mouse-wheel', this.handleMouseWheelCtrlkeyMode)
this.selectionHook!.off('mouse-down', this.handleMouseDownCtrlkeyMode)
this.lastCtrlkeyDownTime = 0
}
@@ -1032,15 +880,6 @@ export class SelectionService {
this.lastCtrlkeyDownTime = -1
}
/**
* Handle mouse down events in ctrlkey trigger mode
* ignore CtrlKey pressing when mouse down is used
* because user is multi-selecting
*/
private handleMouseDownCtrlkeyMode = () => {
this.lastCtrlkeyDownTime = -1
}
//check if the key is ctrl key
private isCtrlkey(vkCode: number) {
return vkCode === 162 || vkCode === 163
@@ -1051,11 +890,6 @@ export class SelectionService {
return vkCode === 160 || vkCode === 161
}
//check if the key is alt key
private isAltkey(vkCode: number) {
return vkCode === 164 || vkCode === 165
}
/**
* Create a preloaded action window for quick response
* Action windows handle specific operations on selected text
@@ -1070,8 +904,7 @@ export class SelectionService {
frame: false,
transparent: true,
autoHideMenuBar: true,
titleBarStyle: 'hidden', // [macOS]
trafficLightPosition: { x: 12, y: 9 }, // [macOS]
titleBarStyle: 'hidden',
hasShadow: false,
thickFrame: false,
show: false,
@@ -1109,17 +942,6 @@ export class SelectionService {
}
}
/**
* Close all preloaded action windows
*/
private closePreloadedActionWindows() {
for (const actionWindow of this.preloadedActionWindows) {
if (!actionWindow.isDestroyed()) {
actionWindow.destroy()
}
}
}
/**
* Preload a new action window asynchronously
* This method is called after popping a window to ensure we always have windows ready
@@ -1148,27 +970,6 @@ export class SelectionService {
if (!actionWindow.isDestroyed()) {
actionWindow.destroy()
}
// [macOS] a HACKY way
// make sure other windows do not bring to front when action window is closed
if (isMac) {
const focusableWindows: BrowserWindow[] = []
for (const window of BrowserWindow.getAllWindows()) {
if (!window.isDestroyed() && window.isVisible()) {
if (window.isFocusable()) {
focusableWindows.push(window)
window.setFocusable(false)
}
}
}
setTimeout(() => {
for (const window of focusableWindows) {
if (!window.isDestroyed()) {
window.setFocusable(true)
}
}
}, 50)
}
})
//remember the action window size
@@ -1214,26 +1015,22 @@ export class SelectionService {
//center way
if (!this.isFollowToolbar || !this.toolbarWindow) {
const display = screen.getDisplayNearestPoint(screen.getCursorScreenPoint())
const workArea = display.workArea
const centerX = workArea.x + (workArea.width - actionWindowWidth) / 2
const centerY = workArea.y + (workArea.height - actionWindowHeight) / 2
actionWindow.setBounds({
width: actionWindowWidth,
height: actionWindowHeight,
x: Math.round(centerX),
y: Math.round(centerY)
})
if (this.isRemeberWinSize) {
actionWindow.setBounds({
width: actionWindowWidth,
height: actionWindowHeight
})
}
actionWindow.show()
this.hideToolbar()
return
}
//follow toolbar
const toolbarBounds = this.toolbarWindow!.getBounds()
const display = screen.getDisplayNearestPoint(screen.getCursorScreenPoint())
const display = screen.getDisplayNearestPoint({ x: toolbarBounds.x, y: toolbarBounds.y })
const workArea = display.workArea
const GAP = 6 // 6px gap from screen edges
@@ -1293,44 +1090,29 @@ export class SelectionService {
* Manages appropriate event listeners for each mode
*/
private processTriggerMode() {
switch (this.triggerMode) {
case TriggerMode.Selected:
if (this.isCtrlkeyListenerActive) {
this.selectionHook!.off('key-down', this.handleKeyDownCtrlkeyMode)
this.selectionHook!.off('key-up', this.handleKeyUpCtrlkeyMode)
if (this.triggerMode === TriggerMode.Selected) {
if (this.isCtrlkeyListenerActive) {
this.selectionHook!.off('key-down', this.handleKeyDownCtrlkeyMode)
this.selectionHook!.off('key-up', this.handleKeyUpCtrlkeyMode)
this.isCtrlkeyListenerActive = false
}
this.isCtrlkeyListenerActive = 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!.setSelectionPassiveMode(false)
} else if (this.triggerMode === TriggerMode.Ctrlkey) {
if (!this.isCtrlkeyListenerActive) {
this.selectionHook!.on('key-down', this.handleKeyDownCtrlkeyMode)
this.selectionHook!.on('key-up', this.handleKeyUpCtrlkeyMode)
this.isCtrlkeyListenerActive = true
}
this.isCtrlkeyListenerActive = 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.isCtrlkeyListenerActive = false
}
this.selectionHook!.setSelectionPassiveMode(true)
break
this.selectionHook!.setSelectionPassiveMode(true)
}
}
public writeToClipboard(text: string): boolean {
if (!this.selectionHook || !this.started) return false
return this.selectionHook.writeToClipboard(text)
return this.selectionHook?.writeToClipboard(text) ?? false
}
/**
@@ -1344,7 +1126,7 @@ export class SelectionService {
selectionService?.hideToolbar()
})
ipcMain.handle(IpcChannel.Selection_WriteToClipboard, (_, text: string): boolean => {
ipcMain.handle(IpcChannel.Selection_WriteToClipboard, (_, text: string) => {
return selectionService?.writeToClipboard(text) ?? false
})
@@ -1404,10 +1186,8 @@ export class SelectionService {
this.isIpcHandlerRegistered = true
}
private logInfo(message: string, forceShow: boolean = false) {
if (isDev || forceShow) {
Logger.info('[SelectionService] Info: ', message)
}
private logInfo(message: string) {
isDev && Logger.info('[SelectionService] Info: ', message)
}
private logError(...args: [...string[], Error]) {
@@ -1421,7 +1201,7 @@ export class SelectionService {
* @returns {boolean} Success status of initialization
*/
export function initSelectionService(): boolean {
if (!isSupportedOS) return false
if (!isWin) return false
configManager.subscribe(ConfigKeys.SelectionAssistantEnabled, (enabled: boolean) => {
//avoid closure

View File

@@ -4,16 +4,10 @@ import { BrowserWindow, globalShortcut } from 'electron'
import Logger from 'electron-log'
import { configManager } from './ConfigManager'
import selectionService from './SelectionService'
import { windowService } from './WindowService'
let showAppAccelerator: string | null = null
let showMiniWindowAccelerator: string | null = null
let selectionAssistantToggleAccelerator: string | null = null
let selectionAssistantSelectTextAccelerator: string | null = null
//indicate if the shortcuts are registered on app boot time
let isRegisterOnBoot = true
// store the focus and blur handlers for each window to unregister them later
const windowOnHandlers = new Map<BrowserWindow, { onFocusHandler: () => void; onBlurHandler: () => void }>()
@@ -34,18 +28,6 @@ function getShortcutHandler(shortcut: Shortcut) {
return () => {
windowService.toggleMiniWindow()
}
case 'selection_assistant_toggle':
return () => {
if (selectionService) {
selectionService.toggleEnabled()
}
}
case 'selection_assistant_select_text':
return () => {
if (selectionService) {
selectionService.processSelectTextByShortcut()
}
}
default:
return null
}
@@ -55,8 +37,9 @@ function formatShortcutKey(shortcut: string[]): string {
return shortcut.join('+')
}
// convert the shortcut recorded by keyboard event key value to electron global shortcut format
const convertShortcutFormat = (shortcut: string | string[]): string => {
const convertShortcutRecordedByKeyboardEventKeyValueToElectronGlobalShortcutFormat = (
shortcut: string | string[]
): string => {
const accelerator = (() => {
if (Array.isArray(shortcut)) {
return shortcut
@@ -110,14 +93,11 @@ const convertShortcutFormat = (shortcut: string | string[]): string => {
}
export function registerShortcuts(window: BrowserWindow) {
if (isRegisterOnBoot) {
window.once('ready-to-show', () => {
if (configManager.getLaunchToTray()) {
registerOnlyUniversalShortcuts()
}
})
isRegisterOnBoot = false
}
window.once('ready-to-show', () => {
if (configManager.getLaunchToTray()) {
registerOnlyUniversalShortcuts()
}
})
//only for clearer code
const registerOnlyUniversalShortcuts = () => {
@@ -144,12 +124,7 @@ 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
)
) {
if (onlyUniversalShortcuts && !['show_app', 'mini_window'].includes(shortcut.key)) {
return
}
@@ -171,14 +146,6 @@ export function registerShortcuts(window: BrowserWindow) {
showMiniWindowAccelerator = formatShortcutKey(shortcut.shortcut)
break
case 'selection_assistant_toggle':
selectionAssistantToggleAccelerator = formatShortcutKey(shortcut.shortcut)
break
case 'selection_assistant_select_text':
selectionAssistantSelectTextAccelerator = formatShortcutKey(shortcut.shortcut)
break
//the following ZOOMs will register shortcuts seperately, so will return
case 'zoom_in':
globalShortcut.register('CommandOrControl+=', () => handler(window))
@@ -195,7 +162,9 @@ export function registerShortcuts(window: BrowserWindow) {
return
}
const accelerator = convertShortcutFormat(shortcut.shortcut)
const accelerator = convertShortcutRecordedByKeyboardEventKeyValueToElectronGlobalShortcutFormat(
shortcut.shortcut
)
globalShortcut.register(accelerator, () => handler(window))
} catch (error) {
@@ -212,25 +181,15 @@ export function registerShortcuts(window: BrowserWindow) {
if (showAppAccelerator) {
const handler = getShortcutHandler({ key: 'show_app' } as Shortcut)
const accelerator = convertShortcutFormat(showAppAccelerator)
const accelerator =
convertShortcutRecordedByKeyboardEventKeyValueToElectronGlobalShortcutFormat(showAppAccelerator)
handler && globalShortcut.register(accelerator, () => handler(window))
}
if (showMiniWindowAccelerator) {
const handler = getShortcutHandler({ key: 'mini_window' } as Shortcut)
const accelerator = convertShortcutFormat(showMiniWindowAccelerator)
handler && globalShortcut.register(accelerator, () => handler(window))
}
if (selectionAssistantToggleAccelerator) {
const handler = getShortcutHandler({ key: 'selection_assistant_toggle' } as Shortcut)
const accelerator = convertShortcutFormat(selectionAssistantToggleAccelerator)
handler && globalShortcut.register(accelerator, () => handler(window))
}
if (selectionAssistantSelectTextAccelerator) {
const handler = getShortcutHandler({ key: 'selection_assistant_select_text' } as Shortcut)
const accelerator = convertShortcutFormat(selectionAssistantSelectTextAccelerator)
const accelerator =
convertShortcutRecordedByKeyboardEventKeyValueToElectronGlobalShortcutFormat(showMiniWindowAccelerator)
handler && globalShortcut.register(accelerator, () => handler(window))
}
} catch (error) {
@@ -258,8 +217,6 @@ export function unregisterAllShortcuts() {
try {
showAppAccelerator = null
showMiniWindowAccelerator = null
selectionAssistantToggleAccelerator = null
selectionAssistantSelectTextAccelerator = null
windowOnHandlers.forEach((handlers, window) => {
window.off('focus', handlers.onFocusHandler)
window.off('blur', handlers.onBlurHandler)

View File

@@ -49,23 +49,6 @@ export class StoreSyncService {
this.windowIds = this.windowIds.filter((id) => id !== windowId)
}
/**
* Sync an action to all renderer windows
* @param type Action type, like 'settings/setTray'
* @param payload Action payload
*
* NOTICE: DO NOT use directly in ConfigManager, may cause infinite sync loop
*/
public syncToRenderer(type: string, payload: any): void {
const action: StoreSyncAction = {
type,
payload
}
//-1 means the action is from the main process, will be broadcast to all windows
this.broadcastToOtherWindows(-1, action)
}
/**
* Register IPC handlers for store sync communication
* Handles window subscription, unsubscription and action broadcasting

View File

@@ -1,4 +1,4 @@
import { isLinux, isMac, isWin } from '@main/constant'
import { isMac } from '@main/constant'
import { locales } from '@main/utils/locales'
import { app, Menu, MenuItemConstructorOptions, nativeImage, nativeTheme, Tray } from 'electron'
@@ -6,7 +6,6 @@ import icon from '../../../build/tray_icon.png?asset'
import iconDark from '../../../build/tray_icon_dark.png?asset'
import iconLight from '../../../build/tray_icon_light.png?asset'
import { ConfigKeys, configManager } from './ConfigManager'
import selectionService from './SelectionService'
import { windowService } from './WindowService'
export class TrayService {
@@ -30,14 +29,14 @@ export class TrayService {
const iconPath = isMac ? (nativeTheme.shouldUseDarkColors ? iconLight : iconDark) : icon
const tray = new Tray(iconPath)
if (isWin) {
if (process.platform === 'win32') {
tray.setImage(iconPath)
} else if (isMac) {
} else if (process.platform === 'darwin') {
const image = nativeImage.createFromPath(iconPath)
const resizedImage = image.resize({ width: 16, height: 16 })
resizedImage.setTemplateImage(true)
tray.setImage(resizedImage)
} else if (isLinux) {
} else if (process.platform === 'linux') {
const image = nativeImage.createFromPath(iconPath)
const resizedImage = image.resize({ width: 16, height: 16 })
tray.setImage(resizedImage)
@@ -47,7 +46,7 @@ export class TrayService {
this.updateContextMenu()
if (isLinux) {
if (process.platform === 'linux') {
this.tray.setContextMenu(this.contextMenu)
}
@@ -70,29 +69,19 @@ export class TrayService {
private updateContextMenu() {
const locale = locales[configManager.getLanguage()]
const { tray: trayLocale, selection: selectionLocale } = locale.translation
const { tray: trayLocale } = locale.translation
const quickAssistantEnabled = configManager.getEnableQuickAssistant()
const selectionAssistantEnabled = configManager.getSelectionAssistantEnabled()
const enableQuickAssistant = configManager.getEnableQuickAssistant()
const template = [
{
label: trayLocale.show_window,
click: () => windowService.showMainWindow()
},
quickAssistantEnabled && {
enableQuickAssistant && {
label: trayLocale.show_mini_window,
click: () => windowService.showMiniWindow()
},
(isWin || isMac) && {
label: selectionLocale.name + (selectionAssistantEnabled ? ' - On' : ' - Off'),
click: () => {
if (selectionService) {
selectionService.toggleEnabled()
this.updateContextMenu()
}
}
},
{ type: 'separator' },
{
label: trayLocale.quit,
@@ -129,10 +118,6 @@ export class TrayService {
configManager.subscribe(ConfigKeys.EnableQuickAssistant, () => {
this.updateContextMenu()
})
configManager.subscribe(ConfigKeys.SelectionAssistantEnabled, () => {
this.updateContextMenu()
})
}
private quit() {

View File

@@ -1,142 +0,0 @@
import { GoogleAuth } from 'google-auth-library'
interface ServiceAccountCredentials {
privateKey: string
clientEmail: string
}
interface VertexAIAuthParams {
projectId: string
serviceAccount?: ServiceAccountCredentials
}
const REQUIRED_VERTEX_AI_SCOPE = 'https://www.googleapis.com/auth/cloud-platform'
class VertexAIService {
private static instance: VertexAIService
private authClients: Map<string, GoogleAuth> = new Map()
static getInstance(): VertexAIService {
if (!VertexAIService.instance) {
VertexAIService.instance = new VertexAIService()
}
return VertexAIService.instance
}
/**
* 格式化私钥确保它包含正确的PEM头部和尾部
*/
private formatPrivateKey(privateKey: string): string {
if (!privateKey || typeof privateKey !== 'string') {
throw new Error('Private key must be a non-empty string')
}
// 处理JSON字符串中的转义换行符
let key = privateKey.replace(/\\n/g, '\n')
// 如果已经是正确格式的PEM直接返回
if (key.includes('-----BEGIN PRIVATE KEY-----') && key.includes('-----END PRIVATE KEY-----')) {
return key
}
// 移除所有换行符和空白字符(为了重新格式化)
key = key.replace(/\s+/g, '')
// 移除可能存在的头部和尾部
key = key.replace(/-----BEGIN[^-]*-----/g, '')
key = key.replace(/-----END[^-]*-----/g, '')
// 确保私钥不为空
if (!key) {
throw new Error('Private key is empty after formatting')
}
// 添加正确的PEM头部和尾部并格式化为64字符一行
const formattedKey = key.match(/.{1,64}/g)?.join('\n') || key
return `-----BEGIN PRIVATE KEY-----\n${formattedKey}\n-----END PRIVATE KEY-----`
}
/**
* 获取认证头用于 Vertex AI 请求
*/
async getAuthHeaders(params: VertexAIAuthParams): Promise<Record<string, string>> {
const { projectId, serviceAccount } = params
if (!serviceAccount?.privateKey || !serviceAccount?.clientEmail) {
throw new Error('Service account credentials are required')
}
// 创建缓存键
const cacheKey = `${projectId}-${serviceAccount.clientEmail}`
// 检查是否已有客户端实例
let auth = this.authClients.get(cacheKey)
if (!auth) {
try {
// 格式化私钥
const formattedPrivateKey = this.formatPrivateKey(serviceAccount.privateKey)
// 创建新的认证客户端
auth = new GoogleAuth({
credentials: {
private_key: formattedPrivateKey,
client_email: serviceAccount.clientEmail
},
projectId,
scopes: [REQUIRED_VERTEX_AI_SCOPE]
})
this.authClients.set(cacheKey, auth)
} catch (formatError: any) {
throw new Error(`Invalid private key format: ${formatError.message}`)
}
}
try {
// 获取认证头
const authHeaders = await auth.getRequestHeaders()
// 转换为普通对象
const headers: Record<string, string> = {}
for (const [key, value] of Object.entries(authHeaders)) {
if (typeof value === 'string') {
headers[key] = value
}
}
return headers
} catch (error: any) {
// 如果认证失败,清除缓存的客户端
this.authClients.delete(cacheKey)
throw new Error(`Failed to authenticate with service account: ${error.message}`)
}
}
/**
* 清理指定项目的认证缓存
*/
clearAuthCache(projectId: string, clientEmail?: string): void {
if (clientEmail) {
const cacheKey = `${projectId}-${clientEmail}`
this.authClients.delete(cacheKey)
} else {
// 清理该项目的所有缓存
for (const [key] of this.authClients) {
if (key.startsWith(`${projectId}-`)) {
this.authClients.delete(key)
}
}
}
}
/**
* 清理所有认证缓存
*/
clearAllAuthCache(): void {
this.authClients.clear()
}
}
export default VertexAIService

View File

@@ -1,8 +1,7 @@
import { WebDavConfig } from '@types'
import Logger from 'electron-log'
import https from 'https'
import path from 'path'
import Stream from 'stream'
import https from 'https'
import {
BufferLike,
createClient,
@@ -16,7 +15,7 @@ export default class WebDav {
private webdavPath: string
constructor(params: WebDavConfig) {
this.webdavPath = params.webdavPath || '/'
this.webdavPath = params.webdavPath
this.instance = createClient(params.webdavHost, {
username: params.webdavUser,
@@ -52,7 +51,7 @@ export default class WebDav {
throw error
}
const remoteFilePath = path.posix.join(this.webdavPath, filename)
const remoteFilePath = `${this.webdavPath}/${filename}`
try {
return await this.instance.putFileContents(remoteFilePath, data, options)
@@ -67,7 +66,7 @@ export default class WebDav {
throw new Error('WebDAV client not initialized')
}
const remoteFilePath = path.posix.join(this.webdavPath, filename)
const remoteFilePath = `${this.webdavPath}/${filename}`
try {
return await this.instance.getFileContents(remoteFilePath, options)
@@ -121,7 +120,7 @@ export default class WebDav {
throw new Error('WebDAV client not initialized')
}
const remoteFilePath = path.posix.join(this.webdavPath, filename)
const remoteFilePath = `${this.webdavPath}/${filename}`
try {
return await this.instance.deleteFile(remoteFilePath)

View File

@@ -56,7 +56,7 @@ export class WindowService {
minHeight: 600,
show: false,
autoHideMenuBar: true,
transparent: false,
transparent: isMac,
vibrancy: 'sidebar',
visualEffectState: 'active',
titleBarStyle: 'hidden',
@@ -95,7 +95,6 @@ export class WindowService {
this.setupMaximize(mainWindow, mainWindowState.isMaximized)
this.setupContextMenu(mainWindow)
this.setupSpellCheck(mainWindow)
this.setupWindowEvents(mainWindow)
this.setupWebContentsHandlers(mainWindow)
this.setupWindowLifecycleEvents(mainWindow)
@@ -103,18 +102,6 @@ export class WindowService {
this.loadMainWindowContent(mainWindow)
}
private setupSpellCheck(mainWindow: BrowserWindow) {
const enableSpellCheck = configManager.get('enableSpellCheck', false)
if (enableSpellCheck) {
try {
const spellCheckLanguages = configManager.get('spellCheckLanguages', []) as string[]
spellCheckLanguages.length > 0 && mainWindow.webContents.session.setSpellCheckerLanguages(spellCheckLanguages)
} catch (error) {
Logger.error('Failed to set spell check languages:', error as Error)
}
}
}
private setupMainWindowMonitor(mainWindow: BrowserWindow) {
mainWindow.webContents.on('render-process-gone', (_, details) => {
Logger.error(`Renderer process crashed with: ${JSON.stringify(details)}`)
@@ -129,6 +116,12 @@ export class WindowService {
app.exit(1)
}
})
mainWindow.webContents.on('unresponsive', () => {
// 在升级到electron 34后可以获取具体js stack trace,目前只打个日志监控下
// https://www.electronjs.org/blog/electron-34-0#unresponsive-renderer-javascript-call-stacks
Logger.error('Renderer process unresponsive')
})
}
private setupMaximize(mainWindow: BrowserWindow, isMaximized: boolean) {
@@ -143,10 +136,9 @@ export class WindowService {
}
private setupContextMenu(mainWindow: BrowserWindow) {
contextMenu.contextMenu(mainWindow.webContents)
// setup context menu for all webviews like miniapp
app.on('web-contents-created', (_, webContents) => {
contextMenu.contextMenu(webContents)
contextMenu.contextMenu(mainWindow)
app.on('browser-window-created', (_, win) => {
contextMenu.contextMenu(win)
})
// Dangerous API
@@ -451,7 +443,8 @@ export class WindowService {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
webSecurity: false,
webviewTag: true
webviewTag: true,
backgroundThrottling: false
}
})
@@ -551,25 +544,6 @@ export class WindowService {
public setPinMiniWindow(isPinned) {
this.isPinnedMiniWindow = isPinned
}
/**
* 引用文本到主窗口
* @param text 原始文本(未格式化)
*/
public quoteToMainWindow(text: string): void {
try {
this.showMainWindow()
const mainWindow = this.getMainWindow()
if (mainWindow && !mainWindow.isDestroyed()) {
setTimeout(() => {
mainWindow.webContents.send(IpcChannel.App_QuoteToMain, text)
}, 100)
}
} catch (error) {
Logger.error('Failed to quote to main window:', error as Error)
}
}
}
export const windowService = WindowService.getInstance()

View File

@@ -1,13 +0,0 @@
import { FileListResponse, FileMetadata, FileUploadResponse, Provider } from '@types'
export abstract class BaseFileService {
protected readonly provider: Provider
protected constructor(provider: Provider) {
this.provider = provider
}
abstract uploadFile(file: FileMetadata): Promise<FileUploadResponse>
abstract deleteFile(fileId: string): Promise<void>
abstract listFiles(): Promise<FileListResponse>
abstract retrieveFile(fileId: string): Promise<FileUploadResponse>
}

View File

@@ -1,41 +0,0 @@
import { Provider } from '@types'
import { BaseFileService } from './BaseFileService'
import { GeminiService } from './GeminiService'
import { MistralService } from './MistralService'
export class FileServiceManager {
private static instance: FileServiceManager
private services: Map<string, BaseFileService> = new Map()
// eslint-disable-next-line @typescript-eslint/no-empty-function
private constructor() {}
static getInstance(): FileServiceManager {
if (!this.instance) {
this.instance = new FileServiceManager()
}
return this.instance
}
getService(provider: Provider): BaseFileService {
const type = provider.type
let service = this.services.get(type)
if (!service) {
switch (type) {
case 'gemini':
service = new GeminiService(provider)
break
case 'mistral':
service = new MistralService(provider)
break
default:
throw new Error(`Unsupported service type: ${type}`)
}
this.services.set(type, service)
}
return service
}
}

View File

@@ -1,190 +0,0 @@
import { File, Files, FileState, GoogleGenAI } from '@google/genai'
import { FileListResponse, FileMetadata, FileUploadResponse, Provider } from '@types'
import Logger from 'electron-log'
import { v4 as uuidv4 } from 'uuid'
import { CacheService } from '../CacheService'
import { BaseFileService } from './BaseFileService'
export class GeminiService extends BaseFileService {
private static readonly FILE_LIST_CACHE_KEY = 'gemini_file_list'
private static readonly FILE_CACHE_DURATION = 48 * 60 * 60 * 1000
private static readonly LIST_CACHE_DURATION = 3000
protected readonly fileManager: Files
constructor(provider: Provider) {
super(provider)
this.fileManager = new GoogleGenAI({
vertexai: false,
apiKey: provider.apiKey,
httpOptions: {
baseUrl: provider.apiHost
}
}).files
}
async uploadFile(file: FileMetadata): Promise<FileUploadResponse> {
try {
const uploadResult = await this.fileManager.upload({
file: file.path,
config: {
mimeType: 'application/pdf',
name: file.id,
displayName: file.origin_name
}
})
// 根据文件状态设置响应状态
let status: 'success' | 'processing' | 'failed' | 'unknown'
switch (uploadResult.state) {
case FileState.ACTIVE:
status = 'success'
break
case FileState.PROCESSING:
status = 'processing'
break
case FileState.FAILED:
status = 'failed'
break
default:
status = 'unknown'
}
const response: FileUploadResponse = {
fileId: uploadResult.name || '',
displayName: file.origin_name,
status,
originalFile: {
type: 'gemini',
file: uploadResult
}
}
// 只缓存成功的文件
if (status === 'success') {
const cacheKey = `${GeminiService.FILE_LIST_CACHE_KEY}_${response.fileId}`
CacheService.set<FileUploadResponse>(cacheKey, response, GeminiService.FILE_CACHE_DURATION)
}
return response
} catch (error) {
Logger.error('Error uploading file to Gemini:', error)
return {
fileId: '',
displayName: file.origin_name,
status: 'failed',
originalFile: undefined
}
}
}
async retrieveFile(fileId: string): Promise<FileUploadResponse> {
try {
const cachedResponse = CacheService.get<FileUploadResponse>(`${GeminiService.FILE_LIST_CACHE_KEY}_${fileId}`)
Logger.info('[GeminiService] cachedResponse', cachedResponse)
if (cachedResponse) {
return cachedResponse
}
const files: File[] = []
for await (const f of await this.fileManager.list()) {
files.push(f)
}
Logger.info('[GeminiService] files', files)
const file = files
.filter((file) => file.state === FileState.ACTIVE)
.find((file) => file.name?.substring(6) === fileId) // 去掉 files/ 前缀
Logger.info('[GeminiService] file', file)
if (file) {
return {
fileId: fileId,
displayName: file.displayName || '',
status: 'success',
originalFile: {
type: 'gemini',
file
}
}
}
return {
fileId: fileId,
displayName: '',
status: 'failed',
originalFile: undefined
}
} catch (error) {
Logger.error('Error retrieving file from Gemini:', error)
return {
fileId: fileId,
displayName: '',
status: 'failed',
originalFile: undefined
}
}
}
async listFiles(): Promise<FileListResponse> {
try {
const cachedList = CacheService.get<FileListResponse>(GeminiService.FILE_LIST_CACHE_KEY)
if (cachedList) {
return cachedList
}
const geminiFiles: File[] = []
for await (const f of await this.fileManager.list()) {
geminiFiles.push(f)
}
const fileList: FileListResponse = {
files: geminiFiles
.filter((file) => file.state === FileState.ACTIVE)
.map((file) => {
// 更新单个文件的缓存
const fileResponse: FileUploadResponse = {
fileId: file.name || uuidv4(),
displayName: file.displayName || '',
status: 'success',
originalFile: {
type: 'gemini',
file
}
}
CacheService.set(
`${GeminiService.FILE_LIST_CACHE_KEY}_${file.name}`,
fileResponse,
GeminiService.FILE_CACHE_DURATION
)
return {
id: file.name || uuidv4(),
displayName: file.displayName || '',
size: Number(file.sizeBytes),
status: 'success',
originalFile: {
type: 'gemini',
file
}
}
})
}
// 更新文件列表缓存
CacheService.set(GeminiService.FILE_LIST_CACHE_KEY, fileList, GeminiService.LIST_CACHE_DURATION)
return fileList
} catch (error) {
Logger.error('Error listing files from Gemini:', error)
return { files: [] }
}
}
async deleteFile(fileId: string): Promise<void> {
try {
await this.fileManager.delete({ name: fileId })
Logger.info(`File ${fileId} deleted from Gemini`)
} catch (error) {
Logger.error('Error deleting file from Gemini:', error)
throw error
}
}
}

View File

@@ -1,104 +0,0 @@
import fs from 'node:fs/promises'
import { Mistral } from '@mistralai/mistralai'
import { FileListResponse, FileMetadata, FileUploadResponse, Provider } from '@types'
import Logger from 'electron-log'
import { MistralClientManager } from '../MistralClientManager'
import { BaseFileService } from './BaseFileService'
export class MistralService extends BaseFileService {
private readonly client: Mistral
constructor(provider: Provider) {
super(provider)
const clientManager = MistralClientManager.getInstance()
clientManager.initializeClient(provider)
this.client = clientManager.getClient()
}
async uploadFile(file: FileMetadata): Promise<FileUploadResponse> {
try {
const fileBuffer = await fs.readFile(file.path)
const response = await this.client.files.upload({
file: {
fileName: file.origin_name,
content: new Uint8Array(fileBuffer)
},
purpose: 'ocr'
})
return {
fileId: response.id,
displayName: file.origin_name,
status: 'success',
originalFile: {
type: 'mistral',
file: response
}
}
} catch (error) {
Logger.error('Error uploading file:', error)
return {
fileId: '',
displayName: file.origin_name,
status: 'failed'
}
}
}
async listFiles(): Promise<FileListResponse> {
try {
const response = await this.client.files.list({})
return {
files: response.data.map((file) => ({
id: file.id,
displayName: file.filename || '',
size: file.sizeBytes,
status: 'success', // All listed files are processed,
originalFile: {
type: 'mistral',
file
}
}))
}
} catch (error) {
Logger.error('Error listing files:', error)
return { files: [] }
}
}
async deleteFile(fileId: string): Promise<void> {
try {
await this.client.files.delete({
fileId
})
Logger.info(`File ${fileId} deleted`)
} catch (error) {
Logger.error('Error deleting file:', error)
throw error
}
}
async retrieveFile(fileId: string): Promise<FileUploadResponse> {
try {
const response = await this.client.files.retrieve({
fileId
})
return {
fileId: response.id,
displayName: response.filename || '',
status: 'success' // Retrieved files are always processed
}
} catch (error) {
Logger.error('Error retrieving file:', error)
return {
fileId: fileId,
displayName: '',
status: 'failed',
originalFile: undefined
}
}
}
}

View File

@@ -1,47 +1,37 @@
import { IpcChannel } from '@shared/IpcChannel'
import Logger from 'electron-log'
import { windowService } from '../WindowService'
export async function handleProvidersProtocolUrl(url: URL) {
export function handleProvidersProtocolUrl(url: URL) {
const params = new URLSearchParams(url.search)
switch (url.pathname) {
case '/api-keys': {
// jsonConfig example:
// {
// "id": "tokenflux",
// "baseUrl": "https://tokenflux.ai/v1",
// "apiKey": "sk-xxxx",
// "name": "TokenFlux", // optional
// "type": "openai" // optional
// "apiKey": "sk-xxxx"
// }
// cherrystudio://providers/api-keys?v=1&data={base64Encode(JSON.stringify(jsonConfig))}
// replace + and / to _ and - because + and / are processed by URLSearchParams
const processedSearch = url.search.replaceAll('+', '_').replaceAll('/', '-')
const params = new URLSearchParams(processedSearch)
// cherrystudio://providers/api-keys?data={base64Encode(JSON.stringify(jsonConfig))}
const data = params.get('data')
const mainWindow = windowService.getMainWindow()
const version = params.get('v')
if (version == '1') {
// TODO: handle different version
Logger.info('handleProvidersProtocolUrl', { data, version })
}
// add check there is window.navigate function in mainWindow
if (
mainWindow &&
!mainWindow.isDestroyed() &&
(await mainWindow.webContents.executeJavaScript(`typeof window.navigate === 'function'`))
) {
mainWindow.webContents.executeJavaScript(`window.navigate('/settings/provider?addProviderData=${data}')`)
if (data) {
const stringify = Buffer.from(data, 'base64').toString('utf8')
Logger.info('get api keys from urlschema: ', stringify)
const jsonConfig = JSON.parse(stringify)
Logger.info('get api keys from urlschema: ', jsonConfig)
const mainWindow = windowService.getMainWindow()
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send(IpcChannel.Provider_AddKey, jsonConfig)
mainWindow.webContents.executeJavaScript(`window.navigate('/settings/provider?id=${jsonConfig.id}')`)
}
} else {
setTimeout(() => {
handleProvidersProtocolUrl(url)
}, 1000)
Logger.error('No data found in URL')
}
break
}
default:
Logger.error(`Unknown MCP protocol URL: ${url}`)
console.error(`Unknown MCP protocol URL: ${url}`)
break
}
}

View File

@@ -92,7 +92,6 @@ describe('file', () => {
it('should return DOCUMENT for document extensions', () => {
expect(getFileType('.pdf')).toBe(FileTypes.DOCUMENT)
expect(getFileType('.pptx')).toBe(FileTypes.DOCUMENT)
expect(getFileType('.doc')).toBe(FileTypes.DOCUMENT)
expect(getFileType('.docx')).toBe(FileTypes.DOCUMENT)
expect(getFileType('.xlsx')).toBe(FileTypes.DOCUMENT)
expect(getFileType('.odt')).toBe(FileTypes.DOCUMENT)

View File

@@ -2,26 +2,12 @@ import * as fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { isLinux, isPortable } from '@main/constant'
import { isMac } from '@main/constant'
import { audioExts, documentExts, imageExts, textExts, videoExts } from '@shared/config/constant'
import { FileMetadata, FileTypes } from '@types'
import { FileType, FileTypes } from '@types'
import { app } from 'electron'
import { v4 as uuidv4 } from 'uuid'
export function initAppDataDir() {
const appDataPath = getAppDataPathFromConfig()
if (appDataPath) {
app.setPath('userData', appDataPath)
return
}
if (isPortable) {
const portableDir = process.env.PORTABLE_EXECUTABLE_DIR
app.setPath('userData', path.join(portableDir || app.getPath('exe'), 'data'))
return
}
}
// 创建文件类型映射表,提高查找效率
const fileTypeMap = new Map<string, FileTypes>()
@@ -37,112 +23,12 @@ function initFileTypeMap() {
// 初始化映射表
initFileTypeMap()
export function hasWritePermission(path: string) {
try {
fs.accessSync(path, fs.constants.W_OK)
return true
} catch (error) {
return false
}
}
function getAppDataPathFromConfig() {
try {
const configPath = path.join(getConfigDir(), 'config.json')
if (!fs.existsSync(configPath)) {
return null
}
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
if (!config.appDataPath) {
return null
}
let executablePath = app.getPath('exe')
if (isLinux && process.env.APPIMAGE) {
// 如果是 AppImage 打包的应用,直接使用 APPIMAGE 环境变量
// 这样可以确保获取到正确的可执行文件路径
executablePath = path.join(path.dirname(process.env.APPIMAGE), 'cherry-studio.appimage')
}
let appDataPath = null
// 兼容旧版本
if (config.appDataPath && typeof config.appDataPath === 'string') {
appDataPath = config.appDataPath
// 将旧版本数据迁移到新版本
appDataPath && updateAppDataConfig(appDataPath)
} else {
appDataPath = config.appDataPath.find(
(item: { executablePath: string }) => item.executablePath === executablePath
)?.dataPath
}
if (appDataPath && fs.existsSync(appDataPath) && hasWritePermission(appDataPath)) {
return appDataPath
}
return null
} catch (error) {
return null
}
}
export function updateAppDataConfig(appDataPath: string) {
const configDir = getConfigDir()
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true })
}
// config.json
// appDataPath: [{ executablePath: string, dataPath: string }]
const configPath = path.join(getConfigDir(), 'config.json')
let executablePath = app.getPath('exe')
if (isLinux && process.env.APPIMAGE) {
executablePath = path.join(path.dirname(process.env.APPIMAGE), 'cherry-studio.appimage')
}
if (!fs.existsSync(configPath)) {
fs.writeFileSync(configPath, JSON.stringify({ appDataPath: [{ executablePath, dataPath: appDataPath }] }, null, 2))
return
}
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
if (!config.appDataPath || (config.appDataPath && typeof config.appDataPath !== 'object')) {
config.appDataPath = []
}
const existingPath = config.appDataPath.find(
(item: { executablePath: string }) => item.executablePath === executablePath
)
if (existingPath) {
existingPath.dataPath = appDataPath
} else {
config.appDataPath.push({ executablePath, dataPath: appDataPath })
}
fs.writeFileSync(configPath, JSON.stringify(config, null, 2))
}
export function getFileType(ext: string): FileTypes {
ext = ext.toLowerCase()
return fileTypeMap.get(ext) || FileTypes.OTHER
}
export function getFileDir(filePath: string) {
return path.dirname(filePath)
}
export function getFileName(filePath: string) {
return path.basename(filePath)
}
export function getFileExt(filePath: string) {
return path.extname(filePath)
}
export function getAllFiles(dirPath: string, arrayOfFiles: FileMetadata[] = []): FileMetadata[] {
export function getAllFiles(dirPath: string, arrayOfFiles: FileType[] = []): FileType[] {
const files = fs.readdirSync(dirPath)
files.forEach((file) => {
@@ -164,7 +50,7 @@ export function getAllFiles(dirPath: string, arrayOfFiles: FileMetadata[] = []):
const name = path.basename(file)
const size = fs.statSync(fullPath).size
const fileItem: FileMetadata = {
const fileItem: FileType = {
id: uuidv4(),
name,
path: fullPath,
@@ -202,3 +88,12 @@ export function getCacheDir() {
export function getAppConfigDir(name: string) {
return path.join(getConfigDir(), name)
}
export function setUserDataDir() {
if (!isMac) {
const dir = path.join(path.dirname(app.getPath('exe')), 'data')
if (fs.existsSync(dir) && fs.statSync(dir).isDirectory()) {
app.setPath('userData', dir)
}
}
}

View File

@@ -49,7 +49,7 @@ export async function getBinaryPath(name?: string): Promise<string> {
const binaryName = await getBinaryName(name)
const binariesDir = path.join(os.homedir(), '.cherrystudio', 'bin')
const binariesDirExists = fs.existsSync(binariesDir)
const binariesDirExists = await fs.existsSync(binariesDir)
return binariesDirExists ? path.join(binariesDir, binaryName) : binaryName
}

View File

@@ -0,0 +1,7 @@
import os from 'os'
export function isWindows7() {
if (process.platform !== 'win32') return false
const version = os.release()
return version.startsWith('6.1') // Windows 7 的版本号为 6.1
}

View File

@@ -1,92 +0,0 @@
import { app } from 'electron'
import macosRelease from 'macos-release'
import os from 'os'
/**
* System information interface
*/
export interface SystemInfo {
platform: NodeJS.Platform
arch: string
osRelease: string
appVersion: string
osString: string
archString: string
}
/**
* Get basic system constants for quick access
* @returns {Object} Basic system constants
*/
export function getSystemConstants() {
return {
platform: process.platform,
arch: process.arch,
osRelease: os.release(),
appVersion: app.getVersion()
}
}
/**
* Get system information
* @returns {SystemInfo} Complete system information object
*/
export function getSystemInfo(): SystemInfo {
const platform = process.platform
const arch = process.arch
const osRelease = os.release()
const appVersion = app.getVersion()
let osString = ''
switch (platform) {
case 'win32': {
// Get Windows version
const parts = osRelease.split('.')
const buildNumber = parseInt(parts[2], 10)
osString = buildNumber >= 22000 ? 'Windows 11' : 'Windows 10'
break
}
case 'darwin': {
// macOS version handling using macos-release for better accuracy
try {
const macVersionInfo = macosRelease()
const versionString = macVersionInfo.version.replace(/\./g, '_') // 15.0.0 -> 15_0_0
osString = arch === 'arm64' ? `Mac OS X ${versionString}` : `Intel Mac OS X ${versionString}` // Mac OS X 15_0_0
} catch (error) {
// Fallback to original logic if macos-release fails
const macVersion = osRelease.split('.').slice(0, 2).join('_')
osString = arch === 'arm64' ? `Mac OS X ${macVersion}` : `Intel Mac OS X ${macVersion}`
}
break
}
case 'linux': {
osString = `Linux ${arch}`
break
}
default: {
osString = `${platform} ${arch}`
}
}
const archString = arch === 'x64' ? 'x86_64' : arch === 'arm64' ? 'arm64' : arch
return {
platform,
arch,
osRelease,
appVersion,
osString,
archString
}
}
/**
* Generate User-Agent string based on user system data
* @returns {string} Dynamically generated User-Agent string
*/
export function generateUserAgent(): string {
const systemInfo = getSystemInfo()
return `Mozilla/5.0 (${systemInfo.osString}; ${systemInfo.archString}) AppleWebKit/537.36 (KHTML, like Gecko) CherryStudio/${systemInfo.appVersion} Chrome/124.0.0.0 Safari/537.36`
}

Binary file not shown.

View File

@@ -1,20 +1,8 @@
import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
import { electronAPI } from '@electron-toolkit/preload'
import { UpgradeChannel } from '@shared/config/constant'
import { IpcChannel } from '@shared/IpcChannel'
import {
FileListResponse,
FileMetadata,
FileUploadResponse,
KnowledgeBaseParams,
KnowledgeItem,
MCPServer,
Provider,
Shortcut,
ThemeMode,
WebDavConfig
} from '@types'
import { contextBridge, ipcRenderer, OpenDialogOptions, shell, webUtils } from 'electron'
import { FileType, KnowledgeBaseParams, KnowledgeItem, MCPServer, Shortcut, ThemeMode, WebDavConfig } from '@types'
import { contextBridge, ipcRenderer, OpenDialogOptions, shell } from 'electron'
import { Notification } from 'src/renderer/src/types/notification'
import { CreateDirectoryOptions } from 'webdav'
@@ -28,35 +16,17 @@ const api = {
checkForUpdate: () => ipcRenderer.invoke(IpcChannel.App_CheckForUpdate),
showUpdateDialog: () => ipcRenderer.invoke(IpcChannel.App_ShowUpdateDialog),
setLanguage: (lang: string) => ipcRenderer.invoke(IpcChannel.App_SetLanguage, lang),
setEnableSpellCheck: (isEnable: boolean) => ipcRenderer.invoke(IpcChannel.App_SetEnableSpellCheck, isEnable),
setSpellCheckLanguages: (languages: string[]) => ipcRenderer.invoke(IpcChannel.App_SetSpellCheckLanguages, languages),
setLaunchOnBoot: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetLaunchOnBoot, isActive),
setLaunchToTray: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetLaunchToTray, isActive),
setTray: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetTray, isActive),
setTrayOnClose: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetTrayOnClose, isActive),
setTestPlan: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetTestPlan, isActive),
setTestChannel: (channel: UpgradeChannel) => ipcRenderer.invoke(IpcChannel.App_SetTestChannel, channel),
setTheme: (theme: ThemeMode) => ipcRenderer.invoke(IpcChannel.App_SetTheme, theme),
handleZoomFactor: (delta: number, reset: boolean = false) =>
ipcRenderer.invoke(IpcChannel.App_HandleZoomFactor, delta, reset),
setAutoUpdate: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetAutoUpdate, isActive),
select: (options: Electron.OpenDialogOptions) => ipcRenderer.invoke(IpcChannel.App_Select, options),
hasWritePermission: (path: string) => ipcRenderer.invoke(IpcChannel.App_HasWritePermission, path),
setAppDataPath: (path: string) => ipcRenderer.invoke(IpcChannel.App_SetAppDataPath, path),
getDataPathFromArgs: () => ipcRenderer.invoke(IpcChannel.App_GetDataPathFromArgs),
copy: (oldPath: string, newPath: string, occupiedDirs: string[] = []) =>
ipcRenderer.invoke(IpcChannel.App_Copy, oldPath, newPath, occupiedDirs),
setStopQuitApp: (stop: boolean, reason: string) => ipcRenderer.invoke(IpcChannel.App_SetStopQuitApp, stop, reason),
flushAppData: () => ipcRenderer.invoke(IpcChannel.App_FlushAppData),
isNotEmptyDir: (path: string) => ipcRenderer.invoke(IpcChannel.App_IsNotEmptyDir, path),
relaunchApp: (options?: Electron.RelaunchOptions) => ipcRenderer.invoke(IpcChannel.App_RelaunchApp, options),
openWebsite: (url: string) => ipcRenderer.invoke(IpcChannel.Open_Website, url),
getCacheSize: () => ipcRenderer.invoke(IpcChannel.App_GetCacheSize),
clearCache: () => ipcRenderer.invoke(IpcChannel.App_ClearCache),
mac: {
isProcessTrusted: (): Promise<boolean> => ipcRenderer.invoke(IpcChannel.App_MacIsProcessTrusted),
requestProcessTrust: (): Promise<boolean> => ipcRenderer.invoke(IpcChannel.App_MacRequestProcessTrust)
},
notification: {
send: (notification: Notification) => ipcRenderer.invoke(IpcChannel.Notification_Send, notification)
},
@@ -90,25 +60,13 @@ const api = {
},
file: {
select: (options?: OpenDialogOptions) => ipcRenderer.invoke(IpcChannel.File_Select, options),
upload: (file: FileMetadata) => ipcRenderer.invoke(IpcChannel.File_Upload, file),
upload: (file: FileType) => 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),
clear: () => ipcRenderer.invoke(IpcChannel.File_Clear),
get: (filePath: string) => ipcRenderer.invoke(IpcChannel.File_Get, filePath),
/**
* 创建一个空的临时文件
* @param fileName 文件名
* @returns 临时文件路径
*/
createTempFile: (fileName: string): Promise<string> => ipcRenderer.invoke(IpcChannel.File_CreateTempFile, fileName),
/**
* 写入文件
* @param filePath 文件路径
* @param data 数据
*/
create: (fileName: string) => ipcRenderer.invoke(IpcChannel.File_Create, fileName),
write: (filePath: string, data: Uint8Array | string) => ipcRenderer.invoke(IpcChannel.File_Write, filePath, data),
writeWithId: (id: string, content: string) => ipcRenderer.invoke(IpcChannel.File_WriteWithId, id, content),
open: (options?: OpenDialogOptions) => ipcRenderer.invoke(IpcChannel.File_Open, options),
openPath: (path: string) => ipcRenderer.invoke(IpcChannel.File_OpenPath, path),
@@ -116,18 +74,21 @@ const api = {
ipcRenderer.invoke(IpcChannel.File_Save, path, content, options),
selectFolder: () => ipcRenderer.invoke(IpcChannel.File_SelectFolder),
saveImage: (name: string, data: string) => ipcRenderer.invoke(IpcChannel.File_SaveImage, name, data),
binaryImage: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_BinaryImage, fileId),
base64Image: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Base64Image, fileId),
saveBase64Image: (data: string) => ipcRenderer.invoke(IpcChannel.File_SaveBase64Image, data),
download: (url: string, isUseContentType?: boolean) =>
ipcRenderer.invoke(IpcChannel.File_Download, url, isUseContentType),
copy: (fileId: string, destPath: string) => ipcRenderer.invoke(IpcChannel.File_Copy, fileId, destPath),
binaryImage: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_BinaryImage, fileId),
base64File: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Base64File, fileId),
pdfInfo: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_GetPdfInfo, fileId),
getPathForFile: (file: File) => webUtils.getPathForFile(file)
getPathForFile: (file: File) => {
const electronFile = file as File & { path?: string }
return electronFile.path || null
}
// getPathForFile: (file: File) => webUtils.getPathForFile(file)
},
fs: {
read: (pathOrUrl: string, encoding?: BufferEncoding) => ipcRenderer.invoke(IpcChannel.Fs_Read, pathOrUrl, encoding)
read: (path: string) => ipcRenderer.invoke(IpcChannel.Fs_Read, path)
},
export: {
toWord: (markdown: string, fileName: string) => ipcRenderer.invoke(IpcChannel.Export_Word, markdown, fileName)
@@ -143,45 +104,31 @@ const api = {
add: ({
base,
item,
userId,
forceReload = false
}: {
base: KnowledgeBaseParams
item: KnowledgeItem
userId?: string
forceReload?: boolean
}) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Add, { base, item, forceReload, userId }),
}) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Add, { base, item, forceReload }),
remove: ({ uniqueId, uniqueIds, base }: { uniqueId: string; uniqueIds: string[]; base: KnowledgeBaseParams }) =>
ipcRenderer.invoke(IpcChannel.KnowledgeBase_Remove, { uniqueId, uniqueIds, base }),
search: ({ search, base }: { search: string; base: KnowledgeBaseParams }) =>
ipcRenderer.invoke(IpcChannel.KnowledgeBase_Search, { search, base }),
rerank: ({ search, base, results }: { search: string; base: KnowledgeBaseParams; results: ExtractChunkData[] }) =>
ipcRenderer.invoke(IpcChannel.KnowledgeBase_Rerank, { search, base, results }),
checkQuota: ({ base, userId }: { base: KnowledgeBaseParams; userId: string }) =>
ipcRenderer.invoke(IpcChannel.KnowledgeBase_Check_Quota, base, userId)
ipcRenderer.invoke(IpcChannel.KnowledgeBase_Rerank, { search, base, results })
},
window: {
setMinimumSize: (width: number, height: number) =>
ipcRenderer.invoke(IpcChannel.Windows_SetMinimumSize, width, height),
resetMinimumSize: () => ipcRenderer.invoke(IpcChannel.Windows_ResetMinimumSize)
},
fileService: {
upload: (provider: Provider, file: FileMetadata): Promise<FileUploadResponse> =>
ipcRenderer.invoke(IpcChannel.FileService_Upload, provider, file),
list: (provider: Provider): Promise<FileListResponse> => ipcRenderer.invoke(IpcChannel.FileService_List, provider),
delete: (provider: Provider, fileId: string) => ipcRenderer.invoke(IpcChannel.FileService_Delete, provider, fileId),
retrieve: (provider: Provider, fileId: string): Promise<FileUploadResponse> =>
ipcRenderer.invoke(IpcChannel.FileService_Retrieve, provider, fileId)
},
selectionMenu: {
action: (action: string) => ipcRenderer.invoke('selection-menu:action', action)
},
vertexAI: {
getAuthHeaders: (params: { projectId: string; serviceAccount?: { privateKey: string; clientEmail: string } }) =>
ipcRenderer.invoke(IpcChannel.VertexAI_GetAuthHeaders, params),
clearAuthCache: (projectId: string, clientEmail?: string) =>
ipcRenderer.invoke(IpcChannel.VertexAI_ClearAuthCache, projectId, clientEmail)
gemini: {
uploadFile: (file: FileType, { apiKey, baseURL }: { apiKey: string; baseURL: string }) =>
ipcRenderer.invoke(IpcChannel.Gemini_UploadFile, file, { apiKey, baseURL }),
base64File: (file: FileType) => ipcRenderer.invoke(IpcChannel.Gemini_Base64File, file),
retrieveFile: (file: FileType, apiKey: string) => ipcRenderer.invoke(IpcChannel.Gemini_RetrieveFile, file, apiKey),
listFiles: (apiKey: string) => ipcRenderer.invoke(IpcChannel.Gemini_ListFiles, apiKey),
deleteFile: (fileId: string, apiKey: string) => ipcRenderer.invoke(IpcChannel.Gemini_DeleteFile, fileId, apiKey)
},
config: {
set: (key: string, value: any, isNotify: boolean = false) =>
@@ -217,10 +164,6 @@ const api = {
getInstallInfo: () => ipcRenderer.invoke(IpcChannel.Mcp_GetInstallInfo),
checkMcpConnectivity: (server: any) => ipcRenderer.invoke(IpcChannel.Mcp_CheckConnectivity, server)
},
python: {
execute: (script: string, context?: Record<string, any>, timeout?: number) =>
ipcRenderer.invoke(IpcChannel.Python_Execute, script, context, timeout)
},
shell: {
openExternal: (url: string, options?: Electron.OpenExternalOptions) => shell.openExternal(url, options)
},
@@ -263,9 +206,7 @@ const api = {
},
webview: {
setOpenLinkExternal: (webviewId: number, isExternal: boolean) =>
ipcRenderer.invoke(IpcChannel.Webview_SetOpenLinkExternal, webviewId, isExternal),
setSpellCheckEnabled: (webviewId: number, isEnable: boolean) =>
ipcRenderer.invoke(IpcChannel.Webview_SetSpellCheckEnabled, webviewId, isEnable)
ipcRenderer.invoke(IpcChannel.Webview_SetOpenLinkExternal, webviewId, isExternal)
},
storeSync: {
subscribe: () => ipcRenderer.invoke(IpcChannel.StoreSync_Subscribe),
@@ -289,8 +230,7 @@ const api = {
closeActionWindow: () => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowClose),
minimizeActionWindow: () => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowMinimize),
pinActionWindow: (isPinned: boolean) => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowPin, isPinned)
},
quoteToMainWindow: (text: string) => ipcRenderer.invoke(IpcChannel.App_QuoteToMain, text)
}
}
// Use `contextBridge` APIs to expose Electron APIs to

View File

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

View File

@@ -1,223 +0,0 @@
# Cherry Studio AI Provider 技术架构文档 (新方案)
## 1. 核心设计理念与目标
本架构旨在重构 Cherry Studio 的 AI Provider现称为 `aiCore`)层,以实现以下目标:
- **职责清晰**:明确划分各组件的职责,降低耦合度。
- **高度复用**:最大化业务逻辑和通用处理逻辑的复用,减少重复代码。
- **易于扩展**:方便快捷地接入新的 AI Provider (LLM供应商) 和添加新的 AI 功能 (如翻译、摘要、图像生成等)。
- **易于维护**:简化单个组件的复杂性,提高代码的可读性和可维护性。
- **标准化**:统一内部数据流和接口,简化不同 Provider 之间的差异处理。
核心思路是将纯粹的 **SDK 适配层 (`XxxApiClient`)**、**通用逻辑处理与智能解析层 (中间件)** 以及 **统一业务功能入口层 (`AiCoreService`)** 清晰地分离开来。
## 2. 核心组件详解
### 2.1. `aiCore` (原 `AiProvider` 文件夹)
这是整个 AI 功能的核心模块。
#### 2.1.1. `XxxApiClient` (例如 `aiCore/clients/openai/OpenAIApiClient.ts`)
- **职责**:作为特定 AI Provider SDK 的纯粹适配层。
- **参数适配**:将应用内部统一的 `CoreRequest` 对象 (见下文) 转换为特定 SDK 所需的请求参数格式。
- **基础响应转换**:将 SDK 返回的原始数据块 (`RawSdkChunk`,例如 `OpenAI.Chat.Completions.ChatCompletionChunk`) 转换为一组最基础、最直接的应用层 `Chunk` 对象 (定义于 `src/renderer/src/types/chunk.ts`)。
- 例如SDK 的 `delta.content` -> `TextDeltaChunk`SDK 的 `delta.reasoning_content` -> `ThinkingDeltaChunk`SDK 的 `delta.tool_calls` -> `RawToolCallChunk` (包含原始工具调用数据)。
- **关键**`XxxApiClient` **不处理**耦合在文本内容中的复杂结构,如 `<think>``<tool_use>` 标签。
- **特点**:极度轻量化,代码量少,易于实现和维护新的 Provider 适配。
#### 2.1.2. `ApiClient.ts` (或 `BaseApiClient.ts` 的核心接口)
- 定义了所有 `XxxApiClient` 必须实现的接口,如:
- `getSdkInstance(): Promise<TSdkInstance> | TSdkInstance`
- `getRequestTransformer(): RequestTransformer<TSdkParams>`
- `getResponseChunkTransformer(): ResponseChunkTransformer<TRawChunk, TResponseContext>`
- 其他可选的、与特定 Provider 相关的辅助方法 (如工具调用转换)。
#### 2.1.3. `ApiClientFactory.ts`
- 根据 Provider 配置动态创建和返回相应的 `XxxApiClient` 实例。
#### 2.1.4. `AiCoreService.ts` (`aiCore/index.ts`)
- **职责**:作为所有 AI 相关业务功能的统一入口。
- 提供面向应用的高层接口,例如:
- `executeCompletions(params: CompletionsParams): Promise<AggregatedCompletionsResult>`
- `translateText(params: TranslateParams): Promise<AggregatedTranslateResult>`
- `summarizeText(params: SummarizeParams): Promise<AggregatedSummarizeResult>`
- 未来可能的 `generateImage(prompt: string): Promise<ImageResult>` 等。
- **返回 `Promise`**:每个服务方法返回一个 `Promise`,该 `Promise` 会在整个(可能是流式的)操作完成后,以包含所有聚合结果(如完整文本、工具调用详情、最终的`usage`/`metrics`等)的对象来 `resolve`
- **支持流式回调**:服务方法的参数 (如 `CompletionsParams`) 依然包含 `onChunk` 回调,用于向调用方实时推送处理过程中的 `Chunk` 数据实现流式UI更新。
- **封装特定任务的提示工程 (Prompt Engineering)**
- 例如,`translateText` 方法内部会构建一个包含特定翻译指令的 `CoreRequest`
- **编排和调用中间件链**:通过内部的 `MiddlewareBuilder` (参见 `middleware/BUILDER_USAGE.md`) 实例,根据调用的业务方法和参数,动态构建和组织合适的中间件序列,然后通过 `applyCompletionsMiddlewares` 等组合函数执行。
- 获取 `ApiClient` 实例并将其注入到中间件上游的 `Context` 中。
- **将 `Promise``resolve``reject` 函数传递给中间件链** (通过 `Context`),以便 `FinalChunkConsumerAndNotifierMiddleware` 可以在操作完成或发生错误时结束该 `Promise`
- **优势**
- 业务逻辑(如翻译、摘要的提示构建和流程控制)只需实现一次,即可支持所有通过 `ApiClient` 接入的底层 Provider。
- **支持外部编排**:调用方可以 `await` 服务方法以获取最终聚合结果,然后将此结果作为后续操作的输入,轻松实现多步骤工作流。
- **支持内部组合**:服务自身也可以通过 `await` 调用其他原子服务方法来构建更复杂的组合功能。
#### 2.1.5. `coreRequestTypes.ts` (或 `types.ts`)
- 定义核心的、Provider 无关的内部请求结构,例如:
- `CoreCompletionsRequest`: 包含标准化后的消息列表、模型配置、工具列表、最大Token数、是否流式输出等。
- `CoreTranslateRequest`, `CoreSummarizeRequest` 等 (如果与 `CoreCompletionsRequest` 结构差异较大,否则可复用并添加任务类型标记)。
### 2.2. `middleware`
中间件层负责处理请求和响应流中的通用逻辑和特定特性。其设计和使用遵循 `middleware/BUILDER_USAGE.md` 中定义的规范。
**核心组件包括:**
- **`MiddlewareBuilder`**: 一个通用的、提供流式API的类用于动态构建中间件链。它支持从基础链开始根据条件添加、插入、替换或移除中间件。
- **`applyCompletionsMiddlewares`**: 负责接收 `MiddlewareBuilder` 构建的链并按顺序执行,专门用于 Completions 流程。
- **`MiddlewareRegistry`**: 集中管理所有可用中间件的注册表,提供统一的中间件访问接口。
- **各种独立的中间件模块** (存放于 `common/`, `core/`, `feat/` 子目录)。
#### 2.2.1. `middlewareTypes.ts`
- 定义中间件的核心类型,如 `AiProviderMiddlewareContext` (扩展后包含 `_apiClientInstance``_coreRequest`)、`MiddlewareAPI``CompletionsMiddleware` 等。
#### 2.2.2. 核心中间件 (`middleware/core/`)
- **`TransformCoreToSdkParamsMiddleware.ts`**: 调用 `ApiClient.getRequestTransformer()``CoreRequest` 转换为特定 SDK 的参数,并存入上下文。
- **`RequestExecutionMiddleware.ts`**: 调用 `ApiClient.getSdkInstance()` 获取 SDK 实例,并使用转换后的参数执行实际的 API 调用,返回原始 SDK 流。
- **`StreamAdapterMiddleware.ts`**: 将各种形态的原始 SDK 流 (如异步迭代器) 统一适配为 `ReadableStream<RawSdkChunk>`
- **`RawSdkChunk`**指特定AI提供商SDK在流式响应中返回的、未经应用层统一处理的原始数据块格式 (例如 OpenAI 的 `ChatCompletionChunk`Gemini 的 `GenerateContentResponse` 中的部分等)。
- **`RawSdkChunkToAppChunkMiddleware.ts`**: (新增) 消费 `ReadableStream<RawSdkChunk>`,在其内部对每个 `RawSdkChunk` 调用 `ApiClient.getResponseChunkTransformer()`,将其转换为一个或多个基础的应用层 `Chunk` 对象,并输出 `ReadableStream<Chunk>`
#### 2.2.3. 特性中间件 (`middleware/feat/`)
这些中间件消费由 `ResponseTransformMiddleware` 输出的、相对标准化的 `Chunk` 流,并处理更复杂的逻辑。
- **`ThinkingTagExtractionMiddleware.ts`**: 检查 `TextDeltaChunk`,解析其中可能包含的 `<think>...</think>` 文本内嵌标签,生成 `ThinkingDeltaChunk``ThinkingCompleteChunk`
- **`ToolUseExtractionMiddleware.ts`**: 检查 `TextDeltaChunk`,解析其中可能包含的 `<tool_use>...</tool_use>` 文本内嵌标签,生成工具调用相关的 Chunk。如果 `ApiClient` 输出了原生工具调用数据,此中间件也负责将其转换为标准格式。
#### 2.2.4. 核心处理中间件 (`middleware/core/`)
- **`TransformCoreToSdkParamsMiddleware.ts`**: 调用 `ApiClient.getRequestTransformer()``CoreRequest` 转换为特定 SDK 的参数,并存入上下文。
- **`SdkCallMiddleware.ts`**: 调用 `ApiClient.getSdkInstance()` 获取 SDK 实例,并使用转换后的参数执行实际的 API 调用,返回原始 SDK 流。
- **`StreamAdapterMiddleware.ts`**: 将各种形态的原始 SDK 流统一适配为标准流格式。
- **`ResponseTransformMiddleware.ts`**: 将原始 SDK 响应转换为应用层标准 `Chunk` 对象。
- **`TextChunkMiddleware.ts`**: 处理文本相关的 Chunk 流。
- **`ThinkChunkMiddleware.ts`**: 处理思考相关的 Chunk 流。
- **`McpToolChunkMiddleware.ts`**: 处理工具调用相关的 Chunk 流。
- **`WebSearchMiddleware.ts`**: 处理 Web 搜索相关逻辑。
#### 2.2.5. 通用中间件 (`middleware/common/`)
- **`LoggingMiddleware.ts`**: 请求和响应日志。
- **`AbortHandlerMiddleware.ts`**: 处理请求中止。
- **`FinalChunkConsumerMiddleware.ts`**: 消费最终的 `Chunk` 流,通过 `context.onChunk` 回调通知应用层实时数据。
- **累积数据**:在流式处理过程中,累积关键数据,如文本片段、工具调用信息、`usage`/`metrics` 等。
- **结束 `Promise`**:当输入流结束时,使用累积的聚合结果来完成整个处理流程。
- 在流结束时,发送包含最终累加信息的完成信号。
### 2.3. `types/chunk.ts`
- 定义应用全局统一的 `Chunk` 类型及其所有变体。这包括基础类型 (如 `TextDeltaChunk`, `ThinkingDeltaChunk`)、SDK原生数据传递类型 (如 `RawToolCallChunk`, `RawFinishChunk` - 作为 `ApiClient` 转换的中间产物),以及功能性类型 (如 `McpToolCallRequestChunk`, `WebSearchCompleteChunk`)。
## 3. 核心执行流程 (以 `AiCoreService.executeCompletions` 为例)
```markdown
**应用层 (例如 UI 组件)**
||
\\/
**`AiProvider.completions` (`aiCore/index.ts`)**
(1. prepare ApiClient instance. 2. use `CompletionsMiddlewareBuilder.withDefaults()` to build middleware chain. 3. call `applyCompletionsMiddlewares`)
||
\\/
**`applyCompletionsMiddlewares` (`middleware/composer.ts`)**
(接收构建好的链、ApiClient实例、原始SDK方法开始按序执行中间件)
||
\\/
**[ 预处理阶段中间件 ]**
(例如: `FinalChunkConsumerMiddleware`, `TransformCoreToSdkParamsMiddleware`, `AbortHandlerMiddleware`)
|| (Context 中准备好 SDK 请求参数)
\\/
**[ 处理阶段中间件 ]**
(例如: `McpToolChunkMiddleware`, `WebSearchMiddleware`, `TextChunkMiddleware`, `ThinkingTagExtractionMiddleware`)
|| (处理各种特性和Chunk类型)
\\/
**[ SDK调用阶段中间件 ]**
(例如: `ResponseTransformMiddleware`, `StreamAdapterMiddleware`, `SdkCallMiddleware`)
|| (输出: 标准化的应用层Chunk流)
\\/
**`FinalChunkConsumerMiddleware` (核心)**
(消费最终的 `Chunk` 流, 通过 `context.onChunk` 回调通知应用层, 并在流结束时完成处理)
||
\\/
**`AiProvider.completions` 返回 `Promise<CompletionsResult>`**
```
## 4. 建议的文件/目录结构
```
src/renderer/src/
└── aiCore/
├── clients/
│ ├── openai/
│ ├── gemini/
│ ├── anthropic/
│ ├── BaseApiClient.ts
│ ├── ApiClientFactory.ts
│ ├── AihubmixAPIClient.ts
│ ├── index.ts
│ └── types.ts
├── middleware/
│ ├── common/
│ ├── core/
│ ├── feat/
│ ├── builder.ts
│ ├── composer.ts
│ ├── index.ts
│ ├── register.ts
│ ├── schemas.ts
│ ├── types.ts
│ └── utils.ts
├── types/
│ ├── chunk.ts
│ └── ...
└── index.ts
```
## 5. 迁移和实施建议
- **小步快跑,逐步迭代**:优先完成核心流程的重构(例如 `completions`),再逐步迁移其他功能(`translate` 等)和其他 Provider。
- **优先定义核心类型**`CoreRequest`, `Chunk`, `ApiClient` 接口是整个架构的基石。
- **为 `ApiClient` 瘦身**:将现有 `XxxProvider` 中的复杂逻辑剥离到新的中间件或 `AiCoreService` 中。
- **强化中间件**:让中间件承担起更多解析和特性处理的责任。
- **编写单元测试和集成测试**:确保每个组件和整体流程的正确性。
此架构旨在提供一个更健壮、更灵活、更易于维护的 AI 功能核心,支撑 Cherry Studio 未来的发展。
## 6. 迁移策略与实施建议
本节内容提炼自早期的 `migrate.md` 文档,并根据最新的架构讨论进行了调整。
**目标架构核心组件回顾:**
与第 2 节描述的核心组件一致,主要包括 `XxxApiClient`, `AiCoreService`, 中间件链, `CoreRequest` 类型, 和标准化的 `Chunk` 类型。
**迁移步骤:**
**Phase 0: 准备工作和类型定义**
1. **定义核心数据结构 (TypeScript 类型)**
- `CoreCompletionsRequest` (Type):定义应用内部统一的对话请求结构。
- `Chunk` (Type - 检查并按需扩展现有 `src/renderer/src/types/chunk.ts`)定义所有可能的通用Chunk类型。
- 为其他API翻译、总结定义类似的 `CoreXxxRequest` (Type)。
2. **定义 `ApiClient` 接口:** 明确 `getRequestTransformer`, `getResponseChunkTransformer`, `getSdkInstance` 等核心方法。
3. **调整 `AiProviderMiddlewareContext`**
- 确保包含 `_apiClientInstance: ApiClient<any,any,any>`
- 确保包含 `_coreRequest: CoreRequestType`
- 考虑添加 `resolvePromise: (value: AggregatedResultType) => void``rejectPromise: (reason?: any) => void` 用于 `AiCoreService` 的 Promise 返回。
**Phase 1: 实现第一个 `ApiClient` (以 `OpenAIApiClient` 为例)**
1. **创建 `OpenAIApiClient` 类:** 实现 `ApiClient` 接口。
2. **迁移SDK实例和配置。**
3. **实现 `getRequestTransformer()`**`CoreCompletionsRequest` 转换为 OpenAI SDK 参数。
4. **实现 `getResponseChunkTransformer()`**`OpenAI.Chat.Completions.ChatCompletionChunk` 转换为基础的 `

View File

@@ -1,223 +0,0 @@
import { isOpenAILLMModel } from '@renderer/config/models'
import {
GenerateImageParams,
MCPCallToolResponse,
MCPTool,
MCPToolResponse,
Model,
Provider,
ToolCallResponse
} from '@renderer/types'
import {
RequestOptions,
SdkInstance,
SdkMessageParam,
SdkModel,
SdkParams,
SdkRawChunk,
SdkRawOutput,
SdkTool,
SdkToolCall
} from '@renderer/types/sdk'
import { CompletionsContext } from '../middleware/types'
import { AnthropicAPIClient } from './anthropic/AnthropicAPIClient'
import { BaseApiClient } from './BaseApiClient'
import { GeminiAPIClient } from './gemini/GeminiAPIClient'
import { OpenAIAPIClient } from './openai/OpenAIApiClient'
import { OpenAIResponseAPIClient } from './openai/OpenAIResponseAPIClient'
import { RequestTransformer, ResponseChunkTransformer } from './types'
/**
* AihubmixAPIClient - 根据模型类型自动选择合适的ApiClient
* 使用装饰器模式实现在ApiClient层面进行模型路由
*/
export class AihubmixAPIClient extends BaseApiClient {
// 使用联合类型而不是any保持类型安全
private clients: Map<string, AnthropicAPIClient | GeminiAPIClient | OpenAIResponseAPIClient | OpenAIAPIClient> =
new Map()
private defaultClient: OpenAIAPIClient
private currentClient: BaseApiClient
constructor(provider: Provider) {
super(provider)
const providerExtraHeaders = {
...provider,
extra_headers: {
...provider.extra_headers,
'APP-Code': 'MLTG2087'
}
}
// 初始化各个client - 现在有类型安全
const claudeClient = new AnthropicAPIClient(providerExtraHeaders)
const geminiClient = new GeminiAPIClient({ ...providerExtraHeaders, apiHost: 'https://aihubmix.com/gemini' })
const openaiClient = new OpenAIResponseAPIClient(providerExtraHeaders)
const defaultClient = new OpenAIAPIClient(providerExtraHeaders)
this.clients.set('claude', claudeClient)
this.clients.set('gemini', geminiClient)
this.clients.set('openai', openaiClient)
this.clients.set('default', defaultClient)
// 设置默认client
this.defaultClient = defaultClient
this.currentClient = this.defaultClient as BaseApiClient
}
override getBaseURL(): string {
if (!this.currentClient) {
return this.provider.apiHost
}
return this.currentClient.getBaseURL()
}
/**
* 类型守卫确保client是BaseApiClient的实例
*/
private isValidClient(client: unknown): client is BaseApiClient {
return (
client !== null &&
client !== undefined &&
typeof client === 'object' &&
'createCompletions' in client &&
'getRequestTransformer' in client &&
'getResponseChunkTransformer' in client
)
}
/**
* 根据模型获取合适的client
*/
private getClient(model: Model): BaseApiClient {
const id = model.id.toLowerCase()
// claude开头
if (id.startsWith('claude')) {
const client = this.clients.get('claude')
if (!client || !this.isValidClient(client)) {
throw new Error('Claude client not properly initialized')
}
return client
}
// gemini开头 且不以-nothink、-search结尾
if ((id.startsWith('gemini') || id.startsWith('imagen')) && !id.endsWith('-nothink') && !id.endsWith('-search')) {
const client = this.clients.get('gemini')
if (!client || !this.isValidClient(client)) {
throw new Error('Gemini client not properly initialized')
}
return client
}
// OpenAI系列模型
if (isOpenAILLMModel(model)) {
const client = this.clients.get('openai')
if (!client || !this.isValidClient(client)) {
throw new Error('OpenAI client not properly initialized')
}
return client
}
return this.defaultClient as BaseApiClient
}
/**
* 根据模型选择合适的client并委托调用
*/
public getClientForModel(model: Model): BaseApiClient {
this.currentClient = this.getClient(model)
return this.currentClient
}
// ============ BaseApiClient 抽象方法实现 ============
async createCompletions(payload: SdkParams, options?: RequestOptions): Promise<SdkRawOutput> {
// 尝试从payload中提取模型信息来选择client
const modelId = this.extractModelFromPayload(payload)
if (modelId) {
const modelObj = { id: modelId } as Model
const targetClient = this.getClient(modelObj)
return targetClient.createCompletions(payload, options)
}
// 如果无法从payload中提取模型使用当前设置的client
return this.currentClient.createCompletions(payload, options)
}
/**
* 从SDK payload中提取模型ID
*/
private extractModelFromPayload(payload: SdkParams): string | null {
// 不同的SDK可能有不同的字段名
if ('model' in payload && typeof payload.model === 'string') {
return payload.model
}
return null
}
async generateImage(params: GenerateImageParams): Promise<string[]> {
return this.currentClient.generateImage(params)
}
async getEmbeddingDimensions(model?: Model): Promise<number> {
const client = model ? this.getClient(model) : this.currentClient
return client.getEmbeddingDimensions(model)
}
async listModels(): Promise<SdkModel[]> {
// 可以聚合所有client的模型或者使用默认client
return this.defaultClient.listModels()
}
async getSdkInstance(): Promise<SdkInstance> {
return this.currentClient.getSdkInstance()
}
getRequestTransformer(): RequestTransformer<SdkParams, SdkMessageParam> {
return this.currentClient.getRequestTransformer()
}
getResponseChunkTransformer(ctx: CompletionsContext): ResponseChunkTransformer<SdkRawChunk> {
return this.currentClient.getResponseChunkTransformer(ctx)
}
convertMcpToolsToSdkTools(mcpTools: MCPTool[]): SdkTool[] {
return this.currentClient.convertMcpToolsToSdkTools(mcpTools)
}
convertSdkToolCallToMcp(toolCall: SdkToolCall, mcpTools: MCPTool[]): MCPTool | undefined {
return this.currentClient.convertSdkToolCallToMcp(toolCall, mcpTools)
}
convertSdkToolCallToMcpToolResponse(toolCall: SdkToolCall, mcpTool: MCPTool): ToolCallResponse {
return this.currentClient.convertSdkToolCallToMcpToolResponse(toolCall, mcpTool)
}
buildSdkMessages(
currentReqMessages: SdkMessageParam[],
output: SdkRawOutput | string,
toolResults: SdkMessageParam[],
toolCalls?: SdkToolCall[]
): SdkMessageParam[] {
return this.currentClient.buildSdkMessages(currentReqMessages, output, toolResults, toolCalls)
}
convertMcpToolResponseToSdkMessageParam(
mcpToolResponse: MCPToolResponse,
resp: MCPCallToolResponse,
model: Model
): SdkMessageParam | undefined {
const client = this.getClient(model)
return client.convertMcpToolResponseToSdkMessageParam(mcpToolResponse, resp, model)
}
extractMessagesFromSdkPayload(sdkPayload: SdkParams): SdkMessageParam[] {
return this.currentClient.extractMessagesFromSdkPayload(sdkPayload)
}
estimateMessageTokens(message: SdkMessageParam): number {
return this.currentClient.estimateMessageTokens(message)
}
}

View File

@@ -1,72 +0,0 @@
import { Provider } from '@renderer/types'
import { AihubmixAPIClient } from './AihubmixAPIClient'
import { AnthropicAPIClient } from './anthropic/AnthropicAPIClient'
import { BaseApiClient } from './BaseApiClient'
import { GeminiAPIClient } from './gemini/GeminiAPIClient'
import { VertexAPIClient } from './gemini/VertexAPIClient'
import { OpenAIAPIClient } from './openai/OpenAIApiClient'
import { OpenAIResponseAPIClient } from './openai/OpenAIResponseAPIClient'
import { PPIOAPIClient } from './ppio/PPIOAPIClient'
/**
* Factory for creating ApiClient instances based on provider configuration
* 根据提供者配置创建ApiClient实例的工厂
*/
export class ApiClientFactory {
/**
* Create an ApiClient instance for the given provider
* 为给定的提供者创建ApiClient实例
*/
static create(provider: Provider): BaseApiClient {
console.log(`[ApiClientFactory] Creating ApiClient for provider:`, {
id: provider.id,
type: provider.type
})
let instance: BaseApiClient
// 首先检查特殊的provider id
if (provider.id === 'aihubmix') {
console.log(`[ApiClientFactory] Creating AihubmixAPIClient for provider: ${provider.id}`)
instance = new AihubmixAPIClient(provider) as BaseApiClient
return instance
}
if (provider.id === 'ppio') {
console.log(`[ApiClientFactory] Creating PPIOAPIClient for provider: ${provider.id}`)
instance = new PPIOAPIClient(provider) as BaseApiClient
return instance
}
// 然后检查标准的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 'openai-response':
instance = new OpenAIResponseAPIClient(provider) as BaseApiClient
break
case 'gemini':
instance = new GeminiAPIClient(provider) as BaseApiClient
break
case 'vertexai':
instance = new VertexAPIClient(provider) as BaseApiClient
break
case 'anthropic':
instance = new AnthropicAPIClient(provider) as BaseApiClient
break
default:
console.log(`[ApiClientFactory] Using default OpenAIApiClient for provider: ${provider.id}`)
instance = new OpenAIAPIClient(provider) as BaseApiClient
break
}
return instance
}
}
export function isOpenAIProvider(provider: Provider) {
return !['anthropic', 'gemini'].includes(provider.type)
}

View File

@@ -1,736 +0,0 @@
import Anthropic from '@anthropic-ai/sdk'
import {
Base64ImageSource,
ImageBlockParam,
MessageParam,
TextBlockParam,
ToolResultBlockParam,
ToolUseBlock,
WebSearchTool20250305
} from '@anthropic-ai/sdk/resources'
import {
ContentBlock,
ContentBlockParam,
MessageCreateParams,
MessageCreateParamsBase,
RedactedThinkingBlockParam,
ServerToolUseBlockParam,
ThinkingBlockParam,
ThinkingConfigParam,
ToolUnion,
ToolUseBlockParam,
WebSearchResultBlock,
WebSearchToolResultBlockParam,
WebSearchToolResultError
} from '@anthropic-ai/sdk/resources/messages'
import { MessageStream } from '@anthropic-ai/sdk/resources/messages/messages'
import { GenericChunk } from '@renderer/aiCore/middleware/schemas'
import { DEFAULT_MAX_TOKENS } from '@renderer/config/constant'
import Logger from '@renderer/config/logger'
import { findTokenLimit, isClaudeReasoningModel, isReasoningModel, isWebSearchModel } from '@renderer/config/models'
import { getAssistantSettings } from '@renderer/services/AssistantService'
import FileManager from '@renderer/services/FileManager'
import { estimateTextTokens } from '@renderer/services/TokenService'
import {
Assistant,
EFFORT_RATIO,
FileTypes,
MCPCallToolResponse,
MCPTool,
MCPToolResponse,
Model,
Provider,
ToolCallResponse,
WebSearchSource
} from '@renderer/types'
import {
ChunkType,
ErrorChunk,
LLMWebSearchCompleteChunk,
LLMWebSearchInProgressChunk,
MCPToolCreatedChunk,
TextDeltaChunk,
ThinkingDeltaChunk
} from '@renderer/types/chunk'
import { type Message } from '@renderer/types/newMessage'
import {
AnthropicSdkMessageParam,
AnthropicSdkParams,
AnthropicSdkRawChunk,
AnthropicSdkRawOutput
} from '@renderer/types/sdk'
import { addImageFileToContents } from '@renderer/utils/formats'
import {
anthropicToolUseToMcpTool,
isEnabledToolUse,
mcpToolCallResponseToAnthropicMessage,
mcpToolsToAnthropicTools
} from '@renderer/utils/mcp-tools'
import { findFileBlocks, findImageBlocks } from '@renderer/utils/messageUtils/find'
import { buildSystemPrompt } from '@renderer/utils/prompt'
import { BaseApiClient } from '../BaseApiClient'
import { AnthropicStreamListener, RawStreamListener, RequestTransformer, ResponseChunkTransformer } from '../types'
export class AnthropicAPIClient extends BaseApiClient<
Anthropic,
AnthropicSdkParams,
AnthropicSdkRawOutput,
AnthropicSdkRawChunk,
AnthropicSdkMessageParam,
ToolUseBlock,
ToolUnion
> {
constructor(provider: Provider) {
super(provider)
}
async getSdkInstance(): Promise<Anthropic> {
if (this.sdkInstance) {
return this.sdkInstance
}
this.sdkInstance = new Anthropic({
apiKey: this.apiKey,
baseURL: this.getBaseURL(),
dangerouslyAllowBrowser: true,
defaultHeaders: {
'anthropic-beta': 'output-128k-2025-02-19',
...this.provider.extra_headers
}
})
return this.sdkInstance
}
override async createCompletions(
payload: AnthropicSdkParams,
options?: Anthropic.RequestOptions
): Promise<AnthropicSdkRawOutput> {
const sdk = await this.getSdkInstance()
if (payload.stream) {
return sdk.messages.stream(payload, options)
}
return await sdk.messages.create(payload, options)
}
// @ts-ignore sdk未提供
// eslint-disable-next-line @typescript-eslint/no-unused-vars
override async generateImage(generateImageParams: GenerateImageParams): Promise<string[]> {
return []
}
override async listModels(): Promise<Anthropic.ModelInfo[]> {
const sdk = await this.getSdkInstance()
const response = await sdk.models.list()
return response.data
}
// @ts-ignore sdk未提供
override async getEmbeddingDimensions(): Promise<number> {
throw new Error("Anthropic SDK doesn't support getEmbeddingDimensions method.")
}
override getTemperature(assistant: Assistant, model: Model): number | undefined {
if (assistant.settings?.reasoning_effort && isClaudeReasoningModel(model)) {
return undefined
}
return assistant.settings?.temperature
}
override getTopP(assistant: Assistant, model: Model): number | undefined {
if (assistant.settings?.reasoning_effort && isClaudeReasoningModel(model)) {
return undefined
}
return assistant.settings?.topP
}
/**
* Get the reasoning effort
* @param assistant - The assistant
* @param model - The model
* @returns The reasoning effort
*/
private getBudgetToken(assistant: Assistant, model: Model): ThinkingConfigParam | undefined {
if (!isReasoningModel(model)) {
return undefined
}
const { maxTokens } = getAssistantSettings(assistant)
const reasoningEffort = assistant?.settings?.reasoning_effort
if (reasoningEffort === undefined) {
return {
type: 'disabled'
}
}
const effortRatio = EFFORT_RATIO[reasoningEffort]
const budgetTokens = Math.max(
1024,
Math.floor(
Math.min(
(findTokenLimit(model.id)?.max! - findTokenLimit(model.id)?.min!) * effortRatio +
findTokenLimit(model.id)?.min!,
(maxTokens || DEFAULT_MAX_TOKENS) * effortRatio
)
)
)
return {
type: 'enabled',
budget_tokens: budgetTokens
}
}
/**
* Get the message parameter
* @param message - The message
* @param model - The model
* @returns The message parameter
*/
public async convertMessageToSdkParam(message: Message): Promise<AnthropicSdkMessageParam> {
const parts: MessageParam['content'] = [
{
type: 'text',
text: await this.getMessageContent(message)
}
]
// Get and process image blocks
const imageBlocks = findImageBlocks(message)
for (const imageBlock of imageBlocks) {
if (imageBlock.file) {
// Handle uploaded file
const file = imageBlock.file
const base64Data = await window.api.file.base64Image(file.id + file.ext)
parts.push({
type: 'image',
source: {
data: base64Data.base64,
media_type: base64Data.mime.replace('jpg', 'jpeg') as any,
type: 'base64'
}
})
}
}
// Get and process file blocks
const fileBlocks = findFileBlocks(message)
for (const fileBlock of fileBlocks) {
const { file } = fileBlock
if ([FileTypes.TEXT, FileTypes.DOCUMENT].includes(file.type)) {
if (file.ext === '.pdf' && file.size < 32 * 1024 * 1024) {
const base64Data = await FileManager.readBase64File(file)
parts.push({
type: 'document',
source: {
type: 'base64',
media_type: 'application/pdf',
data: base64Data
}
})
} else {
const fileContent = await (await window.api.file.read(file.id + file.ext)).trim()
parts.push({
type: 'text',
text: file.origin_name + '\n' + fileContent
})
}
}
}
return {
role: message.role === 'system' ? 'user' : message.role,
content: parts
}
}
public convertMcpToolsToSdkTools(mcpTools: MCPTool[]): ToolUnion[] {
return mcpToolsToAnthropicTools(mcpTools)
}
public convertMcpToolResponseToSdkMessageParam(
mcpToolResponse: MCPToolResponse,
resp: MCPCallToolResponse,
model: Model
): AnthropicSdkMessageParam | undefined {
if ('toolUseId' in mcpToolResponse && mcpToolResponse.toolUseId) {
return mcpToolCallResponseToAnthropicMessage(mcpToolResponse, resp, model)
} else if ('toolCallId' in mcpToolResponse) {
return {
role: 'user',
content: [
{
type: 'tool_result',
tool_use_id: mcpToolResponse.toolCallId!,
content: resp.content
.map((item) => {
if (item.type === 'text') {
return {
type: 'text',
text: item.text || ''
} satisfies TextBlockParam
}
if (item.type === 'image') {
return {
type: 'image',
source: {
data: item.data || '',
media_type: (item.mimeType || 'image/png') as Base64ImageSource['media_type'],
type: 'base64'
}
} satisfies ImageBlockParam
}
return
})
.filter((n) => typeof n !== 'undefined'),
is_error: resp.isError
} satisfies ToolResultBlockParam
]
}
}
return
}
// Implementing abstract methods from BaseApiClient
convertSdkToolCallToMcp(toolCall: ToolUseBlock, mcpTools: MCPTool[]): MCPTool | undefined {
// Based on anthropicToolUseToMcpTool logic in AnthropicProvider
// This might need adjustment based on how tool calls are specifically handled in the new structure
const mcpTool = anthropicToolUseToMcpTool(mcpTools, toolCall)
return mcpTool
}
convertSdkToolCallToMcpToolResponse(toolCall: ToolUseBlock, mcpTool: MCPTool): ToolCallResponse {
return {
id: toolCall.id,
toolCallId: toolCall.id,
tool: mcpTool,
arguments: toolCall.input as Record<string, unknown>,
status: 'pending'
} as ToolCallResponse
}
override buildSdkMessages(
currentReqMessages: AnthropicSdkMessageParam[],
output: Anthropic.Message,
toolResults: AnthropicSdkMessageParam[]
): AnthropicSdkMessageParam[] {
const assistantMessage: AnthropicSdkMessageParam = {
role: output.role,
content: convertContentBlocksToParams(output.content)
}
const newMessages: AnthropicSdkMessageParam[] = [...currentReqMessages, assistantMessage]
if (toolResults && toolResults.length > 0) {
newMessages.push(...toolResults)
}
return newMessages
}
override estimateMessageTokens(message: AnthropicSdkMessageParam): number {
if (typeof message.content === 'string') {
return estimateTextTokens(message.content)
}
return message.content
.map((content) => {
switch (content.type) {
case 'text':
return estimateTextTokens(content.text)
case 'image':
if (content.source.type === 'base64') {
return estimateTextTokens(content.source.data)
} else {
return estimateTextTokens(content.source.url)
}
case 'tool_use':
return estimateTextTokens(JSON.stringify(content.input))
case 'tool_result':
return estimateTextTokens(JSON.stringify(content.content))
default:
return 0
}
})
.reduce((acc, curr) => acc + curr, 0)
}
public buildAssistantMessage(message: Anthropic.Message): AnthropicSdkMessageParam {
const messageParam: AnthropicSdkMessageParam = {
role: message.role,
content: convertContentBlocksToParams(message.content)
}
return messageParam
}
public extractMessagesFromSdkPayload(sdkPayload: AnthropicSdkParams): AnthropicSdkMessageParam[] {
return sdkPayload.messages || []
}
/**
* Anthropic专用的原始流监听器
* 处理MessageStream对象的特定事件
*/
attachRawStreamListener(
rawOutput: AnthropicSdkRawOutput,
listener: RawStreamListener<AnthropicSdkRawChunk>
): AnthropicSdkRawOutput {
console.log(`[AnthropicApiClient] 附加流监听器到原始输出`)
// 专用的Anthropic事件处理
const anthropicListener = listener as AnthropicStreamListener
// 检查是否为MessageStream
if (rawOutput instanceof MessageStream) {
console.log(`[AnthropicApiClient] 检测到 Anthropic MessageStream附加专用监听器`)
if (listener.onStart) {
listener.onStart()
}
if (listener.onChunk) {
rawOutput.on('streamEvent', (event: AnthropicSdkRawChunk) => {
listener.onChunk!(event)
})
}
if (anthropicListener.onContentBlock) {
rawOutput.on('contentBlock', anthropicListener.onContentBlock)
}
if (anthropicListener.onMessage) {
rawOutput.on('finalMessage', anthropicListener.onMessage)
}
if (listener.onEnd) {
rawOutput.on('end', () => {
listener.onEnd!()
})
}
if (listener.onError) {
rawOutput.on('error', (error: Error) => {
listener.onError!(error)
})
}
return rawOutput
}
if (anthropicListener.onMessage) {
anthropicListener.onMessage(rawOutput)
}
// 对于非MessageStream响应
return rawOutput
}
private async getWebSearchParams(model: Model): Promise<WebSearchTool20250305 | undefined> {
if (!isWebSearchModel(model)) {
return undefined
}
return {
type: 'web_search_20250305',
name: 'web_search',
max_uses: 5
} as WebSearchTool20250305
}
getRequestTransformer(): RequestTransformer<AnthropicSdkParams, AnthropicSdkMessageParam> {
return {
transform: async (
coreRequest,
assistant,
model,
isRecursiveCall,
recursiveSdkMessages
): Promise<{
payload: AnthropicSdkParams
messages: AnthropicSdkMessageParam[]
metadata: Record<string, any>
}> => {
const { messages, mcpTools, maxTokens, streamOutput, enableWebSearch } = coreRequest
// 1. 处理系统消息
let systemPrompt = assistant.prompt
// 2. 设置工具
const { tools } = this.setupToolsConfig({
mcpTools: mcpTools,
model,
enableToolUse: isEnabledToolUse(assistant)
})
if (this.useSystemPromptForTools) {
systemPrompt = await buildSystemPrompt(systemPrompt, mcpTools, assistant)
}
const systemMessage: TextBlockParam | undefined = systemPrompt
? { type: 'text', text: systemPrompt }
: undefined
// 3. 处理用户消息
const sdkMessages: AnthropicSdkMessageParam[] = []
if (typeof messages === 'string') {
sdkMessages.push({ role: 'user', content: messages })
} else {
const processedMessages = addImageFileToContents(messages)
for (const message of processedMessages) {
sdkMessages.push(await this.convertMessageToSdkParam(message))
}
}
if (enableWebSearch) {
const webSearchTool = await this.getWebSearchParams(model)
if (webSearchTool) {
tools.push(webSearchTool)
}
}
const commonParams: MessageCreateParamsBase = {
model: model.id,
messages:
isRecursiveCall && recursiveSdkMessages && recursiveSdkMessages.length > 0
? recursiveSdkMessages
: sdkMessages,
max_tokens: maxTokens || DEFAULT_MAX_TOKENS,
temperature: this.getTemperature(assistant, model),
top_p: this.getTopP(assistant, model),
system: systemMessage ? [systemMessage] : undefined,
thinking: this.getBudgetToken(assistant, model),
tools: tools.length > 0 ? tools : undefined,
// 只在对话场景下应用自定义参数,避免影响翻译、总结等其他业务逻辑
...(coreRequest.callType === 'chat' ? this.getCustomParameters(assistant) : {})
}
const finalParams: MessageCreateParams = streamOutput
? {
...commonParams,
stream: true
}
: {
...commonParams,
stream: false
}
const timeout = this.getTimeout(model)
return { payload: finalParams, messages: sdkMessages, metadata: { timeout } }
}
}
}
getResponseChunkTransformer(): ResponseChunkTransformer<AnthropicSdkRawChunk> {
return () => {
let accumulatedJson = ''
const toolCalls: Record<number, ToolUseBlock> = {}
return {
async transform(rawChunk: AnthropicSdkRawChunk, controller: TransformStreamDefaultController<GenericChunk>) {
switch (rawChunk.type) {
case 'message': {
let i = 0
for (const content of rawChunk.content) {
switch (content.type) {
case 'text': {
controller.enqueue({
type: ChunkType.TEXT_DELTA,
text: content.text
} as TextDeltaChunk)
break
}
case 'tool_use': {
toolCalls[i] = content
i++
break
}
case 'thinking': {
controller.enqueue({
type: ChunkType.THINKING_DELTA,
text: content.thinking
} as ThinkingDeltaChunk)
break
}
case 'web_search_tool_result': {
controller.enqueue({
type: ChunkType.LLM_WEB_SEARCH_COMPLETE,
llm_web_search: {
results: content.content,
source: WebSearchSource.ANTHROPIC
}
} as LLMWebSearchCompleteChunk)
break
}
}
}
if (i > 0) {
controller.enqueue({
type: ChunkType.MCP_TOOL_CREATED,
tool_calls: Object.values(toolCalls)
} as MCPToolCreatedChunk)
}
controller.enqueue({
type: ChunkType.LLM_RESPONSE_COMPLETE,
response: {
usage: {
prompt_tokens: rawChunk.usage.input_tokens || 0,
completion_tokens: rawChunk.usage.output_tokens || 0,
total_tokens: (rawChunk.usage.input_tokens || 0) + (rawChunk.usage.output_tokens || 0)
}
}
})
break
}
case 'content_block_start': {
const contentBlock = rawChunk.content_block
switch (contentBlock.type) {
case 'server_tool_use': {
if (contentBlock.name === 'web_search') {
controller.enqueue({
type: ChunkType.LLM_WEB_SEARCH_IN_PROGRESS
} as LLMWebSearchInProgressChunk)
}
break
}
case 'web_search_tool_result': {
if (
contentBlock.content &&
(contentBlock.content as WebSearchToolResultError).type === 'web_search_tool_result_error'
) {
controller.enqueue({
type: ChunkType.ERROR,
error: {
code: (contentBlock.content as WebSearchToolResultError).error_code,
message: (contentBlock.content as WebSearchToolResultError).error_code
}
} as ErrorChunk)
} else {
controller.enqueue({
type: ChunkType.LLM_WEB_SEARCH_COMPLETE,
llm_web_search: {
results: contentBlock.content as Array<WebSearchResultBlock>,
source: WebSearchSource.ANTHROPIC
}
} as LLMWebSearchCompleteChunk)
}
break
}
case 'tool_use': {
toolCalls[rawChunk.index] = contentBlock
break
}
}
break
}
case 'content_block_delta': {
const messageDelta = rawChunk.delta
switch (messageDelta.type) {
case 'text_delta': {
if (messageDelta.text) {
controller.enqueue({
type: ChunkType.TEXT_DELTA,
text: messageDelta.text
} as TextDeltaChunk)
}
break
}
case 'thinking_delta': {
if (messageDelta.thinking) {
controller.enqueue({
type: ChunkType.THINKING_DELTA,
text: messageDelta.thinking
} as ThinkingDeltaChunk)
}
break
}
case 'input_json_delta': {
if (messageDelta.partial_json) {
accumulatedJson += messageDelta.partial_json
}
break
}
}
break
}
case 'content_block_stop': {
const toolCall = toolCalls[rawChunk.index]
if (toolCall) {
try {
toolCall.input = JSON.parse(accumulatedJson)
Logger.debug(`Tool call id: ${toolCall.id}, accumulated json: ${accumulatedJson}`)
controller.enqueue({
type: ChunkType.MCP_TOOL_CREATED,
tool_calls: [toolCall]
} as MCPToolCreatedChunk)
} catch (error) {
Logger.error(`Error parsing tool call input: ${error}`)
}
}
break
}
case 'message_delta': {
controller.enqueue({
type: ChunkType.LLM_RESPONSE_COMPLETE,
response: {
usage: {
prompt_tokens: rawChunk.usage.input_tokens || 0,
completion_tokens: rawChunk.usage.output_tokens || 0,
total_tokens: (rawChunk.usage.input_tokens || 0) + (rawChunk.usage.output_tokens || 0)
}
}
})
}
}
}
}
}
}
}
/**
* 将 ContentBlock 数组转换为 ContentBlockParam 数组
* 去除服务器生成的额外字段只保留发送给API所需的字段
*/
function convertContentBlocksToParams(contentBlocks: ContentBlock[]): ContentBlockParam[] {
return contentBlocks.map((block): ContentBlockParam => {
switch (block.type) {
case 'text':
// TextBlock -> TextBlockParam去除 citations 等服务器字段
return {
type: 'text',
text: block.text
} satisfies TextBlockParam
case 'tool_use':
// ToolUseBlock -> ToolUseBlockParam
return {
type: 'tool_use',
id: block.id,
name: block.name,
input: block.input
} satisfies ToolUseBlockParam
case 'thinking':
// ThinkingBlock -> ThinkingBlockParam
return {
type: 'thinking',
thinking: block.thinking,
signature: block.signature
} satisfies ThinkingBlockParam
case 'redacted_thinking':
// RedactedThinkingBlock -> RedactedThinkingBlockParam
return {
type: 'redacted_thinking',
data: block.data
} satisfies RedactedThinkingBlockParam
case 'server_tool_use':
// ServerToolUseBlock -> ServerToolUseBlockParam
return {
type: 'server_tool_use',
id: block.id,
name: block.name,
input: block.input
} satisfies ServerToolUseBlockParam
case 'web_search_tool_result':
// WebSearchToolResultBlock -> WebSearchToolResultBlockParam
return {
type: 'web_search_tool_result',
tool_use_id: block.tool_use_id,
content: block.content
} satisfies WebSearchToolResultBlockParam
default:
return block as ContentBlockParam
}
})
}

View File

@@ -1,767 +0,0 @@
import {
Content,
createPartFromUri,
File,
FunctionCall,
GenerateContentConfig,
GenerateImagesConfig,
GoogleGenAI,
HarmBlockThreshold,
HarmCategory,
Modality,
Model as GeminiModel,
Part,
SafetySetting,
SendMessageParameters,
ThinkingConfig,
Tool
} from '@google/genai'
import { nanoid } from '@reduxjs/toolkit'
import { GenericChunk } from '@renderer/aiCore/middleware/schemas'
import {
findTokenLimit,
GEMINI_FLASH_MODEL_REGEX,
isGemmaModel,
isSupportedThinkingTokenGeminiModel,
isVisionModel
} from '@renderer/config/models'
import { estimateTextTokens } from '@renderer/services/TokenService'
import {
Assistant,
EFFORT_RATIO,
FileMetadata,
FileTypes,
FileUploadResponse,
GenerateImageParams,
MCPCallToolResponse,
MCPTool,
MCPToolResponse,
Model,
Provider,
ToolCallResponse,
WebSearchSource
} from '@renderer/types'
import { ChunkType, LLMWebSearchCompleteChunk } from '@renderer/types/chunk'
import { Message } from '@renderer/types/newMessage'
import {
GeminiOptions,
GeminiSdkMessageParam,
GeminiSdkParams,
GeminiSdkRawChunk,
GeminiSdkRawOutput,
GeminiSdkToolCall
} from '@renderer/types/sdk'
import {
geminiFunctionCallToMcpTool,
isEnabledToolUse,
mcpToolCallResponseToGeminiMessage,
mcpToolsToGeminiTools
} from '@renderer/utils/mcp-tools'
import { findFileBlocks, findImageBlocks, getMainTextContent } from '@renderer/utils/messageUtils/find'
import { buildSystemPrompt } from '@renderer/utils/prompt'
import { defaultTimeout, MB } from '@shared/config/constant'
import { BaseApiClient } from '../BaseApiClient'
import { RequestTransformer, ResponseChunkTransformer } from '../types'
export class GeminiAPIClient extends BaseApiClient<
GoogleGenAI,
GeminiSdkParams,
GeminiSdkRawOutput,
GeminiSdkRawChunk,
GeminiSdkMessageParam,
GeminiSdkToolCall,
Tool
> {
constructor(provider: Provider) {
super(provider)
}
override async createCompletions(payload: GeminiSdkParams, options?: GeminiOptions): Promise<GeminiSdkRawOutput> {
const sdk = await this.getSdkInstance()
const { model, history, ...rest } = payload
const realPayload: Omit<GeminiSdkParams, 'model'> = {
...rest,
config: {
...rest.config,
abortSignal: options?.signal,
httpOptions: {
...rest.config?.httpOptions,
timeout: options?.timeout
}
}
} satisfies SendMessageParameters
const streamOutput = options?.streamOutput
const chat = sdk.chats.create({
model: model,
history: history
})
if (streamOutput) {
const stream = chat.sendMessageStream(realPayload)
return stream
} else {
const response = await chat.sendMessage(realPayload)
return response
}
}
override async generateImage(generateImageParams: GenerateImageParams): Promise<string[]> {
const sdk = await this.getSdkInstance()
try {
const { model, prompt, imageSize, batchSize, signal } = generateImageParams
const config: GenerateImagesConfig = {
numberOfImages: batchSize,
aspectRatio: imageSize,
abortSignal: signal,
httpOptions: {
timeout: defaultTimeout
}
}
const response = await sdk.models.generateImages({
model: model,
prompt,
config
})
if (!response.generatedImages || response.generatedImages.length === 0) {
return []
}
const images = response.generatedImages
.filter((image) => image.image?.imageBytes)
.map((image) => {
const dataPrefix = `data:${image.image?.mimeType || 'image/png'};base64,`
return dataPrefix + image.image?.imageBytes
})
// console.log(response?.generatedImages?.[0]?.image?.imageBytes);
return images
} catch (error) {
console.error('[generateImage] error:', error)
throw error
}
}
override async getEmbeddingDimensions(model: Model): Promise<number> {
const sdk = await this.getSdkInstance()
const data = await sdk.models.embedContent({
model: model.id,
contents: [{ role: 'user', parts: [{ text: 'hi' }] }]
})
return data.embeddings?.[0]?.values?.length || 0
}
override async listModels(): Promise<GeminiModel[]> {
const sdk = await this.getSdkInstance()
const response = await sdk.models.list()
const models: GeminiModel[] = []
for await (const model of response) {
models.push(model)
}
return models
}
override async getSdkInstance() {
if (this.sdkInstance) {
return this.sdkInstance
}
this.sdkInstance = new GoogleGenAI({
vertexai: false,
apiKey: this.apiKey,
apiVersion: this.getApiVersion(),
httpOptions: {
baseUrl: this.getBaseURL(),
apiVersion: this.getApiVersion(),
headers: {
...this.provider.extra_headers
}
}
})
return this.sdkInstance
}
protected getApiVersion(): string {
if (this.provider.isVertex) {
return 'v1'
}
return 'v1beta'
}
/**
* Handle a PDF file
* @param file - The file
* @returns The part
*/
private async handlePdfFile(file: FileMetadata): Promise<Part> {
const smallFileSize = 20 * MB
const isSmallFile = file.size < smallFileSize
if (isSmallFile) {
const { data, mimeType } = await this.base64File(file)
return {
inlineData: {
data,
mimeType
} as Part['inlineData']
}
}
// Retrieve file from Gemini uploaded files
const fileMetadata: FileUploadResponse = await window.api.fileService.retrieve(this.provider, file.id)
if (fileMetadata.status === 'success') {
const remoteFile = fileMetadata.originalFile?.file as File
return createPartFromUri(remoteFile.uri!, remoteFile.mimeType!)
}
// If file is not found, upload it to Gemini
const result = await window.api.fileService.upload(this.provider, file)
const remoteFile = result.originalFile?.file as File
return createPartFromUri(remoteFile.uri!, remoteFile.mimeType!)
}
/**
* Get the message contents
* @param message - The message
* @returns The message contents
*/
private async convertMessageToSdkParam(message: Message): Promise<Content> {
const role = message.role === 'user' ? 'user' : 'model'
const parts: Part[] = [{ text: await this.getMessageContent(message) }]
// Add any generated images from previous responses
const imageBlocks = findImageBlocks(message)
for (const imageBlock of imageBlocks) {
if (
imageBlock.metadata?.generateImageResponse?.images &&
imageBlock.metadata.generateImageResponse.images.length > 0
) {
for (const imageUrl of imageBlock.metadata.generateImageResponse.images) {
if (imageUrl && imageUrl.startsWith('data:')) {
// Extract base64 data and mime type from the data URL
const matches = imageUrl.match(/^data:(.+);base64,(.*)$/)
if (matches && matches.length === 3) {
const mimeType = matches[1]
const base64Data = matches[2]
parts.push({
inlineData: {
data: base64Data,
mimeType: mimeType
} as Part['inlineData']
})
}
}
}
}
const file = imageBlock.file
if (file) {
const base64Data = await window.api.file.base64Image(file.id + file.ext)
parts.push({
inlineData: {
data: base64Data.base64,
mimeType: base64Data.mime
} as Part['inlineData']
})
}
}
const fileBlocks = findFileBlocks(message)
for (const fileBlock of fileBlocks) {
const file = fileBlock.file
if (file.type === FileTypes.IMAGE) {
const base64Data = await window.api.file.base64Image(file.id + file.ext)
parts.push({
inlineData: {
data: base64Data.base64,
mimeType: base64Data.mime
} as Part['inlineData']
})
}
if (file.ext === '.pdf') {
parts.push(await this.handlePdfFile(file))
continue
}
if ([FileTypes.TEXT, FileTypes.DOCUMENT].includes(file.type)) {
const fileContent = await (await window.api.file.read(file.id + file.ext)).trim()
parts.push({
text: file.origin_name + '\n' + fileContent
})
}
}
return {
role,
parts: parts
}
}
// @ts-ignore unused
private async getImageFileContents(message: Message): Promise<Content> {
const role = message.role === 'user' ? 'user' : 'model'
const content = getMainTextContent(message)
const parts: Part[] = [{ text: content }]
const imageBlocks = findImageBlocks(message)
for (const imageBlock of imageBlocks) {
if (
imageBlock.metadata?.generateImageResponse?.images &&
imageBlock.metadata.generateImageResponse.images.length > 0
) {
for (const imageUrl of imageBlock.metadata.generateImageResponse.images) {
if (imageUrl && imageUrl.startsWith('data:')) {
// Extract base64 data and mime type from the data URL
const matches = imageUrl.match(/^data:(.+);base64,(.*)$/)
if (matches && matches.length === 3) {
const mimeType = matches[1]
const base64Data = matches[2]
parts.push({
inlineData: {
data: base64Data,
mimeType: mimeType
} as Part['inlineData']
})
}
}
}
}
const file = imageBlock.file
if (file) {
const base64Data = await window.api.file.base64Image(file.id + file.ext)
parts.push({
inlineData: {
data: base64Data.base64,
mimeType: base64Data.mime
} as Part['inlineData']
})
}
}
return {
role,
parts: parts
}
}
/**
* Get the safety settings
* @returns The safety settings
*/
private getSafetySettings(): SafetySetting[] {
const safetyThreshold = 'OFF' as HarmBlockThreshold
return [
{
category: HarmCategory.HARM_CATEGORY_HATE_SPEECH,
threshold: safetyThreshold
},
{
category: HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT,
threshold: safetyThreshold
},
{
category: HarmCategory.HARM_CATEGORY_HARASSMENT,
threshold: safetyThreshold
},
{
category: HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT,
threshold: safetyThreshold
},
{
category: HarmCategory.HARM_CATEGORY_CIVIC_INTEGRITY,
threshold: HarmBlockThreshold.BLOCK_NONE
}
]
}
/**
* Get the reasoning effort for the assistant
* @param assistant - The assistant
* @param model - The model
* @returns The reasoning effort
*/
private getBudgetToken(assistant: Assistant, model: Model) {
if (isSupportedThinkingTokenGeminiModel(model)) {
const reasoningEffort = assistant?.settings?.reasoning_effort
// 如果thinking_budget是undefined不思考
if (reasoningEffort === undefined) {
return GEMINI_FLASH_MODEL_REGEX.test(model.id)
? {
thinkingConfig: {
thinkingBudget: 0
}
}
: {}
}
if (reasoningEffort === 'auto') {
return {
thinkingConfig: {
includeThoughts: true,
thinkingBudget: -1
}
}
}
const effortRatio = EFFORT_RATIO[reasoningEffort]
const { min, max } = findTokenLimit(model.id) || { min: 0, max: 0 }
// 计算 budgetTokens确保不低于 min
const budget = Math.floor((max - min) * effortRatio + min)
return {
thinkingConfig: {
...(budget > 0 ? { thinkingBudget: budget } : {}),
includeThoughts: true
} as ThinkingConfig
}
}
return {}
}
private getGenerateImageParameter(): Partial<GenerateContentConfig> {
return {
systemInstruction: undefined,
responseModalities: [Modality.TEXT, Modality.IMAGE],
responseMimeType: 'text/plain'
}
}
getRequestTransformer(): RequestTransformer<GeminiSdkParams, GeminiSdkMessageParam> {
return {
transform: async (
coreRequest,
assistant,
model,
isRecursiveCall,
recursiveSdkMessages
): Promise<{
payload: GeminiSdkParams
messages: GeminiSdkMessageParam[]
metadata: Record<string, any>
}> => {
const { messages, mcpTools, maxTokens, enableWebSearch, enableGenerateImage } = coreRequest
// 1. 处理系统消息
let systemInstruction = assistant.prompt
// 2. 设置工具
const { tools } = this.setupToolsConfig({
mcpTools,
model,
enableToolUse: isEnabledToolUse(assistant)
})
if (this.useSystemPromptForTools) {
systemInstruction = await buildSystemPrompt(assistant.prompt || '', mcpTools, assistant)
}
let messageContents: Content = { role: 'user', parts: [] } // Initialize messageContents
const history: Content[] = []
// 3. 处理用户消息
if (typeof messages === 'string') {
messageContents = {
role: 'user',
parts: [{ text: messages }]
}
} else {
const userLastMessage = messages.pop()
if (userLastMessage) {
messageContents = await this.convertMessageToSdkParam(userLastMessage)
for (const message of messages) {
history.push(await this.convertMessageToSdkParam(message))
}
messages.push(userLastMessage)
}
}
if (enableWebSearch) {
tools.push({
googleSearch: {}
})
}
if (isGemmaModel(model) && assistant.prompt) {
const isFirstMessage = history.length === 0
if (isFirstMessage && messageContents) {
const userMessageText =
messageContents.parts && messageContents.parts.length > 0
? (messageContents.parts[0] as Part).text || ''
: ''
const systemMessage = [
{
text:
'<start_of_turn>user\n' +
systemInstruction +
'<end_of_turn>\n' +
'<start_of_turn>user\n' +
userMessageText +
'<end_of_turn>'
}
] as Part[]
if (messageContents && messageContents.parts) {
messageContents.parts[0] = systemMessage[0]
}
}
}
const newHistory =
isRecursiveCall && recursiveSdkMessages && recursiveSdkMessages.length > 0
? recursiveSdkMessages.slice(0, recursiveSdkMessages.length - 1)
: history
const newMessageContents =
isRecursiveCall && recursiveSdkMessages && recursiveSdkMessages.length > 0
? recursiveSdkMessages[recursiveSdkMessages.length - 1]
: messageContents
const generateContentConfig: GenerateContentConfig = {
safetySettings: this.getSafetySettings(),
systemInstruction: isGemmaModel(model) ? undefined : systemInstruction,
temperature: this.getTemperature(assistant, model),
topP: this.getTopP(assistant, model),
maxOutputTokens: maxTokens,
tools: tools,
...(enableGenerateImage ? this.getGenerateImageParameter() : {}),
...this.getBudgetToken(assistant, model),
// 只在对话场景下应用自定义参数,避免影响翻译、总结等其他业务逻辑
...(coreRequest.callType === 'chat' ? this.getCustomParameters(assistant) : {})
}
const param: GeminiSdkParams = {
model: model.id,
config: generateContentConfig,
history: newHistory,
message: newMessageContents.parts!
}
return {
payload: param,
messages: [messageContents],
metadata: {}
}
}
}
}
getResponseChunkTransformer(): ResponseChunkTransformer<GeminiSdkRawChunk> {
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) {
controller.enqueue({
type: ChunkType.THINKING_DELTA,
text: text
})
} else if (part.text) {
controller.enqueue({
type: ChunkType.TEXT_DELTA,
text: text
})
} else if (part.inlineData) {
controller.enqueue({
type: ChunkType.IMAGE_COMPLETE,
image: {
type: 'base64',
images: [
part.inlineData?.data?.startsWith('data:')
? part.inlineData?.data
: `data:${part.inlineData?.mimeType || 'image/png'};base64,${part.inlineData?.data}`
]
}
})
} else if (part.functionCall) {
toolCalls.push(part.functionCall)
}
})
}
if (candidate.finishReason) {
if (candidate.groundingMetadata) {
controller.enqueue({
type: ChunkType.LLM_WEB_SEARCH_COMPLETE,
llm_web_search: {
results: candidate.groundingMetadata,
source: WebSearchSource.GEMINI
}
} as LLMWebSearchCompleteChunk)
}
controller.enqueue({
type: ChunkType.LLM_RESPONSE_COMPLETE,
response: {
usage: {
prompt_tokens: chunk.usageMetadata?.promptTokenCount || 0,
completion_tokens:
(chunk.usageMetadata?.totalTokenCount || 0) - (chunk.usageMetadata?.promptTokenCount || 0),
total_tokens: chunk.usageMetadata?.totalTokenCount || 0
}
}
})
}
}
}
if (toolCalls.length > 0) {
controller.enqueue({
type: ChunkType.MCP_TOOL_CREATED,
tool_calls: toolCalls
})
}
}
})
}
public convertMcpToolsToSdkTools(mcpTools: MCPTool[]): Tool[] {
return mcpToolsToGeminiTools(mcpTools)
}
public convertSdkToolCallToMcp(toolCall: GeminiSdkToolCall, mcpTools: MCPTool[]): MCPTool | undefined {
return geminiFunctionCallToMcpTool(mcpTools, toolCall)
}
public convertSdkToolCallToMcpToolResponse(toolCall: GeminiSdkToolCall, mcpTool: MCPTool): ToolCallResponse {
const parsedArgs = (() => {
try {
return typeof toolCall.args === 'string' ? JSON.parse(toolCall.args) : toolCall.args
} catch {
return toolCall.args
}
})()
return {
id: toolCall.id || nanoid(),
toolCallId: toolCall.id,
tool: mcpTool,
arguments: parsedArgs,
status: 'pending'
} as ToolCallResponse
}
public convertMcpToolResponseToSdkMessageParam(
mcpToolResponse: MCPToolResponse,
resp: MCPCallToolResponse,
model: Model
): GeminiSdkMessageParam | undefined {
if ('toolUseId' in mcpToolResponse && mcpToolResponse.toolUseId) {
return mcpToolCallResponseToGeminiMessage(mcpToolResponse, resp, isVisionModel(model))
} else if ('toolCallId' in mcpToolResponse) {
return {
role: 'user',
parts: [
{
functionResponse: {
id: mcpToolResponse.toolCallId,
name: mcpToolResponse.tool.id,
response: {
output: !resp.isError ? resp.content : undefined,
error: resp.isError ? resp.content : undefined
}
}
}
]
} satisfies Content
}
return
}
public buildSdkMessages(
currentReqMessages: Content[],
output: string,
toolResults: Content[],
toolCalls: FunctionCall[]
): Content[] {
const parts: Part[] = []
const modelParts: Part[] = []
if (output) {
modelParts.push({
text: output
})
}
toolCalls.forEach((toolCall) => {
modelParts.push({
functionCall: toolCall
})
})
parts.push(
...toolResults
.map((ts) => ts.parts)
.flat()
.filter((p) => p !== undefined)
)
const userMessage: Content = {
role: 'user',
parts: []
}
if (modelParts.length > 0) {
currentReqMessages.push({
role: 'model',
parts: modelParts
})
}
if (parts.length > 0) {
userMessage.parts?.push(...parts)
currentReqMessages.push(userMessage)
}
return currentReqMessages
}
override estimateMessageTokens(message: GeminiSdkMessageParam): number {
return (
message.parts?.reduce((acc, part) => {
if (part.text) {
return acc + estimateTextTokens(part.text)
}
if (part.functionCall) {
return acc + estimateTextTokens(JSON.stringify(part.functionCall))
}
if (part.functionResponse) {
return acc + estimateTextTokens(JSON.stringify(part.functionResponse.response))
}
if (part.inlineData) {
return acc + estimateTextTokens(part.inlineData.data || '')
}
if (part.fileData) {
return acc + estimateTextTokens(part.fileData.fileUri || '')
}
return acc
}, 0) || 0
)
}
public extractMessagesFromSdkPayload(sdkPayload: GeminiSdkParams): GeminiSdkMessageParam[] {
const messageParam: GeminiSdkMessageParam = {
role: 'user',
parts: []
}
if (Array.isArray(sdkPayload.message)) {
sdkPayload.message.forEach((part) => {
if (typeof part === 'string') {
messageParam.parts?.push({ text: part })
} else if (typeof part === 'object') {
messageParam.parts?.push(part)
}
})
}
return [...(sdkPayload.history || []), messageParam]
}
private async base64File(file: FileMetadata) {
const { data } = await window.api.file.base64File(file.id + file.ext)
return {
data,
mimeType: 'application/pdf'
}
}
}

View File

@@ -1,95 +0,0 @@
import { GoogleGenAI } from '@google/genai'
import { getVertexAILocation, getVertexAIProjectId, getVertexAIServiceAccount } from '@renderer/hooks/useVertexAI'
import { Provider } from '@renderer/types'
import { GeminiAPIClient } from './GeminiAPIClient'
export class VertexAPIClient extends GeminiAPIClient {
private authHeaders?: Record<string, string>
private authHeadersExpiry?: number
constructor(provider: Provider) {
super(provider)
}
override async getSdkInstance() {
if (this.sdkInstance) {
return this.sdkInstance
}
const serviceAccount = getVertexAIServiceAccount()
const projectId = getVertexAIProjectId()
const location = getVertexAILocation()
if (!serviceAccount.privateKey || !serviceAccount.clientEmail || !projectId || !location) {
throw new Error('Vertex AI settings are not configured')
}
const authHeaders = await this.getServiceAccountAuthHeaders()
this.sdkInstance = new GoogleGenAI({
vertexai: true,
project: projectId,
location: location,
httpOptions: {
apiVersion: this.getApiVersion(),
headers: authHeaders
}
})
return this.sdkInstance
}
/**
* 获取认证头,如果配置了 service account 则从主进程获取
*/
private async getServiceAccountAuthHeaders(): Promise<Record<string, string> | undefined> {
const serviceAccount = getVertexAIServiceAccount()
const projectId = getVertexAIProjectId()
// 检查是否配置了 service account
if (!serviceAccount.privateKey || !serviceAccount.clientEmail || !projectId) {
return undefined
}
// 检查是否已有有效的认证头(提前 5 分钟过期)
const now = Date.now()
if (this.authHeaders && this.authHeadersExpiry && this.authHeadersExpiry - now > 5 * 60 * 1000) {
return this.authHeaders
}
try {
// 从主进程获取认证头
this.authHeaders = await window.api.vertexAI.getAuthHeaders({
projectId,
serviceAccount: {
privateKey: serviceAccount.privateKey,
clientEmail: serviceAccount.clientEmail
}
})
// 设置过期时间(通常认证头有效期为 1 小时)
this.authHeadersExpiry = now + 60 * 60 * 1000
return this.authHeaders
} catch (error: any) {
console.error('Failed to get auth headers:', error)
throw new Error(`Service Account authentication failed: ${error.message}`)
}
}
/**
* 清理认证缓存并重新初始化
*/
clearAuthCache(): void {
this.authHeaders = undefined
this.authHeadersExpiry = undefined
const serviceAccount = getVertexAIServiceAccount()
const projectId = getVertexAIProjectId()
if (projectId && serviceAccount.clientEmail) {
window.api.vertexAI.clearAuthCache(projectId, serviceAccount.clientEmail)
}
}
}

View File

@@ -1,6 +0,0 @@
export * from './ApiClientFactory'
export * from './BaseApiClient'
export * from './types'
// Export specific clients from subdirectories
export * from './openai/OpenAIApiClient'

View File

@@ -1,764 +0,0 @@
import { DEFAULT_MAX_TOKENS } from '@renderer/config/constant'
import Logger from '@renderer/config/logger'
import {
findTokenLimit,
GEMINI_FLASH_MODEL_REGEX,
getOpenAIWebSearchParams,
isDoubaoThinkingAutoModel,
isReasoningModel,
isSupportedReasoningEffortGrokModel,
isSupportedReasoningEffortModel,
isSupportedReasoningEffortOpenAIModel,
isSupportedThinkingTokenClaudeModel,
isSupportedThinkingTokenDoubaoModel,
isSupportedThinkingTokenGeminiModel,
isSupportedThinkingTokenModel,
isSupportedThinkingTokenQwenModel,
isVisionModel
} from '@renderer/config/models'
import { processPostsuffixQwen3Model, processReqMessages } from '@renderer/services/ModelMessageService'
import { estimateTextTokens } from '@renderer/services/TokenService'
// For Copilot token
import {
Assistant,
EFFORT_RATIO,
FileTypes,
MCPCallToolResponse,
MCPTool,
MCPToolResponse,
Model,
Provider,
ToolCallResponse,
WebSearchSource
} from '@renderer/types'
import { ChunkType } from '@renderer/types/chunk'
import { Message } from '@renderer/types/newMessage'
import {
OpenAISdkMessageParam,
OpenAISdkParams,
OpenAISdkRawChunk,
OpenAISdkRawContentSource,
OpenAISdkRawOutput,
ReasoningEffortOptionalParams
} from '@renderer/types/sdk'
import { addImageFileToContents } from '@renderer/utils/formats'
import {
isEnabledToolUse,
mcpToolCallResponseToOpenAICompatibleMessage,
mcpToolsToOpenAIChatTools,
openAIToolsToMcpTool
} from '@renderer/utils/mcp-tools'
import { findFileBlocks, findImageBlocks } from '@renderer/utils/messageUtils/find'
import { buildSystemPrompt } from '@renderer/utils/prompt'
import OpenAI, { AzureOpenAI } from 'openai'
import { ChatCompletionContentPart, ChatCompletionContentPartRefusal, ChatCompletionTool } from 'openai/resources'
import { GenericChunk } from '../../middleware/schemas'
import { RequestTransformer, ResponseChunkTransformer, ResponseChunkTransformerContext } from '../types'
import { OpenAIBaseClient } from './OpenAIBaseClient'
export class OpenAIAPIClient extends OpenAIBaseClient<
OpenAI | AzureOpenAI,
OpenAISdkParams,
OpenAISdkRawOutput,
OpenAISdkRawChunk,
OpenAISdkMessageParam,
OpenAI.Chat.Completions.ChatCompletionMessageToolCall,
ChatCompletionTool
> {
constructor(provider: Provider) {
super(provider)
}
override async createCompletions(
payload: OpenAISdkParams,
options?: OpenAI.RequestOptions
): Promise<OpenAISdkRawOutput> {
const sdk = await this.getSdkInstance()
// @ts-ignore - SDK参数可能有额外的字段
return await sdk.chat.completions.create(payload, options)
}
/**
* Get the reasoning effort for the assistant
* @param assistant - The assistant
* @param model - The model
* @returns The reasoning effort
*/
// Method for reasoning effort, moved from OpenAIProvider
override getReasoningEffort(assistant: Assistant, model: Model): ReasoningEffortOptionalParams {
if (this.provider.id === 'groq') {
return {}
}
if (!isReasoningModel(model)) {
return {}
}
const reasoningEffort = assistant?.settings?.reasoning_effort
// Doubao 思考模式支持
if (isSupportedThinkingTokenDoubaoModel(model)) {
// reasoningEffort 为空,默认开启 enabled
if (!reasoningEffort) {
return { thinking: { type: 'disabled' } }
}
if (reasoningEffort === 'high') {
return { thinking: { type: 'enabled' } }
}
if (reasoningEffort === 'auto' && isDoubaoThinkingAutoModel(model)) {
return { thinking: { type: 'auto' } }
}
// 其他情况不带 thinking 字段
return {}
}
if (!reasoningEffort) {
if (model.provider === 'openrouter') {
if (isSupportedThinkingTokenGeminiModel(model) && !GEMINI_FLASH_MODEL_REGEX.test(model.id)) {
return {}
}
return { reasoning: { enabled: false, exclude: true } }
}
if (isSupportedThinkingTokenQwenModel(model)) {
return { enable_thinking: false }
}
if (isSupportedThinkingTokenClaudeModel(model)) {
return {}
}
if (isSupportedThinkingTokenGeminiModel(model)) {
if (GEMINI_FLASH_MODEL_REGEX.test(model.id)) {
return {
extra_body: {
google: {
thinking_config: {
thinking_budget: 0
}
}
}
}
}
return {}
}
if (isSupportedThinkingTokenDoubaoModel(model)) {
return { thinking: { type: 'disabled' } }
}
return {}
}
const effortRatio = EFFORT_RATIO[reasoningEffort]
const budgetTokens = Math.floor(
(findTokenLimit(model.id)?.max! - findTokenLimit(model.id)?.min!) * effortRatio + findTokenLimit(model.id)?.min!
)
// OpenRouter models
if (model.provider === 'openrouter') {
if (isSupportedReasoningEffortModel(model) || isSupportedThinkingTokenModel(model)) {
return {
reasoning: {
effort: reasoningEffort === 'auto' ? 'medium' : reasoningEffort
}
}
}
}
// Qwen models
if (isSupportedThinkingTokenQwenModel(model)) {
return {
enable_thinking: true,
thinking_budget: budgetTokens
}
}
// Grok models
if (isSupportedReasoningEffortGrokModel(model)) {
return {
reasoning_effort: reasoningEffort
}
}
// OpenAI models
if (isSupportedReasoningEffortOpenAIModel(model)) {
return {
reasoning_effort: reasoningEffort
}
}
if (isSupportedThinkingTokenGeminiModel(model)) {
if (reasoningEffort === 'auto') {
return {
extra_body: {
google: {
thinking_config: {
thinking_budget: -1,
include_thoughts: true
}
}
}
}
}
return {
extra_body: {
google: {
thinking_config: {
thinking_budget: budgetTokens,
include_thoughts: true
}
}
}
}
}
// Claude models
if (isSupportedThinkingTokenClaudeModel(model)) {
const maxTokens = assistant.settings?.maxTokens
return {
thinking: {
type: 'enabled',
budget_tokens: Math.floor(
Math.max(1024, Math.min(budgetTokens, (maxTokens || DEFAULT_MAX_TOKENS) * effortRatio))
)
}
}
}
// Doubao models
if (isSupportedThinkingTokenDoubaoModel(model)) {
if (assistant.settings?.reasoning_effort === 'high') {
return {
thinking: {
type: 'enabled'
}
}
}
}
// Default case: no special thinking settings
return {}
}
/**
* Check if the provider does not support files
* @returns True if the provider does not support files, false otherwise
*/
private get isNotSupportFiles() {
if (this.provider?.isNotSupportArrayContent) {
return true
}
const providers = ['deepseek', 'baichuan', 'minimax', 'xirang']
return providers.includes(this.provider.id)
}
/**
* Get the message parameter
* @param message - The message
* @param model - The model
* @returns The message parameter
*/
public async convertMessageToSdkParam(message: Message, model: Model): Promise<OpenAISdkMessageParam> {
const isVision = isVisionModel(model)
const content = await this.getMessageContent(message)
const fileBlocks = findFileBlocks(message)
const imageBlocks = findImageBlocks(message)
if (fileBlocks.length === 0 && imageBlocks.length === 0) {
return {
role: message.role === 'system' ? 'user' : message.role,
content
} as OpenAISdkMessageParam
}
// If the model does not support files, extract the file content
if (this.isNotSupportFiles) {
const fileContent = await this.extractFileContent(message)
return {
role: message.role === 'system' ? 'user' : message.role,
content: content + '\n\n---\n\n' + fileContent
} as OpenAISdkMessageParam
}
// If the model supports files, add the file content to the message
const parts: ChatCompletionContentPart[] = []
if (content) {
parts.push({ type: 'text', text: content })
}
for (const imageBlock of imageBlocks) {
if (isVision) {
if (imageBlock.file) {
const image = await window.api.file.base64Image(imageBlock.file.id + imageBlock.file.ext)
parts.push({ type: 'image_url', image_url: { url: image.data } })
} else if (imageBlock.url && imageBlock.url.startsWith('data:')) {
parts.push({ type: 'image_url', image_url: { url: imageBlock.url } })
}
}
}
for (const fileBlock of fileBlocks) {
const file = fileBlock.file
if (!file) {
continue
}
if ([FileTypes.TEXT, FileTypes.DOCUMENT].includes(file.type)) {
const fileContent = await (await window.api.file.read(file.id + file.ext)).trim()
parts.push({
type: 'text',
text: file.origin_name + '\n' + fileContent
})
}
}
return {
role: message.role === 'system' ? 'user' : message.role,
content: parts
} as OpenAISdkMessageParam
}
public convertMcpToolsToSdkTools(mcpTools: MCPTool[]): ChatCompletionTool[] {
return mcpToolsToOpenAIChatTools(mcpTools)
}
public convertSdkToolCallToMcp(
toolCall: OpenAI.Chat.Completions.ChatCompletionMessageToolCall,
mcpTools: MCPTool[]
): MCPTool | undefined {
return openAIToolsToMcpTool(mcpTools, toolCall)
}
public convertSdkToolCallToMcpToolResponse(
toolCall: OpenAI.Chat.Completions.ChatCompletionMessageToolCall,
mcpTool: MCPTool
): ToolCallResponse {
let parsedArgs: any
try {
parsedArgs = JSON.parse(toolCall.function.arguments)
} catch {
parsedArgs = toolCall.function.arguments
}
return {
id: toolCall.id,
toolCallId: toolCall.id,
tool: mcpTool,
arguments: parsedArgs,
status: 'pending'
} as ToolCallResponse
}
public convertMcpToolResponseToSdkMessageParam(
mcpToolResponse: MCPToolResponse,
resp: MCPCallToolResponse,
model: Model
): OpenAISdkMessageParam | undefined {
if ('toolUseId' in mcpToolResponse && mcpToolResponse.toolUseId) {
// This case is for Anthropic/Claude like tool usage, OpenAI uses tool_call_id
// For OpenAI, we primarily expect toolCallId. This might need adjustment if mixing provider concepts.
return mcpToolCallResponseToOpenAICompatibleMessage(mcpToolResponse, resp, isVisionModel(model))
} else if ('toolCallId' in mcpToolResponse && mcpToolResponse.toolCallId) {
return {
role: 'tool',
tool_call_id: mcpToolResponse.toolCallId,
content: JSON.stringify(resp.content)
} as OpenAI.Chat.Completions.ChatCompletionToolMessageParam
}
return undefined
}
public buildSdkMessages(
currentReqMessages: OpenAISdkMessageParam[],
output: string | undefined,
toolResults: OpenAISdkMessageParam[],
toolCalls: OpenAI.Chat.Completions.ChatCompletionMessageToolCall[]
): OpenAISdkMessageParam[] {
if (!output && toolCalls.length === 0) {
return [...currentReqMessages, ...toolResults]
}
const assistantMessage: OpenAISdkMessageParam = {
role: 'assistant',
content: output,
tool_calls: toolCalls.length > 0 ? toolCalls : undefined
}
const newReqMessages = [...currentReqMessages, assistantMessage, ...toolResults]
return newReqMessages
}
override estimateMessageTokens(message: OpenAISdkMessageParam): number {
let sum = 0
if (typeof message.content === 'string') {
sum += estimateTextTokens(message.content)
} else if (Array.isArray(message.content)) {
sum += (message.content || [])
.map((part: ChatCompletionContentPart | ChatCompletionContentPartRefusal) => {
switch (part.type) {
case 'text':
return estimateTextTokens(part.text)
case 'image_url':
return estimateTextTokens(part.image_url.url)
case 'input_audio':
return estimateTextTokens(part.input_audio.data)
case 'file':
return estimateTextTokens(part.file.file_data || '')
default:
return 0
}
})
.reduce((acc, curr) => acc + curr, 0)
}
if ('tool_calls' in message && message.tool_calls) {
sum += message.tool_calls.reduce((acc, toolCall) => {
return acc + estimateTextTokens(JSON.stringify(toolCall.function.arguments))
}, 0)
}
return sum
}
public extractMessagesFromSdkPayload(sdkPayload: OpenAISdkParams): OpenAISdkMessageParam[] {
return sdkPayload.messages || []
}
getRequestTransformer(): RequestTransformer<OpenAISdkParams, OpenAISdkMessageParam> {
return {
transform: async (
coreRequest,
assistant,
model,
isRecursiveCall,
recursiveSdkMessages
): Promise<{
payload: OpenAISdkParams
messages: OpenAISdkMessageParam[]
metadata: Record<string, any>
}> => {
const { messages, mcpTools, maxTokens, streamOutput, enableWebSearch } = coreRequest
// 1. 处理系统消息
let systemMessage = { role: 'system', content: assistant.prompt || '' }
if (isSupportedReasoningEffortOpenAIModel(model)) {
systemMessage = {
role: 'developer',
content: `Formatting re-enabled${systemMessage ? '\n' + systemMessage.content : ''}`
}
}
if (model.id.includes('o1-mini') || model.id.includes('o1-preview')) {
systemMessage.role = 'assistant'
}
// 2. 设置工具必须在this.usesystemPromptForTools前面
const { tools } = this.setupToolsConfig({
mcpTools: mcpTools,
model,
enableToolUse: isEnabledToolUse(assistant)
})
if (this.useSystemPromptForTools) {
systemMessage.content = await buildSystemPrompt(systemMessage.content || '', mcpTools, assistant)
}
// 3. 处理用户消息
const userMessages: OpenAISdkMessageParam[] = []
if (typeof messages === 'string') {
userMessages.push({ role: 'user', content: messages })
} else {
const processedMessages = addImageFileToContents(messages)
for (const message of processedMessages) {
userMessages.push(await this.convertMessageToSdkParam(message, model))
}
}
const lastUserMsg = userMessages.findLast((m) => m.role === 'user')
if (lastUserMsg && isSupportedThinkingTokenQwenModel(model)) {
const postsuffix = '/no_think'
const qwenThinkModeEnabled = assistant.settings?.qwenThinkMode === true
const currentContent = lastUserMsg.content
lastUserMsg.content = processPostsuffixQwen3Model(currentContent, postsuffix, qwenThinkModeEnabled) as any
}
// 4. 最终请求消息
let reqMessages: OpenAISdkMessageParam[]
if (!systemMessage.content) {
reqMessages = [...userMessages]
} else {
reqMessages = [systemMessage, ...userMessages].filter(Boolean) as OpenAISdkMessageParam[]
}
reqMessages = processReqMessages(model, reqMessages)
// 5. 创建通用参数
const commonParams = {
model: model.id,
messages:
isRecursiveCall && recursiveSdkMessages && recursiveSdkMessages.length > 0
? recursiveSdkMessages
: reqMessages,
temperature: this.getTemperature(assistant, model),
top_p: this.getTopP(assistant, model),
max_tokens: maxTokens,
tools: tools.length > 0 ? tools : undefined,
service_tier: this.getServiceTier(model),
...this.getProviderSpecificParameters(assistant, model),
...this.getReasoningEffort(assistant, model),
...getOpenAIWebSearchParams(model, enableWebSearch),
// 只在对话场景下应用自定义参数,避免影响翻译、总结等其他业务逻辑
...(coreRequest.callType === 'chat' ? this.getCustomParameters(assistant) : {})
}
// Create the appropriate parameters object based on whether streaming is enabled
const sdkParams: OpenAISdkParams = streamOutput
? {
...commonParams,
stream: true
}
: {
...commonParams,
stream: false
}
const timeout = this.getTimeout(model)
return { payload: sdkParams, messages: reqMessages, metadata: { timeout } }
}
}
}
// 在RawSdkChunkToGenericChunkMiddleware中使用
getResponseChunkTransformer(): ResponseChunkTransformer<OpenAISdkRawChunk> {
let hasBeenCollectedWebSearch = false
const collectWebSearchData = (
chunk: OpenAISdkRawChunk,
contentSource: OpenAISdkRawContentSource,
context: ResponseChunkTransformerContext
) => {
if (hasBeenCollectedWebSearch) {
return
}
// OpenAI annotations
// @ts-ignore - annotations may not be in standard type definitions
const annotations = contentSource.annotations || chunk.annotations
if (annotations && annotations.length > 0 && annotations[0].type === 'url_citation') {
hasBeenCollectedWebSearch = true
return {
results: annotations,
source: WebSearchSource.OPENAI
}
}
// Grok citations
// @ts-ignore - citations may not be in standard type definitions
if (context.provider?.id === 'grok' && chunk.citations) {
hasBeenCollectedWebSearch = true
return {
// @ts-ignore - citations may not be in standard type definitions
results: chunk.citations,
source: WebSearchSource.GROK
}
}
// Perplexity citations
// @ts-ignore - citations may not be in standard type definitions
if (context.provider?.id === 'perplexity' && chunk.citations && chunk.citations.length > 0) {
hasBeenCollectedWebSearch = true
return {
// @ts-ignore - citations may not be in standard type definitions
results: chunk.citations,
source: WebSearchSource.PERPLEXITY
}
}
// OpenRouter citations
// @ts-ignore - citations may not be in standard type definitions
if (context.provider?.id === 'openrouter' && chunk.citations && chunk.citations.length > 0) {
hasBeenCollectedWebSearch = true
return {
// @ts-ignore - citations may not be in standard type definitions
results: chunk.citations,
source: WebSearchSource.OPENROUTER
}
}
// Zhipu web search
// @ts-ignore - web_search may not be in standard type definitions
if (context.provider?.id === 'zhipu' && chunk.web_search) {
hasBeenCollectedWebSearch = true
return {
// @ts-ignore - web_search may not be in standard type definitions
results: chunk.web_search,
source: WebSearchSource.ZHIPU
}
}
// Hunyuan web search
// @ts-ignore - search_info may not be in standard type definitions
if (context.provider?.id === 'hunyuan' && chunk.search_info?.search_results) {
hasBeenCollectedWebSearch = true
return {
// @ts-ignore - search_info may not be in standard type definitions
results: chunk.search_info.search_results,
source: WebSearchSource.HUNYUAN
}
}
// TODO: 放到AnthropicApiClient中
// // Other providers...
// // @ts-ignore - web_search may not be in standard type definitions
// if (chunk.web_search) {
// const sourceMap: Record<string, string> = {
// openai: 'openai',
// anthropic: 'anthropic',
// qwenlm: 'qwen'
// }
// const source = sourceMap[context.provider?.id] || 'openai_response'
// return {
// results: chunk.web_search,
// source: source as const
// }
// }
return null
}
const toolCalls: OpenAI.Chat.Completions.ChatCompletionMessageToolCall[] = []
let isFinished = false
let lastUsageInfo: any = null
/**
* 统一的完成信号发送逻辑
* - 有 finish_reason 时
* - 无 finish_reason 但是流正常结束时
*/
const emitCompletionSignals = (controller: TransformStreamDefaultController<GenericChunk>) => {
if (isFinished) return
if (toolCalls.length > 0) {
controller.enqueue({
type: ChunkType.MCP_TOOL_CREATED,
tool_calls: toolCalls
})
}
const usage = lastUsageInfo || {
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0
}
controller.enqueue({
type: ChunkType.LLM_RESPONSE_COMPLETE,
response: { usage }
})
// 防止重复发送
isFinished = true
}
return (context: ResponseChunkTransformerContext) => ({
async transform(chunk: OpenAISdkRawChunk, controller: TransformStreamDefaultController<GenericChunk>) {
// 持续更新usage信息
if (chunk.usage) {
lastUsageInfo = {
prompt_tokens: chunk.usage.prompt_tokens || 0,
completion_tokens: chunk.usage.completion_tokens || 0,
total_tokens: (chunk.usage.prompt_tokens || 0) + (chunk.usage.completion_tokens || 0)
}
}
// 处理chunk
if ('choices' in chunk && chunk.choices && chunk.choices.length > 0) {
const choice = chunk.choices[0]
if (!choice) return
// 对于流式响应,使用 delta对于非流式响应使用 message。
// 然而某些 OpenAI 兼容平台在非流式请求时会错误地返回一个空对象的 delta 字段。
// 如果 delta 为空对象,应当忽略它并回退到 message避免造成内容缺失。
let contentSource: OpenAISdkRawContentSource | null = null
if ('delta' in choice && choice.delta && Object.keys(choice.delta).length > 0) {
contentSource = choice.delta
} else if ('message' in choice) {
contentSource = choice.message
}
if (!contentSource) return
const webSearchData = collectWebSearchData(chunk, contentSource, context)
if (webSearchData) {
controller.enqueue({
type: ChunkType.LLM_WEB_SEARCH_COMPLETE,
llm_web_search: webSearchData
})
}
// 处理推理内容 (e.g. from OpenRouter DeepSeek-R1)
// @ts-ignore - reasoning_content is not in standard OpenAI types but some providers use it
const reasoningText = contentSource.reasoning_content || contentSource.reasoning
if (reasoningText) {
controller.enqueue({
type: ChunkType.THINKING_DELTA,
text: reasoningText
})
}
// 处理文本内容
if (contentSource.content) {
controller.enqueue({
type: ChunkType.TEXT_DELTA,
text: contentSource.content
})
}
// 处理工具调用
if (contentSource.tool_calls) {
for (const toolCall of contentSource.tool_calls) {
if ('index' in toolCall) {
const { id, index, function: fun } = toolCall
if (fun?.name) {
toolCalls[index] = {
id: id || '',
function: {
name: fun.name,
arguments: fun.arguments || ''
},
type: 'function'
}
} else if (fun?.arguments) {
toolCalls[index].function.arguments += fun.arguments
}
} else {
toolCalls.push(toolCall)
}
}
}
// 处理finish_reason发送流结束信号
if ('finish_reason' in choice && choice.finish_reason) {
Logger.debug(`[OpenAIApiClient] Stream finished with reason: ${choice.finish_reason}`)
const webSearchData = collectWebSearchData(chunk, contentSource, context)
if (webSearchData) {
controller.enqueue({
type: ChunkType.LLM_WEB_SEARCH_COMPLETE,
llm_web_search: webSearchData
})
}
emitCompletionSignals(controller)
}
}
},
// 流正常结束时,检查是否需要发送完成信号
flush(controller) {
if (isFinished) return
Logger.debug('[OpenAIApiClient] Stream ended without finish_reason, emitting fallback completion signals')
emitCompletionSignals(controller)
}
})
}
}

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