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
124 changed files with 7110 additions and 2522 deletions

View File

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

2
.gitignore vendored
View File

@@ -45,7 +45,7 @@ stats.html
local
.aider*
.cursorrules
.cursor/rules
.cursor/*
# vitest
coverage

View File

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

View File

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

View File

@@ -12,72 +12,51 @@ electronLanguages:
directories:
buildResources: build
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
- '!**/*.{h,iobj,ipdb,tlog,recipe,vcxproj,vcxproj.filters}' # 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.
@@ -85,11 +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
arch:
- arm64
- x64
maintainer: electronjs.org
category: Utility
desktop:
@@ -98,13 +86,15 @@ 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: |
新增划词助手

View File

@@ -1,5 +1,6 @@
import react from '@vitejs/plugin-react-swc'
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
import fs from 'fs'
import { resolve } from 'path'
import { visualizer } from 'rollup-plugin-visualizer'
@@ -9,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'),
@@ -19,12 +35,28 @@ export default defineConfig({
},
build: {
rollupOptions: {
external: ['@libsql/client', 'bufferutil', 'utf-8-validate']
},
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: {
@@ -39,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: [

View File

@@ -1,6 +1,6 @@
{
"name": "CherryStudio",
"version": "1.4.0",
"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",
@@ -47,6 +48,7 @@
"test": "vitest run --silent",
"test:main": "vitest run --project main",
"test:renderer": "vitest run --project renderer",
"test:update": "yarn test:renderer --update",
"test:coverage": "vitest run --coverage --silent",
"test:ui": "vitest --ui",
"test:watch": "vitest",
@@ -70,30 +72,44 @@
"@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.4",
"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": "^6.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.19",
"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"
},
@@ -117,7 +133,7 @@
"@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.4.2",
"@swc/plugin-styled-components": "^7.1.5",
@@ -154,8 +170,8 @@
"dexie": "^4.0.8",
"dexie-react-hooks": "^1.1.7",
"dotenv-cli": "^7.4.2",
"electron": "35.4.0",
"electron-builder": "26.0.15",
"electron": "22.3.23",
"electron-builder": "^24.9.1",
"electron-devtools-installer": "^3.2.0",
"electron-vite": "^3.1.0",
"emittery": "^1.0.3",
@@ -177,7 +193,7 @@
"mime": "^4.0.4",
"motion": "^12.10.5",
"npx-scope-finder": "^1.2.0",
"openai": "patch:openai@npm%3A4.96.0#~/.yarn/patches/openai-npm-4.96.0-0665b05cb9.patch",
"openai": "patch:openai@npm%3A5.1.0#~/.yarn/patches/openai-npm-5.1.0-0e7b3ccb07.patch",
"p-queue": "^8.1.0",
"playwright": "^1.52.0",
"prettier": "^3.5.3",
@@ -218,10 +234,13 @@
"@langchain/openai@npm:^0.3.16": "patch:@langchain/openai@npm%3A0.3.16#~/.yarn/patches/@langchain-openai-npm-0.3.16-e525b59526.patch",
"@langchain/openai@npm:>=0.1.0 <0.4.0": "patch:@langchain/openai@npm%3A0.3.16#~/.yarn/patches/@langchain-openai-npm-0.3.16-e525b59526.patch",
"libsql@npm:^0.4.4": "patch:libsql@npm%3A0.4.7#~/.yarn/patches/libsql-npm-0.4.7-444e260fb1.patch",
"openai@npm:^4.77.0": "patch:openai@npm%3A4.96.0#~/.yarn/patches/openai-npm-4.96.0-0665b05cb9.patch",
"openai@npm:^4.77.0": "patch:openai@npm%3A5.1.0#~/.yarn/patches/openai-npm-5.1.0-0e7b3ccb07.patch",
"pkce-challenge@npm:^4.1.0": "patch:pkce-challenge@npm%3A4.1.0#~/.yarn/patches/pkce-challenge-npm-4.1.0-fbc51695a3.patch",
"@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%3A4.96.0#~/.yarn/patches/openai-npm-4.96.0-0665b05cb9.patch",
"openai@npm:^4.87.3": "patch:openai@npm%3A5.1.0#~/.yarn/patches/openai-npm-5.1.0-0e7b3ccb07.patch",
"app-builder-lib@npm:26.0.15": "patch:app-builder-lib@npm%3A26.0.15#~/.yarn/patches/app-builder-lib-npm-26.0.15-360e5b0476.patch",
"@langchain/core@npm:^0.3.26": "patch:@langchain/core@npm%3A0.3.44#~/.yarn/patches/@langchain-core-npm-0.3.44-41d5c3cb0a.patch"
},

View File

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

View File

@@ -1,6 +1,7 @@
import { app } from 'electron'
import { getDataPath } from './utils'
import { isWindows7 } from './utils/runtime'
const isDev = process.env.NODE_ENV === 'development'
@@ -12,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,23 +1,32 @@
interface IBlacklist {
interface IFilterList {
WINDOWS: string[]
MAC?: string[]
}
interface IFinetunedList {
EXCLUDE_CLIPBOARD_CURSOR_DETECT: IFilterList
INCLUDE_CLIPBOARD_DELAY_READ: IFilterList
}
/*************************************************************************
* 注意:请不要修改此配置,除非你非常清楚其含义、影响和行为的目的
* Note: Do not modify this configuration unless you fully understand its meaning, implications, and intended behavior.
* -----------------------------------------------------------------------
* A predefined application filter list to include commonly used software
* that does not require text selection but may conflict with it, and disable them in advance.
* Only available in the selected mode.
*
* Specification: must be all lowercase, need to accurately find the actual running program name
*************************************************************************/
export const SELECTION_PREDEFINED_BLACKLIST: IBlacklist = {
export const SELECTION_PREDEFINED_BLACKLIST: IFilterList = {
WINDOWS: [
// Screenshot
'snipaste.exe',
'pixpin.exe',
'sharex.exe',
// Office
'excel.exe',
'powerpnt.exe',
// Image Editor
'photoshop.exe',
'illustrator.exe',
@@ -32,6 +41,17 @@ export const SELECTION_PREDEFINED_BLACKLIST: IBlacklist = {
'maya.exe',
// CAD
'acad.exe',
'sldworks.exe'
'sldworks.exe',
// Remote Desktop
'mstsc.exe'
]
}
export const SELECTION_FINETUNED_LIST: IFinetunedList = {
EXCLUDE_CLIPBOARD_CURSOR_DETECT: {
WINDOWS: ['acrobat.exe', 'wps.exe', 'cajviewer.exe']
},
INCLUDE_CLIPBOARD_DELAY_READ: {
WINDOWS: ['acrobat.exe', 'wps.exe', 'cajviewer.exe', 'foxitphantom.exe']
}
}

View File

@@ -6,7 +6,7 @@ import { app } from 'electron'
import installExtension, { REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS } from 'electron-devtools-installer'
import Logger from 'electron-log'
import { isDev } from './constant'
import { isDev, isWin } from './constant'
import { registerIpc } from './ipc'
import { configManager } from './services/ConfigManager'
import mcpService from './services/MCPService'
@@ -24,6 +24,16 @@ import { setUserDataDir } from './utils/file'
Logger.initialize()
/**
* Disable chromium's window animations
* main purpose for this is to avoid the transparent window flashing when it is shown
* (especially on Windows for SelectionAssistant Toolbar)
* Know Issue: https://github.com/electron/electron/issues/12130#issuecomment-627198990
*/
if (isWin) {
app.commandLine.appendSwitch('wm-window-animations-disabled')
}
// in production mode, handle uncaught exception and unhandled rejection globally
if (!isDev) {
// handle uncaught exception

File diff suppressed because one or more lines are too long

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,4 +1,5 @@
import { isWin } from '@main/constant'
import { locales } from '@main/utils/locales'
import { IpcChannel } from '@shared/IpcChannel'
import { UpdateInfo } from 'builder-util-runtime'
import { app, BrowserWindow, dialog } from 'electron'
@@ -94,15 +95,22 @@ export default class AppUpdater {
if (!this.releaseInfo) {
return
}
const locale = locales[configManager.getLanguage()]
const { update: updateLocale } = locale.translation
let detail = this.formatReleaseNotes(this.releaseInfo.releaseNotes)
if (detail === '') {
detail = updateLocale.noReleaseNotes
}
dialog
.showMessageBox({
type: 'info',
title: '安装更新',
title: updateLocale.title,
icon,
message: `新版本 ${this.releaseInfo.version} 已准备就绪`,
detail: this.formatReleaseNotes(this.releaseInfo.releaseNotes),
buttons: ['稍后安装', '立即安装'],
message: updateLocale.message.replace('{{version}}', this.releaseInfo.version),
detail,
buttons: [updateLocale.later, updateLocale.install],
defaultId: 1,
cancelId: 0
})
@@ -118,7 +126,7 @@ export default class AppUpdater {
private formatReleaseNotes(releaseNotes: string | ReleaseNoteInfo[] | null | undefined): string {
if (!releaseNotes) {
return '暂无更新说明'
return ''
}
if (typeof releaseNotes === 'string') {

View File

@@ -7,7 +7,7 @@ import Logger from 'electron-log'
import * as fs from 'fs-extra'
import StreamZip from 'node-stream-zip'
import * as path from 'path'
import { createClient, CreateDirectoryOptions, FileStat } from 'webdav'
import { CreateDirectoryOptions, FileStat } from 'webdav'
import WebDav from './WebDav'
import { windowService } from './WindowService'
@@ -340,12 +340,8 @@ class BackupManager {
listWebdavFiles = async (_: Electron.IpcMainInvokeEvent, config: WebDavConfig) => {
try {
const client = createClient(config.webdavHost, {
username: config.webdavUser,
password: config.webdavPass
})
const response = await client.getDirectoryContents(config.webdavPath)
const client = new WebDav(config)
const response = await client.getDirectoryContents()
const files = Array.isArray(response) ? response : response.data
return files

View File

@@ -373,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: '保存文件',

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,4 +1,4 @@
import { SELECTION_PREDEFINED_BLACKLIST } from '@main/configs/SelectionConfig'
import { SELECTION_FINETUNED_LIST, SELECTION_PREDEFINED_BLACKLIST } from '@main/configs/SelectionConfig'
import { isDev, isWin } from '@main/constant'
import { IpcChannel } from '@shared/IpcChannel'
import { BrowserWindow, ipcMain, screen } from 'electron'
@@ -37,6 +37,11 @@ type RelativeOrientation =
| 'middleRight'
| 'center'
enum TriggerMode {
Selected = 'selected',
Ctrlkey = 'ctrlkey'
}
/** SelectionService is a singleton class that manages the selection hook and the toolbar window
*
* Features:
@@ -59,7 +64,7 @@ export class SelectionService {
private initStatus: boolean = false
private started: boolean = false
private triggerMode = 'selected'
private triggerMode = TriggerMode.Selected
private isFollowToolbar = true
private isRemeberWinSize = false
private filterMode = 'default'
@@ -145,17 +150,25 @@ export class SelectionService {
}
private initConfig() {
this.triggerMode = configManager.getSelectionAssistantTriggerMode()
this.triggerMode = configManager.getSelectionAssistantTriggerMode() as TriggerMode
this.isFollowToolbar = configManager.getSelectionAssistantFollowToolbar()
this.isRemeberWinSize = configManager.getSelectionAssistantRemeberWinSize()
this.filterMode = configManager.getSelectionAssistantFilterMode()
this.filterList = configManager.getSelectionAssistantFilterList()
this.setHookGlobalFilterMode(this.filterMode, this.filterList)
this.setHookFineTunedList()
configManager.subscribe(ConfigKeys.SelectionAssistantTriggerMode, (triggerMode: TriggerMode) => {
const oldTriggerMode = this.triggerMode
configManager.subscribe(ConfigKeys.SelectionAssistantTriggerMode, (triggerMode: string) => {
this.triggerMode = triggerMode
this.processTriggerMode()
//trigger mode changed, need to update the filter list
if (oldTriggerMode !== triggerMode) {
this.setHookGlobalFilterMode(this.filterMode, this.filterList)
}
})
configManager.subscribe(ConfigKeys.SelectionAssistantFollowToolbar, (isFollowToolbar: boolean) => {
@@ -193,28 +206,31 @@ export class SelectionService {
if (!this.selectionHook) return
const modeMap = {
default: 0,
whitelist: 1,
blacklist: 2
default: SelectionHook!.FilterMode.DEFAULT,
whitelist: SelectionHook!.FilterMode.INCLUDE_LIST,
blacklist: SelectionHook!.FilterMode.EXCLUDE_LIST
}
let combinedList: string[] = []
let combinedList: string[] = list
let combinedMode = mode
switch (mode) {
case 'blacklist':
//combine the predefined blacklist with the user-defined blacklist
combinedList = [...new Set([...list, ...SELECTION_PREDEFINED_BLACKLIST.WINDOWS])]
break
case 'whitelist':
combinedList = [...list]
break
case 'default':
default:
//use the predefined blacklist as the default filter list
combinedList = [...SELECTION_PREDEFINED_BLACKLIST.WINDOWS]
combinedMode = 'blacklist'
break
//only the selected mode need to combine the predefined blacklist with the user-defined blacklist
if (this.triggerMode === TriggerMode.Selected) {
switch (mode) {
case 'blacklist':
//combine the predefined blacklist with the user-defined blacklist
combinedList = [...new Set([...list, ...SELECTION_PREDEFINED_BLACKLIST.WINDOWS])]
break
case 'whitelist':
combinedList = [...list]
break
case 'default':
default:
//use the predefined blacklist as the default filter list
combinedList = [...SELECTION_PREDEFINED_BLACKLIST.WINDOWS]
combinedMode = 'blacklist'
break
}
}
if (!this.selectionHook.setGlobalFilterMode(modeMap[combinedMode], combinedList)) {
@@ -222,6 +238,20 @@ export class SelectionService {
}
}
private setHookFineTunedList() {
if (!this.selectionHook) return
this.selectionHook.setFineTunedList(
SelectionHook!.FineTunedListType.EXCLUDE_CLIPBOARD_CURSOR_DETECT,
SELECTION_FINETUNED_LIST.EXCLUDE_CLIPBOARD_CURSOR_DETECT.WINDOWS
)
this.selectionHook.setFineTunedList(
SelectionHook!.FineTunedListType.INCLUDE_CLIPBOARD_DELAY_READ,
SELECTION_FINETUNED_LIST.INCLUDE_CLIPBOARD_DELAY_READ.WINDOWS
)
}
/**
* Start the selection service and initialize required windows
* @returns {boolean} Success status of service start
@@ -274,7 +304,12 @@ export class SelectionService {
if (!this.selectionHook) return false
this.selectionHook.stop()
this.selectionHook.cleanup()
this.selectionHook.cleanup() //already remove all listeners
//reset the listener states
this.isCtrlkeyListenerActive = false
this.isHideByMouseKeyListenerActive = false
if (this.toolbarWindow) {
this.toolbarWindow.close()
this.toolbarWindow = null
@@ -324,7 +359,7 @@ export class SelectionService {
hasShadow: false,
thickFrame: false,
roundedCorners: true,
backgroundMaterial: 'none',
// backgroundMaterial: 'none',
type: 'toolbar',
show: false,
webPreferences: {
@@ -774,7 +809,7 @@ export class SelectionService {
*/
private handleKeyDownHide = (data: KeyboardEventData) => {
//dont hide toolbar when ctrlkey is pressed
if (this.triggerMode === 'ctrlkey' && this.isCtrlkey(data.vkCode)) {
if (this.triggerMode === TriggerMode.Ctrlkey && this.isCtrlkey(data.vkCode)) {
return
}
//dont hide toolbar when shiftkey is pressed, because it's used for selection
@@ -806,6 +841,8 @@ export class SelectionService {
//ctrlkey pressed
if (this.lastCtrlkeyDownTime === 0) {
this.lastCtrlkeyDownTime = Date.now()
//add the mouse-wheel listener, detect if user is zooming in/out
this.selectionHook!.on('mouse-wheel', this.handleMouseWheelCtrlkeyMode)
return
}
@@ -829,9 +866,20 @@ export class SelectionService {
*/
private handleKeyUpCtrlkeyMode = (data: KeyboardEventData) => {
if (!this.isCtrlkey(data.vkCode)) return
//remove the mouse-wheel listener
this.selectionHook!.off('mouse-wheel', this.handleMouseWheelCtrlkeyMode)
this.lastCtrlkeyDownTime = 0
}
/**
* Handle mouse wheel events in ctrlkey trigger mode
* ignore CtrlKey pressing when mouse wheel is used
* because user is zooming in/out
*/
private handleMouseWheelCtrlkeyMode = () => {
this.lastCtrlkeyDownTime = -1
}
//check if the key is ctrl key
private isCtrlkey(vkCode: number) {
return vkCode === 162 || vkCode === 163
@@ -1042,7 +1090,7 @@ export class SelectionService {
* Manages appropriate event listeners for each mode
*/
private processTriggerMode() {
if (this.triggerMode === 'selected') {
if (this.triggerMode === TriggerMode.Selected) {
if (this.isCtrlkeyListenerActive) {
this.selectionHook!.off('key-down', this.handleKeyDownCtrlkeyMode)
this.selectionHook!.off('key-up', this.handleKeyUpCtrlkeyMode)
@@ -1051,7 +1099,7 @@ export class SelectionService {
}
this.selectionHook!.setSelectionPassiveMode(false)
} else if (this.triggerMode === 'ctrlkey') {
} else if (this.triggerMode === TriggerMode.Ctrlkey) {
if (!this.isCtrlkeyListenerActive) {
this.selectionHook!.on('key-down', this.handleKeyDownCtrlkeyMode)
this.selectionHook!.on('key-up', this.handleKeyUpCtrlkeyMode)

View File

@@ -1,6 +1,7 @@
import { WebDavConfig } from '@types'
import Logger from 'electron-log'
import Stream from 'stream'
import https from 'https'
import {
BufferLike,
createClient,
@@ -20,7 +21,8 @@ export default class WebDav {
username: params.webdavUser,
password: params.webdavPass,
maxBodyLength: Infinity,
maxContentLength: Infinity
maxContentLength: Infinity,
httpsAgent: new https.Agent({ rejectUnauthorized: false })
})
this.putFileContents = this.putFileContents.bind(this)
@@ -74,6 +76,19 @@ export default class WebDav {
}
}
public getDirectoryContents = async () => {
if (!this.instance) {
throw new Error('WebDAV client not initialized')
}
try {
return await this.instance.getDirectoryContents(this.webdavPath)
} catch (error) {
Logger.error('[WebDAV] Error getting directory contents on WebDAV:', error)
throw error
}
}
public checkConnection = async () => {
if (!this.instance) {
throw new Error('WebDAV client not initialized')

View File

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

Binary file not shown.

View File

@@ -2,7 +2,7 @@ import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
import { electronAPI } from '@electron-toolkit/preload'
import { IpcChannel } from '@shared/IpcChannel'
import { FileType, KnowledgeBaseParams, KnowledgeItem, MCPServer, Shortcut, ThemeMode, WebDavConfig } from '@types'
import { contextBridge, ipcRenderer, OpenDialogOptions, shell, webUtils } from 'electron'
import { contextBridge, ipcRenderer, OpenDialogOptions, shell } from 'electron'
import { Notification } from 'src/renderer/src/types/notification'
import { CreateDirectoryOptions } from 'webdav'
@@ -81,7 +81,11 @@ const api = {
copy: (fileId: string, destPath: string) => ipcRenderer.invoke(IpcChannel.File_Copy, fileId, destPath),
binaryImage: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_BinaryImage, fileId),
base64File: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Base64File, fileId),
getPathForFile: (file: File) => webUtils.getPathForFile(file)
getPathForFile: (file: File) => {
const electronFile = file as File & { path?: string }
return electronFile.path || null
}
// getPathForFile: (file: File) => webUtils.getPathForFile(file)
},
fs: {
read: (path: string) => ipcRenderer.invoke(IpcChannel.Fs_Read, path)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 550 KiB

View File

@@ -26,6 +26,7 @@
--color-primary-mute: #00b96b33;
--color-text: var(--color-text-1);
--color-text-secondary: rgba(235, 235, 245, 0.7);
--color-icon: #ffffff99;
--color-icon-white: #ffffff;
--color-border: #ffffff19;
@@ -43,6 +44,9 @@
--color-reference-text: #ffffff;
--color-reference-background: #0b0e12;
--color-list-item: #222;
--color-list-item-hover: #1e1e1e;
--modal-background: #1f1f1f;
--color-highlight: rgba(0, 0, 0, 1);
@@ -67,7 +71,7 @@
--chat-background-assistant: #2c2c2c;
--chat-text-user: var(--color-black);
--list-item-border-radius: 16px;
--list-item-border-radius: 20px;
}
[theme-mode='light'] {
@@ -98,6 +102,7 @@
--color-primary-mute: #00b96b33;
--color-text: var(--color-text-1);
--color-text-secondary: rgba(0, 0, 0, 0.75);
--color-icon: #00000099;
--color-icon-white: #000000;
--color-border: #00000019;
@@ -115,6 +120,9 @@
--color-reference-text: #000000;
--color-reference-background: #f1f7ff;
--color-list-item: #eee;
--color-list-item-hover: #f5f5f5;
--modal-background: var(--color-white);
--color-highlight: initial;

View File

@@ -10,3 +10,7 @@
width: 100%;
}
}
.context-menu-container {
max-width: 100%;
}

View File

@@ -306,9 +306,14 @@ mjx-container {
/* CodeMirror 相关样式 */
.cm-editor {
border-radius: 5px;
&.cm-focused {
outline: none;
}
.cm-scroller {
font-family: var(--code-font-family);
padding: 1px;
border-radius: 5px;
.cm-gutters {

View File

@@ -231,7 +231,6 @@ const ContentContainer = styled.div<{
$wrap: boolean
$fadeIn: boolean
}>`
display: block;
position: relative;
overflow: auto;
border: 0.5px solid transparent;
@@ -239,12 +238,11 @@ const ContentContainer = styled.div<{
margin-top: 0;
.shiki {
display: flex;
min-width: 100%;
padding: 1em;
code {
display: block;
display: flex;
flex-direction: column;
.line {
display: block;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -23,7 +23,8 @@ import { setMinappsOpenLinkExternal } from '@renderer/store/settings'
import { MinAppType } from '@renderer/types'
import { delay } from '@renderer/utils'
import { Avatar, Drawer, Tooltip } from 'antd'
import { WebviewTag } from 'electron'
// import { WebviewTag } from 'electron'
declare type WebviewTag = any
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import BeatLoader from 'react-spinners/BeatLoader'

View File

@@ -1,4 +1,5 @@
import { WebviewTag } from 'electron'
// import { WebviewTag } from 'electron'
declare type WebviewTag = any
import { memo, useEffect, useRef } from 'react'
/**

View File

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

View File

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

View File

@@ -1,27 +1,5 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Scrollbar > props handling > should handle right prop correctly 1`] = `
.c0 {
overflow-y: auto;
}
.c0::-webkit-scrollbar-thumb {
transition: background 2s ease;
background: transparent;
}
.c0::-webkit-scrollbar-thumb:hover {
background: transparent;
}
<div
class="c0"
data-testid="scrollbar"
>
内容
</div>
`;
exports[`Scrollbar > rendering > should match default styled snapshot 1`] = `
.c0 {
overflow-y: auto;

View File

@@ -9,6 +9,7 @@ import { useMinapps } from '@renderer/hooks/useMinapps'
import useNavBackgroundColor from '@renderer/hooks/useNavBackgroundColor'
import { modelGenerating, useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings'
import i18n from '@renderer/i18n'
import { ThemeMode } from '@renderer/types'
import { isEmoji } from '@renderer/utils'
import type { MenuProps } from 'antd'
@@ -19,7 +20,7 @@ import {
Folder,
Languages,
LayoutGrid,
MessageSquareQuote,
MessageSquare,
Moon,
Palette,
Settings,
@@ -62,10 +63,11 @@ const Sidebar: FC = () => {
const docsId = 'cherrystudio-docs'
const onOpenDocs = () => {
const isChinese = i18n.language.startsWith('zh')
openMinapp({
id: docsId,
name: t('docs.title'),
url: 'https://docs.cherry-ai.com/',
url: isChinese ? 'https://docs.cherry-ai.com/' : 'https://docs.cherry-ai.com/cherry-studio-wen-dang/en-us',
logo: AppLogo
})
}
@@ -147,7 +149,7 @@ const MainMenus: FC = () => {
const isRoutes = (path: string): string => (pathname.startsWith(path) && !minappShow ? 'active' : '')
const iconMap = {
assistants: <MessageSquareQuote size={18} className="icon" />,
assistants: <MessageSquare size={18} className="icon" />,
agents: <Sparkle size={18} className="icon" />,
paintings: <Palette size={18} className="icon" />,
translate: <Languages size={18} className="icon" />,

View File

@@ -1,5 +1,8 @@
import * as AppMeta from '../../../shared/app-meta'
export { default as UserAvatar } from '@renderer/assets/images/avatar.png'
export { default as AppLogo } from '@renderer/assets/images/logo.png'
export const APP_NAME = 'Cherry Studio'
export const APP_NAME = AppMeta.APP_NAME
export const APP_IS_CUSTOM_PRODUCT = AppMeta.APP_IS_CUSTOM_PRODUCT
export const isLocalAi = false

View File

@@ -1348,15 +1348,39 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
],
grok: [
{
id: 'grok-beta',
id: 'grok-3',
provider: 'grok',
name: 'Grok Beta',
name: 'Grok 3',
group: 'Grok'
},
{
id: 'grok-vision-beta',
id: 'grok-3-fast',
provider: 'grok',
name: 'Grok Vision Beta',
name: 'Grok 3 Fast',
group: 'Grok'
},
{
id: 'grok-3-mini',
provider: 'grok',
name: 'Grok 3 Mini',
group: 'Grok'
},
{
id: 'grok-3-mini-fast',
provider: 'grok',
name: 'Grok 3 Mini Fast',
group: 'Grok'
},
{
id: 'grok-2-vision-1212',
provider: 'grok',
name: 'Grok 2 Vision 1212',
group: 'Grok'
},
{
id: 'grok-2-1212',
provider: 'grok',
name: 'Grok 2 1212',
group: 'Grok'
}
],
@@ -2315,7 +2339,8 @@ export function isSupportedThinkingTokenQwenModel(model?: Model): boolean {
}
return (
model.id.toLowerCase().includes('qwen3') ||
model.id.toLowerCase().startsWith('qwen3') ||
model.id.toLowerCase().startsWith('qwen/qwen3') ||
[
'qwen-plus-latest',
'qwen-plus-0428',

View File

@@ -124,8 +124,8 @@ export const PROVIDER_CONFIG = {
websites: {
official: 'https://o3.fan',
apiKey: 'https://o3.fan/token',
docs: 'https://docs.o3.fan',
models: 'https://docs.o3.fan/models'
docs: '',
models: 'https://o3.fan/info/models/'
}
},
burncloud: {
@@ -394,7 +394,7 @@ export const PROVIDER_CONFIG = {
official: 'https://openrouter.ai/',
apiKey: 'https://openrouter.ai/settings/keys',
docs: 'https://openrouter.ai/docs/quick-start',
models: 'https://openrouter.ai/docs/models'
models: 'https://openrouter.ai/models'
}
},
groq: {
@@ -446,7 +446,7 @@ export const PROVIDER_CONFIG = {
websites: {
official: 'https://x.ai/',
docs: 'https://docs.x.ai/',
models: 'https://docs.x.ai/docs#getting-started'
models: 'https://docs.x.ai/docs/models'
}
},
hyperbolic: {

View File

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

View File

@@ -45,6 +45,13 @@ const AntdProvider: FC<PropsWithChildren> = ({ children }) => {
},
Tooltip: {
fontSize: 13
},
ColorPicker: {
fontFamily: 'var(--code-font-family)'
},
Segmented: {
itemActiveBg: 'var(--color-background-mute)',
itemHoverBg: 'var(--color-background-mute)'
}
},
token: {

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ import {
setLaunchToTray,
setPinTopicsToTop,
setSendMessageShortcut as _setSendMessageShortcut,
setShowTokens,
setSidebarIcons,
setTargetLanguage,
setTheme,
@@ -83,6 +84,9 @@ export function useSettings() {
},
setAssistantIconType(assistantIconType: AssistantIconType) {
dispatch(setAssistantIconType(assistantIconType))
},
setShowTokens(showTokens: boolean) {
dispatch(setShowTokens(showTokens))
}
}
}

View File

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

View File

@@ -322,6 +322,7 @@
"translate": "Translate",
"topics.export.siyuan": "Export to Siyuan Note",
"topics.export.wait_for_title_naming": "Generating title...",
"topics.export.obsidian_reasoning": "Include Reasoning Chain",
"topics.export.title_naming_success": "Title generated successfully",
"topics.export.title_naming_failed": "Failed to generate title, using default title",
"input.translating": "Translating...",
@@ -586,7 +587,14 @@
"korean": "Korean",
"portuguese": "Portuguese",
"russian": "Russian",
"spanish": "Spanish"
"spanish": "Spanish",
"polish": "Polish",
"turkish": "Turkish",
"thai": "Thai",
"vietnamese": "Vietnamese",
"indonesian": "Indonesian",
"urdu": "Urdu",
"malay": "Malay"
},
"lmstudio": {
"keep_alive_time.description": "The time in minutes to keep the connection alive, default is 5 minutes.",
@@ -627,6 +635,7 @@
"error.enter.api.key": "Please enter your API key first",
"error.enter.model": "Please select a model first",
"error.enter.name": "Please enter the name of the knowledge base",
"error.fetchTopicName": "Failed to name the topic",
"error.get_embedding_dimensions": "Failed to get embedding dimensions",
"error.invalid.api.host": "Invalid API Host",
"error.invalid.api.key": "Invalid API Key",
@@ -943,8 +952,17 @@
"text_desc_required": "Please enter image description first",
"req_error_text": "Operation failed. Please try again. Avoid using 'copyrighted' or 'sensitive' words in your prompt.",
"auto_create_paint": "Auto-create image",
"auto_create_paint_tip": "After the image is generated, a new image will be created automatically."
"auto_create_paint_tip": "After the image is generated, a new image will be created automatically.",
"select_model": "Select Model",
"input_parameters": "Input Parameters",
"input_image": "Input Image",
"generated_image": "Generated Image",
"pricing": "Pricing",
"model_and_pricing": "Model & Pricing",
"per_image": "per image",
"per_images": "per images",
"required_field": "Required field",
"uploaded_input": "Uploaded input"
},
"prompts": {
"explanation": "Explain this concept to me",
@@ -1097,7 +1115,9 @@
"token": "Joplin Authorization Token",
"token_placeholder": "Joplin Authorization Token",
"url": "Joplin Web Clipper Service URL",
"url_placeholder": "http://127.0.0.1:41184/"
"url_placeholder": "http://127.0.0.1:41184/",
"export_reasoning.title": "Include Reasoning Chain in Export",
"export_reasoning.help": "When enabled, the exported content will include the reasoning chain (thought process) generated by the assistant."
},
"markdown_export.force_dollar_math.help": "When enabled, $$ will be forcibly used to mark LaTeX formulas when exporting to Markdown. Note: This option also affects all export methods through Markdown, such as Notion, Yuque, etc.",
"markdown_export.force_dollar_math.title": "Force $$ for LaTeX formulas",
@@ -1106,12 +1126,14 @@
"markdown_export.path_placeholder": "Export Path",
"markdown_export.select": "Select",
"markdown_export.title": "Markdown Export",
"markdown_export.show_model_name.title": "Use Model Name on Export",
"markdown_export.show_model_name.help": "When enabled, the topic-naming model will be used to create titles for exported messages. Note: This option also affects all export methods through Markdown, such as Notion, Yuque, etc.",
"markdown_export.show_model_provider.title": "Show Model Provider",
"markdown_export.show_model_provider.help": "Display the model provider (e.g., OpenAI, Gemini) when exporting to Markdown",
"minute_interval_one": "{{count}} minute",
"minute_interval_other": "{{count}} minutes",
"notion.api_key": "Notion API Key",
"notion.api_key_placeholder": "Enter Notion API Key",
"notion.auto_split": "Auto split when exporting",
"notion.auto_split_tip": "Automatically split pages when exporting long topics to Notion",
"notion.check": {
"button": "Check",
"empty_api_key": "API key is not configured",
@@ -1125,10 +1147,9 @@
"notion.help": "Notion Configuration Documentation",
"notion.page_name_key": "Page Title Field Name",
"notion.page_name_key_placeholder": "Enter page title field name, default is Name",
"notion.split_size": "Split size",
"notion.split_size_help": "Recommended: 90 for Free plan, 24990 for Plus plan, default is 90",
"notion.split_size_placeholder": "Enter block limit per page (default 90)",
"notion.title": "Notion Configuration",
"notion.title": "Notion Settings",
"notion.export_reasoning.title": "Include Reasoning Chain in Export",
"notion.export_reasoning.help": "When enabled, exported content will include reasoning chain (thought process).",
"title": "Data Settings",
"webdav": {
"autoSync": "Auto Backup",
@@ -1491,6 +1512,7 @@
"advancedSettings": "Advanced Settings"
},
"messages.prompt": "Show prompt",
"messages.tokens": "Show token usage",
"messages.divider": "Show divider between messages",
"messages.grid_columns": "Message grid display columns",
"messages.grid_popover_trigger": "Grid detail trigger",
@@ -1794,6 +1816,8 @@
},
"translate": {
"any.language": "Any language",
"target_language": "Target Language",
"alter_language": "Alternative Language",
"button.translate": "Translate",
"close": "Close",
"closed": "Translation closed",
@@ -1844,6 +1868,13 @@
"show_window": "Show Window",
"visualization": "Visualization"
},
"update": {
"title": "Update",
"message": "New version {{version}} is ready, do you want to install it now?",
"later": "Later",
"install": "Install",
"noReleaseNotes": "No release notes"
},
"selection": {
"name": "Selection Assistant",
"action": {
@@ -1866,6 +1897,9 @@
"esc_stop": "Esc: Stop",
"c_copy": "C: Copy",
"r_regenerate": "R: Regenerate"
},
"translate": {
"smart_translate_tips": "Smart Translation: Content will be translated to the target language first; content already in the target language will be translated to the alternative language"
}
},
"settings": {

View File

@@ -322,6 +322,7 @@
"translate": "翻訳",
"topics.export.siyuan": "思源笔记にエクスポート",
"topics.export.wait_for_title_naming": "タイトルを生成中...",
"topics.export.obsidian_reasoning": "思考過程を含める",
"topics.export.title_naming_success": "タイトルの生成に成功しました",
"topics.export.title_naming_failed": "タイトルの生成に失敗しました。デフォルトのタイトルを使用します",
"input.translating": "翻訳中...",
@@ -586,7 +587,14 @@
"korean": "韓国語",
"portuguese": "ポルトガル語",
"russian": "ロシア語",
"spanish": "スペイン語"
"spanish": "スペイン語",
"polish": "ポーランド語",
"turkish": "トルコ語",
"thai": "タイ語",
"vietnamese": "ベトナム語",
"indonesian": "インドネシア語",
"urdu": "ウルドゥー語",
"malay": "マレー語"
},
"lmstudio": {
"keep_alive_time.description": "モデルがメモリに保持される時間デフォルト5分",
@@ -627,6 +635,7 @@
"error.enter.api.key": "APIキーを入力してください",
"error.enter.model": "モデルを選択してください",
"error.enter.name": "ナレッジベース名を入力してください",
"error.fetchTopicName": "トピックの命名に失敗しました",
"error.get_embedding_dimensions": "埋込み次元を取得できませんでした",
"error.invalid.api.host": "無効なAPIアドレスです",
"error.invalid.api.key": "無効なAPIキーです",
@@ -943,7 +952,17 @@
"text_desc_required": "画像の説明を先に入力してください",
"req_error_text": "実行に失敗しました。もう一度お試しください。プロンプトに「著作権用語」や「センシティブな用語」を含めないでください。",
"auto_create_paint": "画像を自動作成",
"auto_create_paint_tip": "画像が生成された後、自動的に新しい画像が作成されます。"
"auto_create_paint_tip": "画像が生成された後、自動的に新しい画像が作成されます。",
"select_model": "モデルを選択",
"input_parameters": "パラメータ入力",
"input_image": "入力画像",
"generated_image": "生成画像",
"pricing": "料金",
"model_and_pricing": "モデルと料金",
"per_image": "1枚あたり",
"per_images": "複数枚あたり",
"required_field": "必須項目",
"uploaded_input": "アップロード済みの入力"
},
"prompts": {
"explanation": "この概念を説明してください",
@@ -1094,7 +1113,9 @@
"token": "Joplin 認証トークン",
"token_placeholder": "Joplin 認証トークンを入力してください",
"url": "Joplin 剪輯服務 URL",
"url_placeholder": "http://127.0.0.1:41184/"
"url_placeholder": "http://127.0.0.1:41184/",
"export_reasoning.title": "エクスポート時に思考過程を含める",
"export_reasoning.help": "有効にすると、エクスポートされる内容にアシスタントが生成した思考過程(リースニングチェーン)が含まれます。"
},
"markdown_export.force_dollar_math.help": "有効にすると、Markdownにエクスポートする際にLaTeX数式を$$で強制的にマークします。注意この設定はNotion、Yuqueなど、Markdownを通じたすべてのエクスポート方法にも影響します。",
"markdown_export.force_dollar_math.title": "LaTeX数式に$$を強制使用",
@@ -1103,29 +1124,12 @@
"markdown_export.path_placeholder": "エクスポートパス",
"markdown_export.select": "選択",
"markdown_export.title": "Markdown エクスポート",
"markdown_export.show_model_name.title": "エクスポート時にモデル名を使用",
"markdown_export.show_model_name.help": "有効にすると、トピック命名モデルがエクスポートされたメッセージのタイトル作成に使用されます。注意この設定はNotion、Yuqueなど、Markdownを通じたすべてのエクスポート方法にも影響します。",
"markdown_export.show_model_provider.title": "モデルプロバイダーを表示",
"markdown_export.show_model_provider.help": "Markdownエクスポート時にモデルプロバイダーOpenAI、Geminiなどを表示します。",
"minute_interval_one": "{{count}} 分",
"minute_interval_other": "{{count}} 分",
"notion.api_key": "Notion APIキー",
"notion.api_key_placeholder": "Notion APIキーを入力してください",
"notion.auto_split": "ダイアログをエクスポートすると自動ページ分割",
"notion.auto_split_tip": "ダイアログが長い場合、Notionに自動的にページ分割してエクスポートします",
"notion.check": {
"button": "確認",
"empty_api_key": "Api_keyが設定されていません",
"empty_database_id": "Database_idが設定されていません",
"error": "接続エラー、ネットワーク設定とApi_keyとDatabase_idを確認してください",
"fail": "接続エラー、ネットワーク設定とApi_keyとDatabase_idを確認してください",
"success": "接続に成功しました。"
},
"notion.database_id": "Notion データベースID",
"notion.database_id_placeholder": "Notion データベースIDを入力してください",
"notion.help": "Notion 設定ドキュメント",
"notion.page_name_key": "ページタイトルフィールド名",
"notion.page_name_key_placeholder": "ページタイトルフィールド名を入力してください。デフォルトは Name です",
"notion.split_size": "自動ページ分割サイズ",
"notion.split_size_help": "Notion無料版ユーザーは90、有料版ユーザーは24990、デフォルトは90",
"notion.split_size_placeholder": "ページごとのブロック数制限を入力してください(デフォルト90)",
"notion.title": "Notion 設定",
"title": "データ設定",
"webdav": {
"autoSync": "自動バックアップ",
@@ -1245,7 +1249,25 @@
"new_folder.button": "新しいフォルダー"
},
"message_title.use_topic_naming.title": "トピック命名モデルを使用してメッセージのタイトルを作成",
"message_title.use_topic_naming.help": "この設定は、すべてのMarkdownエクスポート方法に影響します。"
"message_title.use_topic_naming.help": "この設定は、すべてのMarkdownエクスポート方法に影響します。",
"notion.api_key": "Notion APIキー",
"notion.api_key_placeholder": "Notion APIキーを入力してください",
"notion.check": {
"button": "確認",
"empty_api_key": "Api_keyが設定されていません",
"empty_database_id": "Database_idが設定されていません",
"error": "接続エラー、ネットワーク設定とApi_keyとDatabase_idを確認してください",
"fail": "接続エラー、ネットワーク設定とApi_keyとDatabase_idを確認してください",
"success": "接続に成功しました。"
},
"notion.database_id": "Notion データベースID",
"notion.database_id_placeholder": "Notion データベースIDを入力してください",
"notion.help": "Notion 設定ドキュメント",
"notion.page_name_key": "ページタイトルフィールド名",
"notion.page_name_key_placeholder": "ページタイトルフィールド名を入力してください。デフォルトは Name です",
"notion.title": "Notion 設定",
"notion.export_reasoning.title": "エクスポート時に思考チェーンを含める",
"notion.export_reasoning.help": "有効にすると、Notionにエクスポートする際に思考チェーンの内容が含まれます。"
},
"display.assistant.title": "アシスタント設定",
"display.custom.css": "カスタムCSS",
@@ -1486,6 +1508,7 @@
"advancedSettings": "詳細設定"
},
"messages.prompt": "プロンプト表示",
"messages.tokens": "トークン使用量を表示",
"messages.divider": "メッセージ間に区切り線を表示",
"messages.grid_columns": "メッセージグリッドの表示列数",
"messages.grid_popover_trigger": "グリッド詳細トリガー",
@@ -1793,6 +1816,8 @@
},
"translate": {
"any.language": "任意の言語",
"target_language": "目標言語",
"alter_language": "備用言語",
"button.translate": "翻訳",
"close": "閉じる",
"closed": "翻訳は閉じられました",
@@ -1843,6 +1868,13 @@
"show_window": "ウィンドウを表示",
"visualization": "可視化"
},
"update": {
"title": "更新",
"message": "新バージョン {{version}} が利用可能です。今すぐインストールしますか?",
"later": "後で",
"install": "今すぐインストール",
"noReleaseNotes": "暫無更新日誌"
},
"selection": {
"name": "テキスト選択ツール",
"action": {
@@ -1865,6 +1897,9 @@
"esc_stop": "Escで停止",
"c_copy": "Cでコピー",
"r_regenerate": "Rで再生成"
},
"translate": {
"smart_translate_tips": "スマート翻訳:内容は優先的に目標言語に翻訳されます。すでに目標言語の場合は、備用言語に翻訳されます。"
}
},
"settings": {

View File

@@ -322,6 +322,7 @@
"translate": "Перевести",
"topics.export.siyuan": "Экспорт в Siyuan Note",
"topics.export.wait_for_title_naming": "Создание заголовка...",
"topics.export.obsidian_reasoning": "Включить цепочку рассуждений",
"topics.export.title_naming_success": "Заголовок успешно создан",
"topics.export.title_naming_failed": "Не удалось создать заголовок, используется заголовок по умолчанию",
"input.translating": "Перевод...",
@@ -586,7 +587,14 @@
"korean": "Корейский",
"portuguese": "Португальский",
"russian": "Русский",
"spanish": "Испанский"
"spanish": "Испанский",
"polish": "Польский",
"turkish": "Туркменский",
"thai": "Тайский",
"vietnamese": "Вьетнамский",
"indonesian": "Индонезийский",
"urdu": "Урду",
"malay": "Малайзийский"
},
"lmstudio": {
"keep_alive_time.description": "Время в минутах, в течение которого модель остается активной, по умолчанию 5 минут.",
@@ -627,6 +635,7 @@
"error.enter.api.key": "Пожалуйста, введите ваш API ключ",
"error.enter.model": "Пожалуйста, выберите модель",
"error.enter.name": "Пожалуйста, введите название базы знаний",
"error.fetchTopicName": "Не удалось назвать тему",
"error.get_embedding_dimensions": "Не удалось получить размерность встраивания",
"error.invalid.api.host": "Неверный API адрес",
"error.invalid.api.key": "Неверный API ключ",
@@ -943,7 +952,17 @@
"text_desc_required": "Пожалуйста, сначала введите описание изображения",
"req_error_text": "Операция не удалась, повторите попытку. Пожалуйста, избегайте защищенных авторским правом терминов и конфиденциальных слов в запросах.",
"auto_create_paint": "Автоматическое создание изображения",
"auto_create_paint_tip": "После генерации изображения будет автоматически создано новое."
"auto_create_paint_tip": "После генерации изображения будет автоматически создано новое.",
"select_model": "Выбрать модель",
"input_parameters": "Ввести параметры",
"input_image": "Входное изображение",
"generated_image": "Сгенерированное изображение",
"pricing": "Цены",
"model_and_pricing": "Модель и цены",
"per_image": "за изображение",
"per_images": "за изображения",
"required_field": "Обязательное поле",
"uploaded_input": "Загруженный ввод"
},
"prompts": {
"explanation": "Объясните мне этот концепт",
@@ -1094,7 +1113,9 @@
"token": "Токен Joplin",
"token_placeholder": "Введите токен Joplin",
"url": "URL Joplin",
"url_placeholder": "http://127.0.0.1:41184/"
"url_placeholder": "http://127.0.0.1:41184/",
"export_reasoning.title": "Включить цепочку рассуждений при экспорте",
"export_reasoning.help": "Если включено, экспортируемый контент будет содержать цепочку рассуждений, сгенерированную ассистентом."
},
"markdown_export.force_dollar_math.help": "Если включено, при экспорте в Markdown для обозначения формул LaTeX будет принудительно использоваться $$. Примечание: Эта опция также влияет на все методы экспорта через Markdown, такие как Notion, Yuque и т.д.",
"markdown_export.force_dollar_math.title": "Принудительно использовать $$ для формул LaTeX",
@@ -1103,12 +1124,14 @@
"markdown_export.path_placeholder": "Путь экспорта",
"markdown_export.select": "Выбрать",
"markdown_export.title": "Экспорт в Markdown",
"markdown_export.show_model_name.title": "Использовать имя модели при экспорте",
"markdown_export.show_model_name.help": "Если включено, для создания заголовков экспортируемых сообщений будет использоваться модель именования темы. Примечание: Эта опция также влияет на все методы экспорта через Markdown, такие как Notion, Yuque и т.д.",
"markdown_export.show_model_provider.title": "Показать поставщика модели",
"markdown_export.show_model_provider.help": "Показывать поставщика модели (например, OpenAI, Gemini) при экспорте в Markdown",
"minute_interval_one": "{{count}} минута",
"minute_interval_other": "{{count}} минут",
"notion.api_key": "Ключ API Notion",
"notion.api_key_placeholder": "Введите ключ API Notion",
"notion.auto_split": "Автоматическое разбиение на страницы при экспорте диалога",
"notion.auto_split_tip": "Автоматическое разбиение на страницы при экспорте в Notion, если тема слишком длинная",
"notion.check": {
"button": "Проверить",
"empty_api_key": "Не настроен API key",
@@ -1122,10 +1145,9 @@
"notion.help": "Документация по настройке Notion",
"notion.page_name_key": "Название поля заголовка страницы",
"notion.page_name_key_placeholder": "Введите название поля заголовка страницы, по умолчанию Name",
"notion.split_size": "Размер автоматического разбиения",
"notion.split_size_help": "Рекомендуется 90 для пользователей бесплатной версии Notion, 24990 для пользователей премиум-версии, значение по умолчанию — 90",
"notion.split_size_placeholder": "Введите ограничение количества блоков на странице (по умолчанию 90)",
"notion.title": "Настройки Notion",
"notion.export_reasoning.title": "Включить цепочку рассуждений при экспорте",
"notion.export_reasoning.help": "При включении, содержимое цепочки рассуждений будет включено при экспорте в Notion.",
"title": "Настройки данных",
"webdav": {
"autoSync": "Автоматическое резервное копирование",
@@ -1486,6 +1508,7 @@
"advancedSettings": "Расширенные настройки"
},
"messages.prompt": "Показывать подсказки",
"messages.tokens": "Показать использование токенов",
"messages.divider": "Показывать разделитель между сообщениями",
"messages.grid_columns": "Количество столбцов сетки сообщений",
"messages.grid_popover_trigger": "Триггер для отображения подробной информации в сетке",
@@ -1793,6 +1816,8 @@
},
"translate": {
"any.language": "Любой язык",
"target_language": "Целевой язык",
"alter_language": "Альтернативный язык",
"button.translate": "Перевести",
"close": "Закрыть",
"closed": "Перевод закрыт",
@@ -1843,6 +1868,13 @@
"show_window": "Показать окно",
"visualization": "Визуализация"
},
"update": {
"title": "Обновление",
"message": "Новая версия {{version}} готова, установить сейчас?",
"later": "Позже",
"install": "Установить",
"noReleaseNotes": "Нет заметок об обновлении"
},
"selection": {
"name": "Помощник выбора",
"action": {
@@ -1865,6 +1897,9 @@
"esc_stop": "Esc - остановить",
"c_copy": "C - копировать",
"r_regenerate": "R - перегенерировать"
},
"translate": {
"smart_translate_tips": "Смарт-перевод: содержимое будет переведено на целевой язык; содержимое уже на целевом языке будет переведено на альтернативный язык"
}
},
"settings": {

View File

@@ -325,6 +325,7 @@
"topics.export.obsidian_no_vault_selected": "请先选择一个保管库",
"topics.export.obsidian_select_vault_first": "请先选择保管库",
"topics.export.obsidian_root_directory": "根目录",
"topics.export.obsidian_reasoning": "导出思维链",
"topics.export.title": "导出",
"topics.export.word": "导出为 Word",
"topics.export.yuque": "导出到语雀",
@@ -586,7 +587,14 @@
"korean": "韩文",
"portuguese": "葡萄牙文",
"russian": "俄文",
"spanish": "西班牙文"
"spanish": "西班牙文",
"polish": "波兰文",
"turkish": "土耳其文",
"thai": "泰文",
"vietnamese": "越南文",
"indonesian": "印尼文",
"urdu": "乌尔都文",
"malay": "马来文"
},
"lmstudio": {
"keep_alive_time.description": "对话后模型在内存中保持的时间默认5分钟",
@@ -627,6 +635,7 @@
"error.enter.api.key": "请输入您的 API 密钥",
"error.enter.model": "请选择一个模型",
"error.enter.name": "请输入知识库名称",
"error.fetchTopicName": "话题命名失败",
"error.get_embedding_dimensions": "获取嵌入维度失败",
"error.invalid.api.host": "无效的 API 地址",
"error.invalid.api.key": "无效的 API 密钥",
@@ -646,7 +655,7 @@
"group.delete.content": "删除分组消息会删除用户提问和所有助手的回答",
"group.delete.title": "删除分组消息",
"ignore.knowledge.base": "联网模式开启,忽略知识库",
"info.notion.block_reach_limit": "对话过长,正在分导出到Notion",
"info.notion.block_reach_limit": "对话过长,正在分导出到Notion",
"loading.notion.exporting_progress": "正在导出到Notion ({{current}}/{{total}})...",
"loading.notion.preparing": "正在准备导出到Notion...",
"mention.title": "切换模型回答",
@@ -941,9 +950,19 @@
"magic_prompt_option_tip": "智能优化放大提示词"
},
"text_desc_required": "请先输入图片描述",
"req_error_text" : "运行失败,请重试。提示词避免“版权词”和”敏感词”哦。",
"req_error_text": "运行失败,请重试。提示词避免“版权词”和”敏感词”哦。",
"auto_create_paint": "自动新建图片",
"auto_create_paint_tip": "在图片生成后,会自动新建图片"
"auto_create_paint_tip": "在图片生成后,会自动新建图片",
"select_model": "选择模型",
"input_parameters": "输入参数",
"input_image": "输入图片",
"generated_image": "生成图片",
"pricing": "定价",
"model_and_pricing": "模型与定价",
"per_image": "每张图片",
"per_images": "每张图片",
"required_field": "必填项",
"uploaded_input": "已上传输入"
},
"prompts": {
"explanation": "帮我解释一下这个概念",
@@ -1096,7 +1115,9 @@
"token": "Joplin 授权令牌",
"token_placeholder": "请输入 Joplin 授权令牌",
"url": "Joplin 剪裁服务监听 URL",
"url_placeholder": "http://127.0.0.1:41184/"
"url_placeholder": "http://127.0.0.1:41184/",
"export_reasoning.title": "导出时包含思维链",
"export_reasoning.help": "开启后导出到Joplin时会包含思维链内容。"
},
"markdown_export.force_dollar_math.help": "开启后导出Markdown时会将强制使用$$来标记LaTeX公式。注意该项也会影响所有通过Markdown导出的方式如Notion、语雀等",
"markdown_export.force_dollar_math.title": "强制使用$$来标记LaTeX公式",
@@ -1105,14 +1126,16 @@
"markdown_export.path_placeholder": "导出路径",
"markdown_export.select": "选择",
"markdown_export.title": "Markdown 导出",
"markdown_export.show_model_name.title": "导出时使用模型名称",
"markdown_export.show_model_name.help": "开启后使用话题命名模型为导出的消息创建标题。注意该项也会影响所有通过Markdown导出的方式如Notion、语雀等。",
"markdown_export.show_model_provider.title": "显示模型供应商",
"markdown_export.show_model_provider.help": "在导出Markdown时显示模型供应商如OpenAI、Gemini等",
"message_title.use_topic_naming.title": "使用话题命名模型为导出的消息创建标题",
"message_title.use_topic_naming.help": "开启后使用话题命名模型为导出的消息创建标题。该项也会影响所有通过Markdown导出的方式",
"minute_interval_one": "{{count}} 分钟",
"minute_interval_other": "{{count}} 分钟",
"notion.api_key": "Notion 密钥",
"notion.api_key_placeholder": "请输入Notion 密钥",
"notion.auto_split": "导出对话时自动分页",
"notion.auto_split_tip": "当要导出的话题过长时自动分页导出到Notion",
"notion.check": {
"button": "检测",
"empty_api_key": "未配置 API key",
@@ -1126,10 +1149,9 @@
"notion.help": "Notion 配置文档",
"notion.page_name_key": "页面标题字段名",
"notion.page_name_key_placeholder": "请输入页面标题字段名,默认为 Name",
"notion.split_size": "自动分页大小",
"notion.split_size_help": "Notion免费版用户建议设置为90高级版用户建议设置为24990默认值为90",
"notion.split_size_placeholder": "请输入每页块数限制(默认90)",
"notion.title": "Notion 配置",
"notion.title": "Notion 设置",
"notion.export_reasoning.title": "导出时包含思维链",
"notion.export_reasoning.help": "开启后导出到Notion时会包含思维链内容。",
"title": "数据设置",
"webdav": {
"autoSync": "自动备份",
@@ -1490,6 +1512,7 @@
"advancedSettings": "高级设置"
},
"messages.prompt": "显示提示词",
"messages.tokens": "显示Token用量",
"messages.divider": "消息分割线",
"messages.grid_columns": "消息网格展示列数",
"messages.grid_popover_trigger": "网格详情触发",
@@ -1793,6 +1816,8 @@
},
"translate": {
"any.language": "任意语言",
"target_language": "目标语言",
"alter_language": "备用语言",
"button.translate": "翻译",
"close": "关闭",
"closed": "翻译已关闭",
@@ -1843,6 +1868,13 @@
"show_window": "显示窗口",
"visualization": "可视化"
},
"update": {
"title": "更新提示",
"message": "发现新版本 {{version}},是否立即安装?",
"later": "稍后",
"install": "立即安装",
"noReleaseNotes": "暂无更新日志"
},
"selection": {
"name": "划词助手",
"action": {
@@ -1865,6 +1897,9 @@
"esc_stop": "Esc 停止",
"c_copy": "C 复制",
"r_regenerate": "R 重新生成"
},
"translate": {
"smart_translate_tips": "智能翻译:内容将优先翻译为目标语言;内容已是目标语言的,将翻译为备选语言"
}
},
"settings": {

View File

@@ -322,6 +322,7 @@
"translate": "翻譯",
"topics.export.siyuan": "匯出到思源筆記",
"topics.export.wait_for_title_naming": "正在生成標題...",
"topics.export.obsidian_reasoning": "包含思維鏈",
"topics.export.title_naming_success": "標題生成成功",
"topics.export.title_naming_failed": "標題生成失敗,使用預設標題",
"input.translating": "翻譯中...",
@@ -586,7 +587,14 @@
"korean": "韓文",
"portuguese": "葡萄牙文",
"russian": "俄文",
"spanish": "西班牙文"
"spanish": "西班牙文",
"polish": "波蘭文",
"turkish": "土耳其文",
"thai": "泰文",
"vietnamese": "越南文",
"indonesian": "印尼文",
"urdu": "烏爾都文",
"malay": "馬來文"
},
"lmstudio": {
"keep_alive_time.description": "對話後模型在記憶體中保持的時間(預設為 5 分鐘)",
@@ -627,6 +635,7 @@
"error.enter.api.key": "請先輸入您的 API 金鑰",
"error.enter.model": "請先選擇一個模型",
"error.enter.name": "請先輸入知識庫名稱",
"error.fetchTopicName": "話題命名失敗",
"error.get_embedding_dimensions": "取得嵌入維度失敗",
"error.invalid.api.host": "無效的 API 位址",
"error.invalid.api.key": "無效的 API 金鑰",
@@ -941,9 +950,19 @@
},
"rendering_speed": "渲染速度",
"text_desc_required": "請先輸入圖片描述",
"req_error_text" : "运行失败,请重试。提示词避免“版权词”和”敏感词”哦。",
"req_error_text": "运行失败,请重试。提示词避免“版权词”和”敏感词”哦。",
"auto_create_paint": "自動新增圖片",
"auto_create_paint_tip": "圖片生成後,會自動新增圖片"
"auto_create_paint_tip": "圖片生成後,會自動新增圖片",
"select_model": "選擇模型",
"input_parameters": "輸入參數",
"input_image": "輸入圖片",
"generated_image": "生成圖片",
"pricing": "定價",
"model_and_pricing": "模型與定價",
"per_image": "每張圖片",
"per_images": "每張圖片",
"required_field": "必填欄位",
"uploaded_input": "已上傳輸入"
},
"prompts": {
"explanation": "幫我解釋一下這個概念",
@@ -1096,7 +1115,9 @@
"token": "Joplin 授權Token",
"token_placeholder": "請輸入 Joplin 授權Token",
"url": "Joplin 剪輯服務 URL",
"url_placeholder": "http://127.0.0.1:41184/"
"url_placeholder": "http://127.0.0.1:41184/",
"export_reasoning.title": "匯出時包含思維鏈",
"export_reasoning.help": "啟用後,匯出內容將包含助手生成的思維鏈(思考過程)。"
},
"markdown_export.force_dollar_math.help": "開啟後匯出Markdown時會強制使用$$來標記LaTeX公式。注意該項也會影響所有透過Markdown匯出的方式如Notion、語雀等",
"markdown_export.force_dollar_math.title": "LaTeX公式強制使用$$",
@@ -1105,12 +1126,14 @@
"markdown_export.path_placeholder": "匯出路徑",
"markdown_export.select": "選擇",
"markdown_export.title": "Markdown 匯出",
"markdown_export.show_model_name.title": "匯出時使用模型名稱",
"markdown_export.show_model_name.help": "啟用後將以主題命名模型為匯出的訊息建立標題。注意該項也會影響所有透過Markdown匯出的方式如Notion、語雀等。",
"markdown_export.show_model_provider.title": "顯示模型供應商",
"markdown_export.show_model_provider.help": "在匯出Markdown時顯示模型供應商如OpenAI、Gemini等",
"minute_interval_one": "{{count}} 分鐘",
"minute_interval_other": "{{count}} 分鐘",
"notion.api_key": "Notion 金鑰",
"notion.api_key_placeholder": "請輸入 Notion 金鑰",
"notion.auto_split": "匯出對話時自動分頁",
"notion.auto_split_tip": "當要匯出的話題過長時自動分頁匯出到 Notion",
"notion.check": {
"button": "檢查",
"empty_api_key": "未設定 API key",
@@ -1124,10 +1147,9 @@
"notion.help": "Notion 設定文件",
"notion.page_name_key": "頁面標題欄位名稱",
"notion.page_name_key_placeholder": "請輸入頁面標題欄位名稱,預設為 Name",
"notion.split_size": "自動分頁大小",
"notion.split_size_help": "Notion 免費版使用者建議設定為 90進階版使用者建議設定為 24990預設值為 90",
"notion.split_size_placeholder": "請輸入每頁塊數限制 (預設 90)",
"notion.title": "Notion 設定",
"notion.export_reasoning.title": "匯出時包含思維鏈",
"notion.export_reasoning.help": "啟用後匯出到Notion時會包含思維鏈內容。",
"title": "資料設定",
"webdav": {
"autoSync": "自動備份",
@@ -1489,6 +1511,7 @@
"advancedSettings": "高級設定"
},
"messages.prompt": "提示詞顯示",
"messages.tokens": "Token用量顯示",
"messages.divider": "訊息間顯示分隔線",
"messages.grid_columns": "訊息網格展示列數",
"messages.grid_popover_trigger": "網格詳細資訊觸發",
@@ -1793,6 +1816,8 @@
},
"translate": {
"any.language": "任意語言",
"target_language": "目標語言",
"alter_language": "備用語言",
"button.translate": "翻譯",
"close": "關閉",
"closed": "翻譯已關閉",
@@ -1843,6 +1868,13 @@
"show_window": "顯示視窗",
"visualization": "視覺化"
},
"update": {
"title": "更新提示",
"message": "新版本 {{version}} 已準備就緒,是否立即安裝?",
"later": "稍後",
"install": "立即安裝",
"noReleaseNotes": "暫無更新日誌"
},
"selection": {
"name": "劃詞助手",
"action": {
@@ -1865,6 +1897,9 @@
"esc_stop": "Esc 停止",
"c_copy": "C 複製",
"r_regenerate": "R 重新生成"
},
"translate": {
"smart_translate_tips": "智能翻譯:內容將優先翻譯為目標語言;內容已是目標語言的,將翻譯為備用語言"
}
},
"settings": {

View File

@@ -557,6 +557,7 @@
"error.enter.api.key": "Παρακαλώ εισάγετε το κλειδί API σας",
"error.enter.model": "Παρακαλώ επιλέξτε ένα μοντέλο",
"error.enter.name": "Παρακαλώ εισάγετε ένα όνομα για τη βάση γνώσεων",
"error.fetchTopicName": "Αποτυχία ονοματοδοσίας θέματος",
"error.get_embedding_dimensions": "Απέτυχε η πρόσληψη διαστάσεων ενσωμάτωσης",
"error.invalid.api.host": "Μη έγκυρη διεύθυνση API",
"error.invalid.api.key": "Μη έγκυρο κλειδί API",
@@ -1657,6 +1658,13 @@
"quit": "Έξοδος",
"show_window": "Εμφάνιση Παραθύρου",
"visualization": "προβολή"
},
"update": {
"title": "Ενημέρωση",
"message": "Νέα έκδοση {{version}} είναι έτοιμη, θέλετε να την εγκαταστήσετε τώρα;",
"later": "Μετά",
"install": "Εγκατάσταση",
"noReleaseNotes": "Χωρίς σημειώσεις"
}
}
}

View File

@@ -558,6 +558,7 @@
"error.enter.api.key": "Ingrese su clave API",
"error.enter.model": "Seleccione un modelo",
"error.enter.name": "Ingrese el nombre de la base de conocimiento",
"error.fetchTopicName": "Error al nombrar el tema",
"error.get_embedding_dimensions": "Fallo al obtener las dimensiones de incrustación",
"error.invalid.api.host": "Dirección API inválida",
"error.invalid.api.key": "Clave API inválida",
@@ -1656,6 +1657,13 @@
"quit": "Salir",
"show_window": "Mostrar Ventana",
"visualization": "Visualización"
},
"update": {
"title": "Actualización",
"message": "Nueva versión {{version}} disponible, ¿desea instalarla ahora?",
"later": "Más tarde",
"install": "Instalar",
"noReleaseNotes": "Sin notas de la versión"
}
}
}

View File

@@ -557,6 +557,7 @@
"error.enter.api.key": "Veuillez entrer votre clé API",
"error.enter.model": "Veuillez sélectionner un modèle",
"error.enter.name": "Veuillez entrer le nom de la base de connaissances",
"error.fetchTopicName": "Échec de la dénomination du sujet",
"error.get_embedding_dimensions": "Impossible d'obtenir les dimensions d'encodage",
"error.invalid.api.host": "Adresse API invalide",
"error.invalid.api.key": "Clé API invalide",
@@ -1657,6 +1658,13 @@
"quit": "Quitter",
"show_window": "Afficher la fenêtre",
"visualization": "Visualisation"
},
"update": {
"title": "Mise à jour",
"message": "Nouvelle version {{version}} disponible, voulez-vous l'installer maintenant ?",
"later": "Plus tard",
"install": "Installer",
"noReleaseNotes": "Aucune note de version"
}
}
}

View File

@@ -559,6 +559,7 @@
"error.enter.api.key": "Insira sua chave API",
"error.enter.model": "Selecione um modelo",
"error.enter.name": "Insira o nome da base de conhecimento",
"error.fetchTopicName": "Falha ao nomear o tópico",
"error.get_embedding_dimensions": "Falha ao obter dimensões de incorporação",
"error.invalid.api.host": "Endereço API inválido",
"error.invalid.api.key": "Chave API inválida",
@@ -1659,6 +1660,13 @@
"quit": "Sair",
"show_window": "Exibir Janela",
"visualization": "Visualização"
},
"update": {
"title": "Atualização",
"message": "Nova versão {{version}} disponível, deseja instalar agora?",
"later": "Mais tarde",
"install": "Instalar",
"noReleaseNotes": "Sem notas de versão"
}
}
}

View File

@@ -1,5 +1,6 @@
import KeyvStorage from '@kangfenmao/keyv-storage'
import { APP_IS_CUSTOM_PRODUCT, APP_NAME } from './config/env'
import { startAutoSync } from './services/BackupService'
import { startNutstoreAutoSync } from './services/NutstoreService'
import storeSyncService from './services/StoreSyncService'
@@ -30,3 +31,7 @@ function initStoreSync() {
initKeyv()
initAutoSync()
initStoreSync()
if (APP_IS_CUSTOM_PRODUCT) {
document.title = APP_NAME
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,98 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`CitationTooltip > basic rendering > should match snapshot 1`] = `
.c0 {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
cursor: pointer;
}
.c0:hover {
opacity: 0.8;
}
.c1 {
color: var(--color-text-1);
font-size: 14px;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.c2 {
font-size: 13px;
line-height: 1.5;
margin-bottom: 8px;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
color: var(--color-text-2);
}
.c3 {
font-size: 12px;
color: var(--color-link);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
}
.c3:hover {
text-decoration: underline;
}
<div
data-color="var(--color-background-mute)"
data-placement="top"
data-styles="{"body":{"border":"1px solid var(--color-border)","padding":"12px","borderRadius":"8px"}}"
data-testid="tooltip-wrapper"
>
<span>
Test content
</span>
<div
data-testid="tooltip-content"
>
<div>
<div
aria-label="Open Example Article in new tab"
class="c0"
role="button"
>
<div
alt="Example Article"
data-testid="mock-favicon"
hostname="example.com"
/>
<div
aria-level="3"
class="c1"
role="heading"
title="Example Article"
>
Example Article
</div>
</div>
<div
aria-label="Citation content"
class="c2"
role="article"
>
This is the article content for testing purposes.
</div>
<div
aria-label="Visit example.com"
class="c3"
role="button"
>
example.com
</div>
</div>
</div>
</div>
`;

View File

@@ -0,0 +1,39 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Markdown > rendering > should match snapshot 1`] = `
<div
class="markdown"
data-testid="markdown-content"
>
# Test Markdown
This is **bold** text.
<span
data-testid="has-link-component"
>
link
</span>
<div
data-testid="has-code-component"
>
<div
data-id="code-block-1"
data-testid="code-block"
>
<code>
test code
</code>
<button
type="button"
>
Save
</button>
</div>
</div>
<span
data-testid="has-img-component"
>
img
</span>
</div>
`;

View File

@@ -5,7 +5,7 @@ import type { RootState } from '@renderer/store'
import { selectFormattedCitationsByBlockId } from '@renderer/store/messageBlock'
import { type Model, WebSearchSource } from '@renderer/types'
import type { MainTextMessageBlock, Message } from '@renderer/types/newMessage'
import { cleanMarkdownContent } from '@renderer/utils/formats'
import { cleanMarkdownContent, encodeHTML } from '@renderer/utils/formats'
import { Flex } from 'antd'
import React, { useMemo } from 'react'
import { useSelector } from 'react-redux'
@@ -13,18 +13,6 @@ import styled from 'styled-components'
import Markdown from '../../Markdown/Markdown'
// HTML实体编码辅助函数
const encodeHTML = (str: string): string => {
const entities: { [key: string]: string } = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&apos;'
}
return str.replace(/[&<>"']/g, (match) => entities[match])
}
interface Props {
block: MainTextMessageBlock
citationBlockId?: string

View File

@@ -0,0 +1,477 @@
import { configureStore } from '@reduxjs/toolkit'
import type { Model } from '@renderer/types'
import { WebSearchSource } from '@renderer/types'
import type { MainTextMessageBlock } from '@renderer/types/newMessage'
import { MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage'
import { render, screen } from '@testing-library/react'
import { Provider } from 'react-redux'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import MainTextBlock from '../MainTextBlock'
// Mock dependencies
const mockUseSettings = vi.fn()
const mockUseSelector = vi.fn()
// Mock hooks
vi.mock('@renderer/hooks/useSettings', () => ({
useSettings: () => mockUseSettings()
}))
vi.mock('react-redux', async () => {
const actual = await import('react-redux')
return {
...actual,
useSelector: () => mockUseSelector(),
useDispatch: () => vi.fn()
}
})
// Mock store to avoid withTypes issues
vi.mock('@renderer/store', () => ({
useAppSelector: vi.fn(),
useAppDispatch: vi.fn(() => vi.fn())
}))
// Mock store selectors
vi.mock('@renderer/store/messageBlock', async () => {
const actual = await import('@renderer/store/messageBlock')
return {
...actual,
selectFormattedCitationsByBlockId: vi.fn(() => [])
}
})
// Mock utilities
vi.mock('@renderer/utils/formats', () => ({
cleanMarkdownContent: vi.fn((content: string) => content),
encodeHTML: vi.fn((content: string) => content.replace(/"/g, '&quot;'))
}))
// Mock services
vi.mock('@renderer/services/ModelService', () => ({
getModelUniqId: vi.fn()
}))
// Mock Markdown component
vi.mock('@renderer/pages/home/Markdown/Markdown', () => ({
__esModule: true,
default: ({ block }: any) => (
<div data-testid="mock-markdown" data-content={block.content}>
Markdown: {block.content}
</div>
)
}))
describe('MainTextBlock', () => {
// Get references to mocked modules
let mockGetModelUniqId: any
let mockCleanMarkdownContent: any
// Create a mock store for Provider
const mockStore = configureStore({
reducer: {
messageBlocks: (state = {}) => state
}
})
beforeEach(async () => {
vi.clearAllMocks()
// Get the mocked functions
const { getModelUniqId } = await import('@renderer/services/ModelService')
const { cleanMarkdownContent } = await import('@renderer/utils/formats')
mockGetModelUniqId = getModelUniqId as any
mockCleanMarkdownContent = cleanMarkdownContent as any
// Default mock implementations
mockUseSettings.mockReturnValue({ renderInputMessageAsMarkdown: false })
mockUseSelector.mockReturnValue([]) // Empty citations by default
mockGetModelUniqId.mockImplementation((model: Model) => `${model.id}-${model.name}`)
})
// Test data factory functions
const createMainTextBlock = (overrides: Partial<MainTextMessageBlock> = {}): MainTextMessageBlock => ({
id: 'test-block-1',
messageId: 'test-message-1',
type: MessageBlockType.MAIN_TEXT,
status: MessageBlockStatus.SUCCESS,
createdAt: new Date().toISOString(),
content: 'Test content',
...overrides
})
const createModel = (overrides: Partial<Model> = {}): Model =>
({
id: 'test-model-1',
name: 'Test Model',
provider: 'test-provider',
...overrides
}) as Model
// Helper functions
const renderMainTextBlock = (props: {
block: MainTextMessageBlock
role: 'user' | 'assistant'
mentions?: Model[]
citationBlockId?: string
}) => {
return render(
<Provider store={mockStore}>
<MainTextBlock {...props} />
</Provider>
)
}
// User-focused query helpers
const getRenderedMarkdown = () => screen.queryByTestId('mock-markdown')
const getRenderedPlainText = () => screen.queryByRole('paragraph')
const getMentionElements = () => screen.queryAllByText(/@/)
describe('basic rendering', () => {
it('should render in markdown mode for assistant messages', () => {
const block = createMainTextBlock({ content: 'Assistant response' })
renderMainTextBlock({ block, role: 'assistant' })
// User should see markdown-rendered content
expect(getRenderedMarkdown()).toBeInTheDocument()
expect(screen.getByText('Markdown: Assistant response')).toBeInTheDocument()
expect(getRenderedPlainText()).not.toBeInTheDocument()
})
it('should render in plain text mode for user messages when setting disabled', () => {
mockUseSettings.mockReturnValue({ renderInputMessageAsMarkdown: false })
const block = createMainTextBlock({ content: 'User message\nWith line breaks' })
renderMainTextBlock({ block, role: 'user' })
// User should see plain text with preserved formatting
expect(getRenderedPlainText()).toBeInTheDocument()
expect(getRenderedPlainText()!.textContent).toBe('User message\nWith line breaks')
expect(getRenderedMarkdown()).not.toBeInTheDocument()
// Check preserved whitespace
const textElement = getRenderedPlainText()!
expect(textElement).toHaveStyle({ whiteSpace: 'pre-wrap' })
})
it('should render user messages as markdown when setting enabled', () => {
mockUseSettings.mockReturnValue({ renderInputMessageAsMarkdown: true })
const block = createMainTextBlock({ content: 'User **bold** content' })
renderMainTextBlock({ block, role: 'user' })
expect(getRenderedMarkdown()).toBeInTheDocument()
expect(screen.getByText('Markdown: User **bold** content')).toBeInTheDocument()
})
it('should preserve complex formatting in plain text mode', () => {
mockUseSettings.mockReturnValue({ renderInputMessageAsMarkdown: false })
const complexContent = `Line 1
Indented line
**Bold not parsed**
- List not parsed`
const block = createMainTextBlock({ content: complexContent })
renderMainTextBlock({ block, role: 'user' })
const textElement = getRenderedPlainText()!
expect(textElement.textContent).toBe(complexContent)
expect(textElement).toHaveClass('markdown')
})
it('should handle empty content gracefully', () => {
const block = createMainTextBlock({ content: '' })
expect(() => {
renderMainTextBlock({ block, role: 'assistant' })
}).not.toThrow()
expect(getRenderedMarkdown()).toBeInTheDocument()
})
})
describe('mentions functionality', () => {
it('should display model mentions when provided', () => {
const block = createMainTextBlock({ content: 'Content with mentions' })
const mentions = [
createModel({ id: 'model-1', name: 'deepseek-r1' }),
createModel({ id: 'model-2', name: 'claude-sonnet-4' })
]
renderMainTextBlock({ block, role: 'assistant', mentions })
// User should see mention tags
expect(screen.getByText('@deepseek-r1')).toBeInTheDocument()
expect(screen.getByText('@claude-sonnet-4')).toBeInTheDocument()
// Service should be called for model processing
expect(mockGetModelUniqId).toHaveBeenCalledTimes(2)
expect(mockGetModelUniqId).toHaveBeenCalledWith(mentions[0])
expect(mockGetModelUniqId).toHaveBeenCalledWith(mentions[1])
})
it('should not display mentions when none provided', () => {
const block = createMainTextBlock({ content: 'No mentions content' })
renderMainTextBlock({ block, role: 'assistant', mentions: [] })
expect(getMentionElements()).toHaveLength(0)
renderMainTextBlock({ block, role: 'assistant', mentions: undefined })
expect(getMentionElements()).toHaveLength(0)
})
it('should style mentions correctly for user visibility', () => {
const block = createMainTextBlock({ content: 'Styled mentions test' })
const mentions = [createModel({ id: 'model-1', name: 'Test Model' })]
renderMainTextBlock({ block, role: 'assistant', mentions })
const mentionElement = screen.getByText('@Test Model')
expect(mentionElement).toHaveStyle({ color: 'var(--color-link)' })
// Check container layout
const container = mentionElement.closest('[style*="gap"]')
expect(container).toHaveStyle({
gap: '8px',
marginBottom: '10px'
})
})
})
describe('content processing', () => {
it('should filter tool_use tags from content', () => {
const testCases = [
{
name: 'single tool_use tag',
content: 'Before <tool_use>tool content</tool_use> after',
expectsFiltering: true
},
{
name: 'multiple tool_use tags',
content: 'Start <tool_use>tool1</tool_use> middle <tool_use>tool2</tool_use> end',
expectsFiltering: true
},
{
name: 'multiline tool_use',
content: `Text before
<tool_use>
multiline
tool content
</tool_use>
text after`,
expectsFiltering: true
},
{
name: 'malformed tool_use',
content: 'Before <tool_use>unclosed tag',
expectsFiltering: false // Should preserve malformed tags
}
]
testCases.forEach(({ content, expectsFiltering }) => {
const block = createMainTextBlock({ content })
const { unmount } = renderMainTextBlock({ block, role: 'assistant' })
const renderedContent = getRenderedMarkdown()
expect(renderedContent).toBeInTheDocument()
if (expectsFiltering) {
// Check that tool_use content is not visible to user
expect(screen.queryByText(/tool content|tool1|tool2|multiline/)).not.toBeInTheDocument()
}
unmount()
})
})
it('should process content through format utilities', () => {
const block = createMainTextBlock({ content: 'Content to process' })
mockUseSelector.mockReturnValue([{ id: '1', content: 'Citation content', number: 1 }])
renderMainTextBlock({
block,
role: 'assistant',
citationBlockId: 'test-citations'
})
// Verify utility functions are called
expect(mockCleanMarkdownContent).toHaveBeenCalled()
})
})
describe('citation integration', () => {
it('should display content normally when no citations are present', () => {
const block = createMainTextBlock({ content: 'Content without citations' })
mockUseSelector.mockReturnValue([])
renderMainTextBlock({ block, role: 'assistant' })
expect(screen.getByText('Markdown: Content without citations')).toBeInTheDocument()
expect(mockUseSelector).toHaveBeenCalled()
})
it('should integrate with citation system when citations exist', () => {
const block = createMainTextBlock({
content: 'Content with citation [1]',
citationReferences: [{ citationBlockSource: WebSearchSource.OPENAI }]
})
const mockCitations = [
{
id: '1',
number: 1,
url: 'https://example.com',
title: 'Example Citation',
content: 'Citation content'
}
]
mockUseSelector.mockReturnValue(mockCitations)
renderMainTextBlock({
block,
role: 'assistant',
citationBlockId: 'citation-test'
})
// Verify citation integration works
expect(mockUseSelector).toHaveBeenCalled()
expect(getRenderedMarkdown()).toBeInTheDocument()
// Verify content processing occurred
expect(mockCleanMarkdownContent).toHaveBeenCalledWith('Citation content')
})
it('should handle different citation sources correctly', () => {
const testSources = [WebSearchSource.OPENAI, 'DEFAULT' as any, 'CUSTOM' as any]
testSources.forEach((source) => {
const block = createMainTextBlock({
content: `Citation test for ${source}`,
citationReferences: [{ citationBlockSource: source }]
})
mockUseSelector.mockReturnValue([{ id: '1', number: 1, url: 'https://test.com', title: 'Test' }])
const { unmount } = renderMainTextBlock({
block,
role: 'assistant',
citationBlockId: `test-${source}`
})
expect(getRenderedMarkdown()).toBeInTheDocument()
unmount()
})
})
it('should handle multiple citations gracefully', () => {
const block = createMainTextBlock({
content: 'Multiple citations [1] and [2]',
citationReferences: [{ citationBlockSource: 'DEFAULT' as any }]
})
const multipleCitations = [
{ id: '1', number: 1, url: 'https://first.com', title: 'First' },
{ id: '2', number: 2, url: 'https://second.com', title: 'Second' }
]
mockUseSelector.mockReturnValue(multipleCitations)
expect(() => {
renderMainTextBlock({ block, role: 'assistant', citationBlockId: 'multi-test' })
}).not.toThrow()
expect(getRenderedMarkdown()).toBeInTheDocument()
})
})
describe('settings integration', () => {
it('should respond to markdown rendering setting changes', () => {
const block = createMainTextBlock({ content: 'Settings test content' })
// Test with markdown enabled
mockUseSettings.mockReturnValue({ renderInputMessageAsMarkdown: true })
const { unmount } = renderMainTextBlock({ block, role: 'user' })
expect(getRenderedMarkdown()).toBeInTheDocument()
unmount()
// Test with markdown disabled
mockUseSettings.mockReturnValue({ renderInputMessageAsMarkdown: false })
renderMainTextBlock({ block, role: 'user' })
expect(getRenderedPlainText()).toBeInTheDocument()
expect(getRenderedMarkdown()).not.toBeInTheDocument()
})
})
describe('edge cases and robustness', () => {
it('should handle large content without performance issues', () => {
const largeContent = 'A'.repeat(1000) + ' with citations [1]'
const block = createMainTextBlock({ content: largeContent })
const largeCitations = [
{
id: '1',
number: 1,
url: 'https://large.com',
title: 'Large',
content: 'B'.repeat(500)
}
]
mockUseSelector.mockReturnValue(largeCitations)
expect(() => {
renderMainTextBlock({
block,
role: 'assistant',
citationBlockId: 'large-test'
})
}).not.toThrow()
expect(getRenderedMarkdown()).toBeInTheDocument()
})
it('should handle special characters and Unicode gracefully', () => {
const specialContent = '测试内容 🚀 📝 ✨ <>&"\'` [1]'
const block = createMainTextBlock({ content: specialContent })
mockUseSelector.mockReturnValue([{ id: '1', number: 1, title: '特殊字符测试', content: '内容 with 🎉' }])
expect(() => {
renderMainTextBlock({
block,
role: 'assistant',
citationBlockId: 'unicode-test'
})
}).not.toThrow()
expect(getRenderedMarkdown()).toBeInTheDocument()
})
it('should handle null and undefined values gracefully', () => {
const block = createMainTextBlock({ content: 'Null safety test' })
expect(() => {
renderMainTextBlock({
block,
role: 'assistant',
mentions: undefined,
citationBlockId: undefined
})
}).not.toThrow()
expect(getRenderedMarkdown()).toBeInTheDocument()
})
it('should integrate properly with Redux store', () => {
const block = createMainTextBlock({
content: 'Redux integration test',
citationReferences: [{ citationBlockSource: 'DEFAULT' as any }]
})
mockUseSelector.mockReturnValue([])
renderMainTextBlock({ block, role: 'assistant', citationBlockId: 'redux-test' })
// Verify Redux integration
expect(mockUseSelector).toHaveBeenCalled()
expect(getRenderedMarkdown()).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,424 @@
import type { ThinkingMessageBlock } from '@renderer/types/newMessage'
import { MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage'
import { render, screen } from '@testing-library/react'
import { act } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import ThinkingBlock from '../ThinkingBlock'
// Mock dependencies
const mockUseSettings = vi.fn()
const mockUseTranslation = vi.fn()
// Mock hooks
vi.mock('@renderer/hooks/useSettings', () => ({
useSettings: () => mockUseSettings()
}))
vi.mock('react-i18next', () => ({
useTranslation: () => mockUseTranslation()
}))
// Mock antd components
vi.mock('antd', () => ({
Collapse: ({ activeKey, onChange, items, className, size, expandIconPosition }: any) => (
<div
data-testid="collapse-container"
className={className}
data-active-key={activeKey}
data-size={size}
data-expand-icon-position={expandIconPosition}>
{items.map((item: any) => (
<div key={item.key} data-testid={`collapse-item-${item.key}`}>
<div data-testid={`collapse-header-${item.key}`} onClick={() => onChange()}>
{item.label}
</div>
{activeKey === item.key && <div data-testid={`collapse-content-${item.key}`}>{item.children}</div>}
</div>
))}
</div>
),
Tooltip: ({ title, children, mouseEnterDelay }: any) => (
<div data-testid="tooltip" title={title} data-mouse-enter-delay={mouseEnterDelay}>
{children}
</div>
),
message: {
success: vi.fn(),
error: vi.fn()
}
}))
// Mock icons
vi.mock('@ant-design/icons', () => ({
CheckOutlined: ({ style }: any) => (
<span data-testid="check-icon" style={style}>
</span>
)
}))
vi.mock('lucide-react', () => ({
Lightbulb: ({ size }: any) => (
<span data-testid="lightbulb-icon" data-size={size}>
💡
</span>
)
}))
// Mock motion
vi.mock('motion/react', () => ({
motion: {
span: ({ children, variants, animate, initial, style }: any) => (
<span
data-testid="motion-span"
data-variants={JSON.stringify(variants)}
data-animate={animate}
data-initial={initial}
style={style}>
{children}
</span>
)
}
}))
// Mock motion variants
vi.mock('@renderer/utils/motionVariants', () => ({
lightbulbVariants: {
active: { rotate: 10, scale: 1.1 },
idle: { rotate: 0, scale: 1 }
}
}))
// Mock Markdown component
vi.mock('@renderer/pages/home/Markdown/Markdown', () => ({
__esModule: true,
default: ({ block }: any) => (
<div data-testid="mock-markdown" data-block-id={block.id}>
Markdown: {block.content}
</div>
)
}))
describe('ThinkingBlock', () => {
beforeEach(async () => {
vi.useFakeTimers()
// Default mock implementations
mockUseSettings.mockReturnValue({
messageFont: 'sans-serif',
fontSize: 14,
thoughtAutoCollapse: false
})
mockUseTranslation.mockReturnValue({
t: (key: string, params?: any) => {
if (key === 'chat.thinking' && params?.seconds) {
return `Thinking... ${params.seconds}s`
}
if (key === 'chat.deeply_thought' && params?.seconds) {
return `Thought for ${params.seconds}s`
}
if (key === 'message.copied') return 'Copied!'
if (key === 'message.copy.failed') return 'Copy failed'
if (key === 'common.copy') return 'Copy'
return key
}
})
})
afterEach(() => {
vi.restoreAllMocks()
vi.clearAllMocks()
vi.clearAllTimers()
vi.useRealTimers()
})
// Test data factory functions
const createThinkingBlock = (overrides: Partial<ThinkingMessageBlock> = {}): ThinkingMessageBlock => ({
id: 'test-thinking-block-1',
messageId: 'test-message-1',
type: MessageBlockType.THINKING,
status: MessageBlockStatus.SUCCESS,
createdAt: new Date().toISOString(),
content: 'I need to think about this carefully...',
thinking_millsec: 5000,
...overrides
})
// Helper functions
const renderThinkingBlock = (block: ThinkingMessageBlock) => {
return render(<ThinkingBlock block={block} />)
}
const getThinkingContent = () => screen.queryByText(/markdown:/i)
const getCopyButton = () => screen.queryByRole('button', { name: /copy/i })
const getThinkingTimeText = () => screen.getByText(/thinking|thought/i)
describe('basic rendering', () => {
it('should render thinking content when provided', () => {
const block = createThinkingBlock({ content: 'Deep thoughts about AI' })
renderThinkingBlock(block)
// User should see the thinking content
expect(screen.getByText('Markdown: Deep thoughts about AI')).toBeInTheDocument()
expect(screen.getByTestId('lightbulb-icon')).toBeInTheDocument()
})
it('should not render when content is empty', () => {
const testCases = ['', undefined]
testCases.forEach((content) => {
const block = createThinkingBlock({ content: content as any })
const { container, unmount } = renderThinkingBlock(block)
expect(container.firstChild).toBeNull()
unmount()
})
})
it('should show copy button only when thinking is complete', () => {
// When thinking (streaming)
const thinkingBlock = createThinkingBlock({ status: MessageBlockStatus.STREAMING })
const { rerender } = renderThinkingBlock(thinkingBlock)
expect(getCopyButton()).not.toBeInTheDocument()
// When thinking is complete
const completedBlock = createThinkingBlock({ status: MessageBlockStatus.SUCCESS })
rerender(<ThinkingBlock block={completedBlock} />)
expect(getCopyButton()).toBeInTheDocument()
})
it('should match snapshot', () => {
const block = createThinkingBlock()
const { container } = renderThinkingBlock(block)
expect(container.firstChild).toMatchSnapshot()
})
})
describe('thinking time display', () => {
it('should display appropriate time messages based on status', () => {
// Completed thinking
const completedBlock = createThinkingBlock({
thinking_millsec: 3500,
status: MessageBlockStatus.SUCCESS
})
const { unmount } = renderThinkingBlock(completedBlock)
const timeText = getThinkingTimeText()
expect(timeText).toHaveTextContent('3.5s')
expect(timeText).toHaveTextContent('Thought for')
unmount()
// Active thinking
const thinkingBlock = createThinkingBlock({
thinking_millsec: 1000,
status: MessageBlockStatus.STREAMING
})
renderThinkingBlock(thinkingBlock)
const activeTimeText = getThinkingTimeText()
expect(activeTimeText).toHaveTextContent('1.0s')
expect(activeTimeText).toHaveTextContent('Thinking...')
})
it('should update thinking time in real-time when active', () => {
const block = createThinkingBlock({
thinking_millsec: 1000,
status: MessageBlockStatus.STREAMING
})
renderThinkingBlock(block)
// Initial state
expect(getThinkingTimeText()).toHaveTextContent('1.0s')
// After time passes
act(() => {
vi.advanceTimersByTime(500)
})
expect(getThinkingTimeText()).toHaveTextContent('1.5s')
})
it('should handle extreme thinking times correctly', () => {
const testCases = [
{ thinking_millsec: 0, expectedTime: '0.0s' },
{ thinking_millsec: undefined, expectedTime: '0.0s' },
{ thinking_millsec: 86400000, expectedTime: '86400.0s' }, // 1 day
{ thinking_millsec: 259200000, expectedTime: '259200.0s' } // 3 days
]
testCases.forEach(({ thinking_millsec, expectedTime }) => {
const block = createThinkingBlock({
thinking_millsec,
status: MessageBlockStatus.SUCCESS
})
const { unmount } = renderThinkingBlock(block)
expect(getThinkingTimeText()).toHaveTextContent(expectedTime)
unmount()
})
})
it('should stop timer when thinking status changes to completed', () => {
const block = createThinkingBlock({
thinking_millsec: 1000,
status: MessageBlockStatus.STREAMING
})
const { rerender } = renderThinkingBlock(block)
// Advance timer while thinking
act(() => {
vi.advanceTimersByTime(1000)
})
expect(getThinkingTimeText()).toHaveTextContent('2.0s')
// Complete thinking
const completedBlock = createThinkingBlock({
thinking_millsec: 1000, // Original time doesn't matter
status: MessageBlockStatus.SUCCESS
})
rerender(<ThinkingBlock block={completedBlock} />)
// Timer should stop - text should change from "Thinking..." to "Thought for"
const timeText = getThinkingTimeText()
expect(timeText).toHaveTextContent('Thought for')
expect(timeText).toHaveTextContent('2.0s')
// Further time advancement shouldn't change the display
act(() => {
vi.advanceTimersByTime(1000)
})
expect(timeText).toHaveTextContent('2.0s')
})
})
describe('collapse behavior', () => {
it('should respect auto-collapse setting for initial state', () => {
// Test expanded by default (auto-collapse disabled)
mockUseSettings.mockReturnValue({
messageFont: 'sans-serif',
fontSize: 14,
thoughtAutoCollapse: false
})
const block = createThinkingBlock()
const { unmount } = renderThinkingBlock(block)
// Content should be visible when expanded
expect(getThinkingContent()).toBeInTheDocument()
unmount()
// Test collapsed by default (auto-collapse enabled)
mockUseSettings.mockReturnValue({
messageFont: 'sans-serif',
fontSize: 14,
thoughtAutoCollapse: true
})
renderThinkingBlock(block)
// Content should not be visible when collapsed
expect(getThinkingContent()).not.toBeInTheDocument()
})
it('should auto-collapse when thinking completes if setting enabled', () => {
mockUseSettings.mockReturnValue({
messageFont: 'sans-serif',
fontSize: 14,
thoughtAutoCollapse: true
})
const streamingBlock = createThinkingBlock({ status: MessageBlockStatus.STREAMING })
const { rerender } = renderThinkingBlock(streamingBlock)
// Should be expanded while thinking
expect(getThinkingContent()).toBeInTheDocument()
// Stop thinking
const completedBlock = createThinkingBlock({ status: MessageBlockStatus.SUCCESS })
rerender(<ThinkingBlock block={completedBlock} />)
// Should be collapsed after thinking completes
expect(getThinkingContent()).not.toBeInTheDocument()
})
})
describe('font and styling', () => {
it('should apply font settings to thinking content', () => {
const testCases = [
{
settings: { messageFont: 'serif', fontSize: 16 },
expectedFont: 'var(--font-family-serif)',
expectedSize: '16px'
},
{
settings: { messageFont: 'sans-serif', fontSize: 14 },
expectedFont: 'var(--font-family)',
expectedSize: '14px'
}
]
testCases.forEach(({ settings, expectedFont, expectedSize }) => {
mockUseSettings.mockReturnValue({
...settings,
thoughtAutoCollapse: false
})
const block = createThinkingBlock()
const { unmount } = renderThinkingBlock(block)
// Find the styled content container
const contentContainer = screen.getByTestId('collapse-content-thought')
const styledDiv = contentContainer.querySelector('div')
expect(styledDiv).toHaveStyle({
fontFamily: expectedFont,
fontSize: expectedSize
})
unmount()
})
})
})
describe('integration and edge cases', () => {
it('should handle content updates correctly', () => {
const block1 = createThinkingBlock({ content: 'Original thought' })
const { rerender } = renderThinkingBlock(block1)
expect(screen.getByText('Markdown: Original thought')).toBeInTheDocument()
const block2 = createThinkingBlock({ content: 'Updated thought' })
rerender(<ThinkingBlock block={block2} />)
expect(screen.getByText('Markdown: Updated thought')).toBeInTheDocument()
expect(screen.queryByText('Markdown: Original thought')).not.toBeInTheDocument()
})
it('should clean up timer on unmount', () => {
const block = createThinkingBlock({ status: MessageBlockStatus.STREAMING })
const { unmount } = renderThinkingBlock(block)
const clearIntervalSpy = vi.spyOn(global, 'clearInterval')
unmount()
expect(clearIntervalSpy).toHaveBeenCalled()
})
it('should handle rapid status changes gracefully', () => {
const block = createThinkingBlock({ status: MessageBlockStatus.STREAMING })
const { rerender } = renderThinkingBlock(block)
// Rapidly toggle between states
for (let i = 0; i < 3; i++) {
rerender(<ThinkingBlock block={createThinkingBlock({ status: MessageBlockStatus.STREAMING })} />)
rerender(<ThinkingBlock block={createThinkingBlock({ status: MessageBlockStatus.SUCCESS })} />)
}
// Should still render correctly
expect(getThinkingContent()).toBeInTheDocument()
expect(getCopyButton()).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,116 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ThinkingBlock > basic rendering > should match snapshot 1`] = `
.c0 {
margin-bottom: 15px;
}
.c1 {
display: flex;
flex-direction: row;
align-items: center;
height: 22px;
gap: 4px;
}
.c2 {
color: var(--color-text-2);
}
.c3 {
background: none;
border: none;
color: var(--color-text-2);
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
margin-left: auto;
opacity: 0.6;
transition: all 0.3s;
}
.c3:hover {
opacity: 1;
color: var(--color-text);
}
.c3:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.c3 .iconfont {
font-size: 14px;
}
<div
class="c0 message-thought-container"
data-active-key="thought"
data-expand-icon-position="end"
data-size="small"
data-testid="collapse-container"
>
<div
data-testid="collapse-item-thought"
>
<div
data-testid="collapse-header-thought"
>
<div
class="c1"
>
<span
data-animate="idle"
data-initial="idle"
data-testid="motion-span"
data-variants="{"active":{"rotate":10,"scale":1.1},"idle":{"rotate":0,"scale":1}}"
style="height: 18px;"
>
<span
data-size="18"
data-testid="lightbulb-icon"
>
💡
</span>
</span>
<span
class="c2"
>
Thought for 5.0s
</span>
<div
data-mouse-enter-delay="0.8"
data-testid="tooltip"
title="Copy"
>
<button
aria-label="Copy"
class="c3 message-action-button"
>
<i
class="iconfont icon-copy"
/>
</button>
</div>
</div>
</div>
<div
data-testid="collapse-content-thought"
>
<div
style="font-family: var(--font-family); font-size: 14px;"
>
<div
data-block-id="test-thinking-block-1"
data-testid="mock-markdown"
>
Markdown:
I need to think about this carefully...
</div>
</div>
</div>
</div>
</div>
`;

View File

@@ -18,7 +18,17 @@ import styled from 'styled-components'
import ChatFlowHistory from './ChatFlowHistory'
// Exclude some areas from the navigation
const EXCLUDED_SELECTORS = ['.MessageFooter', '.code-toolbar', '.ant-collapse-header', '.group-menu-bar', '.code-block']
const EXCLUDED_SELECTORS = [
'.MessageFooter',
'.code-toolbar',
'.ant-collapse-header',
'.group-menu-bar',
'.code-block',
'.message-editor'
]
// Gap between the navigation bar and the right element
const RIGHT_GAP = 16
interface ChatNavigationProps {
containerId: string
@@ -264,10 +274,10 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => {
const triggerWidth = 60 // Same as the width in styled component
// Safe way to calculate position when using calc expressions
let rightOffset = 16 // Default right offset
let rightOffset = RIGHT_GAP // Default right offset
if (showRightTopics) {
// When topics are shown on right, we need to account for topic list width
rightOffset = 16 + 300 // Assuming topic list width is 300px, adjust if different
rightOffset += 275 // --topic-list-width
}
const rightPosition = window.innerWidth - rightOffset - triggerWidth
@@ -280,7 +290,7 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => {
const isInTriggerArea =
!isInExcludedArea &&
e.clientX > rightPosition &&
e.clientX < rightPosition + triggerWidth &&
e.clientX < rightPosition + triggerWidth + RIGHT_GAP &&
e.clientY > topPosition &&
e.clientY < topPosition + height
@@ -412,7 +422,7 @@ interface NavigationContainerProps {
const NavigationContainer = styled.div<NavigationContainerProps>`
position: fixed;
right: 16px;
right: ${RIGHT_GAP}px;
top: 50%;
transform: translateY(-50%) translateX(${(props) => (props.$isVisible ? 0 : '100%')});
z-index: 999;

View File

@@ -7,6 +7,7 @@ import { useMessageStyle, useSettings } from '@renderer/hooks/useSettings'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { getMessageModelId } from '@renderer/services/MessagesService'
import { getModelUniqId } from '@renderer/services/ModelService'
import { estimateMessageUsage } from '@renderer/services/TokenService'
import { Assistant, Topic } from '@renderer/types'
import type { Message, MessageBlock } from '@renderer/types/newMessage'
import { classNames } from '@renderer/utils'
@@ -52,7 +53,7 @@ const MessageItem: FC<Props> = ({
const model = useModel(getMessageModelId(message), message.model?.provider) || message.model
const { isBubbleStyle } = useMessageStyle()
const { showMessageDivider, messageFont, fontSize, narrowMode, messageStyle } = useSettings()
const { editMessageBlocks, resendUserMessageWithEdit } = useMessageOperations(topic)
const { editMessageBlocks, resendUserMessageWithEdit, editMessage } = useMessageOperations(topic)
const messageContainerRef = useRef<HTMLDivElement>(null)
const { editingMessageId, stopEditing } = useMessageEditing()
const isEditing = editingMessageId === message.id
@@ -69,14 +70,15 @@ const MessageItem: FC<Props> = ({
const handleEditSave = useCallback(
async (blocks: MessageBlock[]) => {
try {
console.log('after save blocks', blocks)
await editMessageBlocks(message.id, blocks)
const usage = await estimateMessageUsage(message)
editMessage(message.id, { usage: usage })
stopEditing()
} catch (error) {
console.error('Failed to save message blocks:', error)
}
},
[message, editMessageBlocks, stopEditing]
[message, editMessageBlocks, stopEditing, editMessage]
)
const handleEditResend = useCallback(

View File

@@ -172,7 +172,7 @@ const MessageBlockEditor: FC<Props> = ({ message, onSave, onResend, onCancel })
}
return (
<EditorContainer onDragOver={(e) => e.preventDefault()} onDrop={handleDrop}>
<EditorContainer className="message-editor" onDragOver={(e) => e.preventDefault()} onDrop={handleDrop}>
{editedBlocks
.filter((block) => block.type === MessageBlockType.MAIN_TEXT)
.map((block) => (

View File

@@ -287,7 +287,7 @@ const GridContainer = styled.div<{ $count: number; $layout: MultiModelMessageSty
gap: ${({ $layout }) => ($layout === 'horizontal' ? '16px' : '0')};
grid-template-columns: repeat(
${({ $layout, $count }) => (['fold', 'vertical'].includes($layout) ? 1 : $count)},
minmax(550px, 1fr)
minmax(480px, 1fr)
);
@media (max-width: 800px) {
grid-template-columns: repeat(

View File

@@ -16,10 +16,10 @@ import type { Message } from '@renderer/types/newMessage'
import { captureScrollableDivAsBlob, captureScrollableDivAsDataURL } from '@renderer/utils'
import {
exportMarkdownToJoplin,
exportMarkdownToNotion,
exportMarkdownToSiyuan,
exportMarkdownToYuque,
exportMessageAsMarkdown,
exportMessageToNotion,
messageToMarkdown
} from '@renderer/utils/export'
// import { withMessageThought } from '@renderer/utils/formats'
@@ -244,7 +244,7 @@ const MessageMenubar: FC<Props> = (props) => {
onClick: async () => {
const title = await getMessageTitle(message)
const markdown = messageToMarkdown(message)
exportMarkdownToNotion(title, markdown)
exportMessageToNotion(title, markdown, message)
}
},
exportMenuOptions.yuque && {
@@ -260,9 +260,8 @@ const MessageMenubar: FC<Props> = (props) => {
label: t('chat.topics.export.obsidian'),
key: 'obsidian',
onClick: async () => {
const markdown = messageToMarkdown(message)
const title = topic.name?.replace(/\//g, '_') || 'Untitled'
await ObsidianExportPopup.show({ title, markdown, processingMethod: '1' })
await ObsidianExportPopup.show({ title, message, processingMethod: '1' })
}
},
exportMenuOptions.joplin && {
@@ -270,8 +269,7 @@ const MessageMenubar: FC<Props> = (props) => {
key: 'joplin',
onClick: async () => {
const title = await getMessageTitle(message)
const markdown = messageToMarkdown(message)
exportMarkdownToJoplin(title, markdown)
exportMarkdownToJoplin(title, message)
}
},
exportMenuOptions.siyuan && {

View File

@@ -59,6 +59,7 @@ const CheckboxWrapper = styled.div`
const MessageContent = styled.div<{ isMultiSelectMode: boolean }>`
flex: 1;
min-width: 0;
${(props) => props.isMultiSelectMode && 'margin-left: 8px;'}
`

View File

@@ -1,4 +1,5 @@
// import { useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import type { Message } from '@renderer/types/newMessage'
import { Popover } from 'antd'
@@ -11,6 +12,7 @@ interface MessageTokensProps {
}
const MessgeTokens: React.FC<MessageTokensProps> = ({ message }) => {
const { showTokens } = useSettings()
// const { generating } = useRuntime()
const locateMessage = () => {
EventEmitter.emit(EVENT_NAMES.LOCATE_MESSAGE + ':' + message.id, false)
@@ -23,7 +25,7 @@ const MessgeTokens: React.FC<MessageTokensProps> = ({ message }) => {
if (message.role === 'user') {
return (
<MessageMetadata className="message-tokens" onClick={locateMessage}>
Tokens: {message?.usage?.total_tokens}
{showTokens && `Tokens: ${message?.usage?.total_tokens}`}
</MessageMetadata>
)
}
@@ -54,7 +56,7 @@ const MessgeTokens: React.FC<MessageTokensProps> = ({ message }) => {
<MessageMetadata className="message-tokens" onClick={locateMessage}>
{hasMetrics ? (
<Popover content={metrixs} placement="top" trigger="hover" styles={{ root: { fontSize: 11 } }}>
{tokensInfo}
{showTokens && tokensInfo}
</Popover>
) : (
tokensInfo

View File

@@ -34,7 +34,7 @@ const Container = styled.div<{ $isDark: boolean }>`
margin: 5px 20px 0 20px;
border-radius: 10px;
cursor: pointer;
border: 1px solid var(--color-border);
border: 0.5px solid var(--color-border);
`
const Text = styled.div`

View File

@@ -5,6 +5,7 @@ import MinAppsPopover from '@renderer/components/Popups/MinAppsPopover'
import SearchPopup from '@renderer/components/Popups/SearchPopup'
import { isMac } from '@renderer/config/constant'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { useFullscreen } from '@renderer/hooks/useFullscreen'
import { modelGenerating } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings'
import { useShortcut } from '@renderer/hooks/useShortcuts'
@@ -33,6 +34,7 @@ interface Props {
const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant, activeTopic, setActiveTopic }) => {
const { assistant } = useAssistant(activeAssistant.id)
const { showAssistants, toggleShowAssistants } = useShowAssistants()
const isFullscreen = useFullscreen()
const { topicPosition, sidebarIcons, narrowMode } = useSettings()
const { showTopics, toggleShowTopics } = useShowTopics()
const dispatch = useAppDispatch()
@@ -90,7 +92,7 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant, activeTo
{showAssistants && (
<NavbarLeft style={{ justifyContent: 'space-between', borderRight: 'none', padding: 0 }}>
<Tooltip title={t('navbar.hide_sidebar')} mouseEnterDelay={0.8}>
<NavbarIcon onClick={handleToggleShowAssistants} style={{ marginLeft: isMac ? 16 : 0 }}>
<NavbarIcon onClick={handleToggleShowAssistants} style={{ marginLeft: isMac && !isFullscreen ? 16 : 0 }}>
<PanelLeftClose size={18} />
</NavbarIcon>
</Tooltip>
@@ -113,7 +115,7 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant, activeTo
<Tooltip title={t('navbar.show_sidebar')} mouseEnterDelay={2}>
<NavbarIcon
onClick={() => toggleShowAssistants()}
style={{ marginRight: 8, marginLeft: isMac ? 4 : -12 }}>
style={{ marginRight: 8, marginLeft: isMac && !isFullscreen ? 4 : -12 }}>
<PanelRightClose size={18} />
</NavbarIcon>
</Tooltip>
@@ -123,7 +125,7 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant, activeTo
<Tooltip title={t('navbar.show_sidebar')} mouseEnterDelay={0.8}>
<NavbarIcon
onClick={() => toggleShowAssistants()}
style={{ marginRight: 8, marginLeft: isMac ? 4 : -12 }}
style={{ marginRight: 8, marginLeft: isMac && !isFullscreen ? 4 : -12 }}
onMouseOut={() => setSidebarHideCooldown(false)}>
<PanelRightClose size={18} />
</NavbarIcon>

View File

@@ -58,12 +58,14 @@ const Assistants: FC<AssistantsTabProps> = ({
<div style={{ marginBottom: '8px' }}>
{getGroupedAssistants.map((group) => (
<TagsContainer key={group.tag}>
<GroupTitle>
<Tooltip title={group.tag}>
<GroupTitleName>{group.tag}</GroupTitleName>
</Tooltip>
<Divider style={{ margin: '12px 0' }}></Divider>
</GroupTitle>
{group.tag !== t('assistants.tags.untagged') && (
<GroupTitle>
<Tooltip title={group.tag}>
<GroupTitleName>{group.tag}</GroupTitleName>
</Tooltip>
<Divider style={{ margin: '12px 0' }}></Divider>
</GroupTitle>
)}
{group.assistants.map((assistant) => (
<AssistantItem
key={assistant.id}

View File

@@ -46,6 +46,7 @@ import {
setShowInputEstimatedTokens,
setShowMessageDivider,
setShowPrompt,
setShowTokens,
setShowTranslateConfirm,
setThoughtAutoCollapse
} from '@renderer/store/settings'
@@ -59,7 +60,7 @@ import {
} from '@renderer/types'
import { modalConfirm } from '@renderer/utils'
import { Button, Col, InputNumber, Row, Select, Slider, Switch, Tooltip } from 'antd'
import { CircleHelp, RotateCcw, Settings2 } from 'lucide-react'
import { CircleHelp, Settings2 } from 'lucide-react'
import { FC, useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@@ -71,7 +72,7 @@ interface Props {
}
const SettingsTab: FC<Props> = (props) => {
const { assistant, updateAssistantSettings, updateAssistant } = useAssistant(props.assistant.id)
const { assistant, updateAssistantSettings } = useAssistant(props.assistant.id)
const { provider } = useProvider(assistant.model.provider)
const { messageStyle, fontSize, language } = useSettings()
@@ -113,7 +114,8 @@ const SettingsTab: FC<Props> = (props) => {
messageNavigation,
enableQuickPanelTriggers,
enableBackspaceDeleteModel,
showTranslateConfirm
showTranslateConfirm,
showTokens
} = useSettings()
const onUpdateAssistantSettings = (settings: Partial<AssistantSettings>) => {
@@ -138,24 +140,6 @@ const SettingsTab: FC<Props> = (props) => {
}
}
const onReset = () => {
setTemperature(DEFAULT_TEMPERATURE)
setContextCount(DEFAULT_CONTEXTCOUNT)
updateAssistant({
...assistant,
settings: {
...assistant.settings,
temperature: DEFAULT_TEMPERATURE,
contextCount: DEFAULT_CONTEXTCOUNT,
enableMaxTokens: false,
maxTokens: DEFAULT_MAX_TOKENS,
streamOutput: true,
hideMessages: false,
customParameters: []
}
})
}
const codeStyle = useMemo(() => {
return codeEditor.enabled
? theme === ThemeMode.light
@@ -209,14 +193,6 @@ const SettingsTab: FC<Props> = (props) => {
defaultExpanded={true}
extra={
<HStack alignItems="center" gap={2}>
<Tooltip title={t('chat.settings.reset')}>
<Button
type="text"
size="small"
onClick={onReset}
icon={<RotateCcw size={20} style={{ cursor: 'pointer', padding: '0 3px', opacity: 0.8 }} />}
/>
</Tooltip>
<Button
type="text"
size="small"
@@ -336,6 +312,11 @@ const SettingsTab: FC<Props> = (props) => {
<Switch size="small" checked={showPrompt} onChange={(checked) => dispatch(setShowPrompt(checked))} />
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>{t('settings.messages.tokens')}</SettingRowTitleSmall>
<Switch size="small" checked={showTokens} onChange={(checked) => dispatch(setShowTokens(checked))} />
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>{t('settings.messages.divider')}</SettingRowTitleSmall>
<Switch
@@ -707,6 +688,7 @@ const Container = styled(Scrollbar)`
padding-right: 0;
padding-top: 2px;
padding-bottom: 10px;
margin-top: 3px;
`
const SettingRowTitleSmall = styled(SettingRowTitle)`

View File

@@ -4,6 +4,7 @@ import {
DeleteOutlined,
EditOutlined,
FolderOutlined,
MenuOutlined,
PushpinOutlined,
QuestionCircleOutlined,
UploadOutlined
@@ -54,7 +55,7 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
const { assistants } = useAssistants()
const { assistant, removeTopic, moveTopic, updateTopic, updateTopics } = useAssistant(_assistant.id)
const { t } = useTranslation()
const { showTopicTime, pinTopicsToTop } = useSettings()
const { showTopicTime, pinTopicsToTop, setTopicPosition } = useSettings()
const borderRadius = showTopicTime ? 12 : 'var(--list-item-border-radius)'
@@ -174,7 +175,11 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
if (messages.length >= 2) {
const summaryText = await fetchMessagesSummary({ messages, assistant })
if (summaryText) {
updateTopic({ ...topic, name: summaryText, isNameManuallyEdited: false })
const updatedTopic = { ...topic, name: summaryText, isNameManuallyEdited: false }
updateTopic(updatedTopic)
topic.id === activeTopic.id && setActiveTopic(updatedTopic)
} else {
window.message?.error(t('message.error.fetchTopicName'))
}
}
}
@@ -190,7 +195,9 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
defaultValue: topic?.name || ''
})
if (name && topic?.name !== name) {
updateTopic({ ...topic, name, isNameManuallyEdited: true })
const updatedTopic = { ...topic, name, isNameManuallyEdited: true }
updateTopic(updatedTopic)
topic.id === activeTopic.id && setActiveTopic(updatedTopic)
}
}
},
@@ -242,6 +249,23 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
})
}
},
{
label: t('settings.topic.position'),
key: 'topic-position',
icon: <MenuOutlined />,
children: [
{
label: t('settings.topic.position.left'),
key: 'left',
onClick: () => setTopicPosition('left')
},
{
label: t('settings.topic.position.right'),
key: 'right',
onClick: () => setTopicPosition('right')
}
]
},
{
label: t('chat.topics.copy.title'),
key: 'copy',
@@ -306,16 +330,15 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
label: t('chat.topics.export.obsidian'),
key: 'obsidian',
onClick: async () => {
const markdown = await topicToMarkdown(topic)
await ObsidianExportPopup.show({ title: topic.name, markdown, processingMethod: '3' })
await ObsidianExportPopup.show({ title: topic.name, topic, processingMethod: '3' })
}
},
exportMenuOptions.joplin && {
label: t('chat.topics.export.joplin'),
key: 'joplin',
onClick: async () => {
const markdown = await topicToMarkdown(topic)
exportMarkdownToJoplin(topic.name, markdown)
const topicMessages = await TopicManager.getTopicMessages(topic.id)
exportMarkdownToJoplin(topic.name, topicMessages)
}
},
exportMenuOptions.siyuan && {
@@ -358,26 +381,27 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
return menus
}, [
activeTopic.id,
assistant,
assistants,
exportMenuOptions.docx,
targetTopic,
t,
exportMenuOptions.image,
exportMenuOptions.joplin,
exportMenuOptions.markdown,
exportMenuOptions.markdown_reason,
exportMenuOptions.docx,
exportMenuOptions.notion,
exportMenuOptions.obsidian,
exportMenuOptions.siyuan,
exportMenuOptions.yuque,
onClearMessages,
onDeleteTopic,
onMoveTopic,
onPinTopic,
setActiveTopic,
t,
exportMenuOptions.obsidian,
exportMenuOptions.joplin,
exportMenuOptions.siyuan,
assistants,
assistant,
updateTopic,
targetTopic
activeTopic.id,
setActiveTopic,
onPinTopic,
onClearMessages,
setTopicPosition,
onMoveTopic,
onDeleteTopic
])
// Sort topics based on pinned status if pinTopicsToTop is enabled
@@ -481,7 +505,6 @@ const TopicListItem = styled.div`
justify-content: space-between;
position: relative;
cursor: pointer;
border: 0.5px solid transparent;
position: relative;
width: calc(var(--assistants-width) - 20px);
.menu {
@@ -489,15 +512,10 @@ const TopicListItem = styled.div`
color: var(--color-text-3);
}
&:hover {
background-color: var(--color-background-soft);
.name {
}
background-color: var(--color-list-item-hover);
}
&.active {
background-color: var(--color-background-soft);
border: 0.5px solid var(--color-border);
.name {
}
background-color: var(--color-list-item);
.menu {
opacity: 1;
&:hover {

View File

@@ -1,4 +1,5 @@
import {
CheckOutlined,
DeleteOutlined,
EditOutlined,
MinusCircleOutlined,
@@ -19,7 +20,7 @@ import AssistantSettingsPopup from '@renderer/pages/settings/AssistantSettings'
import { getDefaultModel, getDefaultTopic } from '@renderer/services/AssistantService'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { Assistant, AssistantsSortType } from '@renderer/types'
import { uuid } from '@renderer/utils'
import { getLeadingEmoji, uuid } from '@renderer/utils'
import { hasTopicPendingRequests } from '@renderer/utils/queue'
import { Dropdown, MenuProps } from 'antd'
import { omit } from 'lodash'
@@ -151,7 +152,7 @@ const AssistantItem: FC<AssistantItemProps> = ({
) : (
assistantIconType === 'emoji' && (
<EmojiIcon
emoji={assistant.emoji || assistantName.slice(0, 1)}
emoji={assistant.emoji || getLeadingEmoji(assistantName)}
className={isPending && !isActive ? 'animation-pulse' : ''}
/>
)
@@ -185,10 +186,9 @@ const handleTagOperation = (
updateAssistants: (assistants: Assistant[]) => void
) => {
if (assistant.tags?.includes(tag)) {
updateAssistants(assistants.map((a) => (a.id === assistant.id ? { ...a, tags: [] } : a)))
} else {
updateAssistants(assistants.map((a) => (a.id === assistant.id ? { ...a, tags: [tag] } : a)))
return
}
updateAssistants(assistants.map((a) => (a.id === assistant.id ? { ...a, tags: [tag] } : a)))
}
// 提取创建菜单项的函数
@@ -202,8 +202,7 @@ const createTagMenuItems = (
const items: MenuProps['items'] = [
...allTags.map((tag) => ({
label: tag,
icon: assistant.tags?.includes(tag) ? <DeleteOutlined size={14} /> : <Tag size={12} />,
danger: assistant.tags?.includes(tag),
icon: assistant.tags?.includes(tag) ? <CheckOutlined size={14} /> : <Tag size={12} />,
key: `all-tag-${tag}`,
onClick: () => handleTagOperation(tag, assistant, assistants, updateAssistants)
}))
@@ -383,23 +382,18 @@ const Container = styled.div`
display: flex;
flex-direction: row;
justify-content: space-between;
padding: 0 10px;
padding: 0 8px;
height: 37px;
position: relative;
border-radius: var(--list-item-border-radius);
border: 0.5px solid transparent;
width: calc(var(--assistants-width) - 20px);
cursor: pointer;
.iconfont {
opacity: 0;
color: var(--color-text-3);
}
&:hover {
background-color: var(--color-background-soft);
background-color: var(--color-list-item-hover);
}
&.active {
background-color: var(--color-background-soft);
border: 0.5px solid var(--color-border);
background-color: var(--color-list-item);
}
`
@@ -423,7 +417,6 @@ const MenuButton = styled.div`
align-items: center;
min-width: 22px;
height: 22px;
min-width: 22px;
min-height: 22px;
border-radius: 11px;
position: absolute;

View File

@@ -58,6 +58,7 @@ const HomeTabs: FC<Props> = ({
const assistantTab = {
label: t('assistants.abbr'),
value: 'assistants'
// icon: <BotIcon size={16} />
}
const onCreateAssistant = async () => {
@@ -104,28 +105,35 @@ const HomeTabs: FC<Props> = ({
return (
<Container style={{ ...border, ...style }} className="home-tabs">
{(showTab || (forceToSeeAllTab == true && !showTopics)) && (
<Segmented
value={tab}
style={{ borderRadius: 16, paddingTop: 10, margin: '0 10px', gap: 2 }}
options={
[
(position === 'left' && topicPosition === 'left') || (forceToSeeAllTab == true && position === 'left')
? assistantTab
: undefined,
{
label: t('common.topics'),
value: 'topic'
},
{
label: t('settings.title'),
value: 'settings'
}
].filter(Boolean) as SegmentedProps['options']
}
onChange={(value) => setTab(value as 'topic' | 'settings')}
block
/>
<>
<Segmented
value={tab}
style={{ borderRadius: 50 }}
shape="round"
options={
[
(position === 'left' && topicPosition === 'left') || (forceToSeeAllTab == true && position === 'left')
? assistantTab
: undefined,
{
label: t('common.topics'),
value: 'topic'
// icon: <MessageSquareQuote size={16} />
},
{
label: t('settings.title'),
value: 'settings'
// icon: <SettingsIcon size={16} />
}
].filter(Boolean) as SegmentedProps['options']
}
onChange={(value) => setTab(value as 'topic' | 'settings')}
block
/>
<Divider />
</>
)}
<TabContent className="home-tabs-content">
{tab === 'assistants' && (
<Assistants
@@ -149,7 +157,7 @@ const Container = styled.div`
flex-direction: column;
max-width: var(--assistants-width);
min-width: var(--assistants-width);
background-color: var(--color-background);
background-color: transparent;
overflow: hidden;
.collapsed {
width: 0;
@@ -165,14 +173,21 @@ const TabContent = styled.div`
overflow-x: hidden;
`
const Divider = styled.div`
border-top: 0.5px solid var(--color-border);
margin-top: 10px;
margin-left: 10px;
margin-right: 10px;
`
const Segmented = styled(AntSegmented)`
font-family: var(--font-family);
&.ant-segmented {
background-color: transparent;
border-radius: 0 !important;
border-bottom: 0.5px solid var(--color-border);
padding-bottom: 10px;
margin: 0 10px;
margin-top: 10px;
padding: 0;
}
.ant-segmented-item {
overflow: hidden;
@@ -184,10 +199,10 @@ const Segmented = styled(AntSegmented)`
border-radius: var(--list-item-border-radius);
box-shadow: none;
}
.ant-segmented-item-selected {
background-color: var(--color-background-soft);
border: 0.5px solid var(--color-border);
.ant-segmented-item-selected,
.ant-segmented-item-selected:active {
transition: none !important;
background-color: var(--color-list-item);
}
.ant-segmented-item-label {
align-items: center;
@@ -200,25 +215,17 @@ const Segmented = styled(AntSegmented)`
.ant-segmented-item-label[aria-selected='true'] {
color: var(--color-text);
}
.iconfont {
font-size: 13px;
margin-left: -2px;
}
.anticon-setting {
font-size: 12px;
}
.icon-business-smart-assistant {
margin-right: -2px;
}
.ant-segmented-item-icon + * {
margin-left: 4px;
}
.ant-segmented-thumb {
transition: none !important;
background-color: var(--color-background-soft);
border: 0.5px solid var(--color-border);
background-color: var(--color-list-item);
border-radius: var(--list-item-border-radius);
box-shadow: none;
&:hover {
background-color: transparent;
}
}
.ant-segmented-item-label,
.ant-segmented-item-icon {

View File

@@ -93,7 +93,7 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
.map((file) => ({
id: file.name,
name: file.name,
path: window.api.file.getPathForFile(file),
path: window.api.file.getPathForFile(file) || '',
size: file.size,
ext: `.${file.name.split('.').pop()}`.toLowerCase(),
count: 1,

View File

@@ -7,8 +7,9 @@ import { Route, Routes, useParams } from 'react-router-dom'
import AihubmixPage from './AihubmixPage'
import DmxapiPage from './DmxapiPage'
import SiliconPage from './SiliconPage'
import TokenFluxPage from './TokenFluxPage'
const Options = ['aihubmix', 'silicon', 'dmxapi']
const Options = ['aihubmix', 'silicon', 'dmxapi', 'tokenflux']
const PaintingsRoutePage: FC = () => {
const params = useParams()
@@ -28,6 +29,7 @@ const PaintingsRoutePage: FC = () => {
<Route path="/aihubmix" element={<AihubmixPage Options={Options} />} />
<Route path="/silicon" element={<SiliconPage Options={Options} />} />
<Route path="/dmxapi" element={<DmxapiPage Options={Options} />} />
<Route path="/tokenflux" element={<TokenFluxPage Options={Options} />} />
</Routes>
)
}

View File

@@ -0,0 +1,786 @@
import { PlusOutlined } from '@ant-design/icons'
import { Navbar, NavbarCenter, NavbarRight } from '@renderer/components/app/Navbar'
import Scrollbar from '@renderer/components/Scrollbar'
import TranslateButton from '@renderer/components/TranslateButton'
import { isMac } from '@renderer/config/constant'
import { getProviderLogo } from '@renderer/config/providers'
import { usePaintings } from '@renderer/hooks/usePaintings'
import { useAllProviders } from '@renderer/hooks/useProvider'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings'
import FileManager from '@renderer/services/FileManager'
import { translateText } from '@renderer/services/TranslateService'
import { useAppDispatch } from '@renderer/store'
import { setGenerating } from '@renderer/store/runtime'
import type { TokenFluxPainting } from '@renderer/types'
import { getErrorMessage, uuid } from '@renderer/utils'
import { Avatar, Button, Select, Tooltip } from 'antd'
import TextArea from 'antd/es/input/TextArea'
import { Info } from 'lucide-react'
import type { FC } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useLocation, useNavigate } from 'react-router-dom'
import styled from 'styled-components'
import SendMessageButton from '../home/Inputbar/SendMessageButton'
import { SettingHelpLink, SettingTitle } from '../settings'
import Artboard from './components/Artboard'
import { DynamicFormRender } from './components/DynamicFormRender'
import PaintingsList from './components/PaintingsList'
import { DEFAULT_TOKENFLUX_PAINTING, type TokenFluxModel } from './config/tokenFluxConfig'
import TokenFluxService from './utils/TokenFluxService'
const TokenFluxPage: FC<{ Options: string[] }> = ({ Options }) => {
const [models, setModels] = useState<TokenFluxModel[]>([])
const [selectedModel, setSelectedModel] = useState<TokenFluxModel | null>(null)
const [formData, setFormData] = useState<Record<string, any>>({})
const [currentImageIndex, setCurrentImageIndex] = useState(0)
const [isLoading, setIsLoading] = useState(false)
const [abortController, setAbortController] = useState<AbortController | null>(null)
const [spaceClickCount, setSpaceClickCount] = useState(0)
const [isTranslating, setIsTranslating] = useState(false)
const { t, i18n } = useTranslation()
const providers = useAllProviders()
const { addPainting, removePainting, updatePainting, persistentData } = usePaintings()
const tokenFluxPaintings = useMemo(() => persistentData.tokenFluxPaintings || [], [persistentData.tokenFluxPaintings])
const [painting, setPainting] = useState<TokenFluxPainting>(
tokenFluxPaintings[0] || { ...DEFAULT_TOKENFLUX_PAINTING, id: uuid() }
)
const providerOptions = Options.map((option) => {
const provider = providers.find((p) => p.id === option)
return {
label: t(`provider.${provider?.id}`),
value: provider?.id
}
})
const dispatch = useAppDispatch()
const { generating } = useRuntime()
const navigate = useNavigate()
const location = useLocation()
const { autoTranslateWithSpace } = useSettings()
const spaceClickTimer = useRef<NodeJS.Timeout>(null)
const tokenfluxProvider = providers.find((p) => p.id === 'tokenflux')!
const textareaRef = useRef<any>(null)
const tokenFluxService = useMemo(
() => new TokenFluxService(tokenfluxProvider.apiHost, tokenfluxProvider.apiKey),
[tokenfluxProvider]
)
useEffect(() => {
tokenFluxService.fetchModels().then((models) => {
setModels(models)
if (models.length > 0) {
setSelectedModel(models[0])
}
})
}, [tokenFluxService])
const getNewPainting = useCallback(() => {
return {
...DEFAULT_TOKENFLUX_PAINTING,
id: uuid(),
model: selectedModel?.id || '',
inputParams: {},
generationId: undefined
}
}, [selectedModel])
const updatePaintingState = useCallback(
(updates: Partial<TokenFluxPainting>) => {
setPainting((prevPainting) => {
const updatedPainting = { ...prevPainting, ...updates }
updatePainting('tokenFluxPaintings', updatedPainting)
return updatedPainting
})
},
[updatePainting]
)
const handleError = (error: unknown) => {
if (error instanceof Error && error.name !== 'AbortError') {
window.modal.error({
content: getErrorMessage(error),
centered: true
})
}
}
const handleModelChange = (modelId: string) => {
const model = models.find((m) => m.id === modelId)
if (model) {
setSelectedModel(model)
setFormData({})
updatePaintingState({ model: model.id, inputParams: {} })
}
}
const handleFormFieldChange = (field: string, value: any) => {
const newFormData = { ...formData, [field]: value }
setFormData(newFormData)
updatePaintingState({ inputParams: newFormData })
}
const onGenerate = async () => {
if (painting.files.length > 0) {
const confirmed = await window.modal.confirm({
content: t('paintings.regenerate.confirm'),
centered: true
})
if (!confirmed) return
await FileManager.deleteFiles(painting.files)
}
const prompt = textareaRef.current?.resizableTextArea?.textArea?.value || ''
if (!tokenfluxProvider.enabled) {
window.modal.error({
content: t('error.provider_disabled'),
centered: true
})
return
}
if (!tokenfluxProvider.apiKey) {
window.modal.error({
content: t('error.no_api_key'),
centered: true
})
return
}
if (!selectedModel || !prompt) {
window.modal.error({
content: t('paintings.text_desc_required'),
centered: true
})
return
}
const controller = new AbortController()
setAbortController(controller)
setIsLoading(true)
dispatch(setGenerating(true))
try {
const requestBody = {
model: selectedModel.id,
input: {
prompt,
...formData
}
}
const inputParams = { prompt, ...formData }
updatePaintingState({
model: selectedModel.id,
prompt,
status: 'processing',
inputParams
})
const result = await tokenFluxService.generateAndWait(requestBody, {
signal: controller.signal,
onStatusUpdate: (updates) => {
updatePaintingState(updates)
}
})
if (result && result.images && result.images.length > 0) {
const urls = result.images.map((img: { url: string }) => img.url)
const validFiles = await tokenFluxService.downloadImages(urls)
await FileManager.addFiles(validFiles)
updatePaintingState({ files: validFiles, urls, status: 'succeeded' })
}
setIsLoading(false)
dispatch(setGenerating(false))
setAbortController(null)
} catch (error: unknown) {
handleError(error)
setIsLoading(false)
dispatch(setGenerating(false))
setAbortController(null)
}
}
const onCancel = () => {
abortController?.abort()
setIsLoading(false)
dispatch(setGenerating(false))
setAbortController(null)
}
const nextImage = () => {
setCurrentImageIndex((prev) => (prev + 1) % painting.files.length)
}
const prevImage = () => {
setCurrentImageIndex((prev) => (prev - 1 + painting.files.length) % painting.files.length)
}
const handleAddPainting = () => {
const newPainting = addPainting('tokenFluxPaintings', getNewPainting())
updatePainting('tokenFluxPaintings', newPainting)
setPainting(newPainting as TokenFluxPainting)
return newPainting
}
const onDeletePainting = (paintingToDelete: TokenFluxPainting) => {
if (paintingToDelete.id === painting.id) {
const currentIndex = tokenFluxPaintings.findIndex((p) => p.id === paintingToDelete.id)
if (currentIndex > 0) {
setPainting(tokenFluxPaintings[currentIndex - 1])
} else if (tokenFluxPaintings.length > 1) {
setPainting(tokenFluxPaintings[1])
}
}
removePainting('tokenFluxPaintings', paintingToDelete)
}
const translate = async () => {
if (isTranslating) {
return
}
if (!painting.prompt) {
return
}
try {
setIsTranslating(true)
const translatedText = await translateText(painting.prompt, 'english')
updatePaintingState({ prompt: translatedText })
} catch (error) {
console.error('Translation failed:', error)
} finally {
setIsTranslating(false)
}
}
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (autoTranslateWithSpace && event.key === ' ') {
setSpaceClickCount((prev) => prev + 1)
if (spaceClickTimer.current) {
clearTimeout(spaceClickTimer.current)
}
spaceClickTimer.current = setTimeout(() => {
setSpaceClickCount(0)
}, 200)
if (spaceClickCount === 2) {
setSpaceClickCount(0)
setIsTranslating(true)
translate()
}
}
}
const handleProviderChange = (providerId: string) => {
const routeName = location.pathname.split('/').pop()
if (providerId !== routeName) {
navigate('../' + providerId, { replace: true })
}
}
const onSelectPainting = (newPainting: TokenFluxPainting) => {
if (generating) return
setPainting(newPainting)
setCurrentImageIndex(0)
// Set form data from painting's input params
if (newPainting.inputParams) {
// Filter out the prompt from inputParams since it's handled separately
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { prompt, ...formInputParams } = newPainting.inputParams
setFormData(formInputParams)
} else {
setFormData({})
}
// Set selected model if available
if (newPainting.model) {
const model = models.find((m) => m.id === newPainting.model)
if (model) {
setSelectedModel(model)
}
} else {
setSelectedModel(null)
}
}
const readI18nContext = (property: Record<string, any>, key: string): string => {
const lang = i18n.language.split('-')[0] // Get the base language code (e.g., 'en' from 'en-US')
console.log('readI18nContext', { property, key, lang })
return property[`${key}_${lang}`] || property[key]
}
useEffect(() => {
if (tokenFluxPaintings.length === 0) {
const newPainting = getNewPainting()
addPainting('tokenFluxPaintings', newPainting)
setPainting(newPainting)
}
}, [tokenFluxPaintings, addPainting, getNewPainting])
useEffect(() => {
const timer = spaceClickTimer.current
return () => {
if (timer) {
clearTimeout(timer)
}
}
}, [])
useEffect(() => {
if (painting.status === 'processing' && painting.generationId) {
tokenFluxService
.pollGenerationResult(painting.generationId, {
onStatusUpdate: (updates) => {
console.log('Polling status update:', updates)
updatePaintingState(updates)
}
})
.then((result) => {
if (result && result.images && result.images.length > 0) {
const urls = result.images.map((img: { url: string }) => img.url)
tokenFluxService.downloadImages(urls).then(async (validFiles) => {
await FileManager.addFiles(validFiles)
updatePaintingState({ files: validFiles, urls, status: 'succeeded' })
})
}
})
.catch((error) => {
console.error('Polling failed:', error)
updatePaintingState({ status: 'failed' })
})
}
}, [painting.generationId, painting.status, tokenFluxService, updatePaintingState])
return (
<Container>
<Navbar>
<NavbarCenter style={{ borderRight: 'none' }}>{t('paintings.title')}</NavbarCenter>
{isMac && (
<NavbarRight style={{ justifyContent: 'flex-end' }}>
<Button size="small" className="nodrag" icon={<PlusOutlined />} onClick={handleAddPainting}>
{t('paintings.button.new.image')}
</Button>
</NavbarRight>
)}
</Navbar>
<ContentContainer id="content-container">
<LeftContainer>
{/* Provider Section */}
<ProviderTitleContainer>
<SettingTitle style={{ marginBottom: 8 }}>{t('common.provider')}</SettingTitle>
<SettingHelpLink target="_blank" href="https://tokenflux.ai">
{t('paintings.learn_more')}
<ProviderLogo shape="square" src={getProviderLogo('tokenflux')} size={16} style={{ marginLeft: 5 }} />
</SettingHelpLink>
</ProviderTitleContainer>
<Select
value={providerOptions.find((p) => p.value === 'tokenflux')?.value}
onChange={handleProviderChange}
style={{ width: '100%' }}>
{providerOptions.map((provider) => (
<Select.Option value={provider.value} key={provider.value}>
<SelectOptionContainer>
<ProviderLogo shape="square" src={getProviderLogo(provider.value || '')} size={16} />
{provider.label}
</SelectOptionContainer>
</Select.Option>
))}
</Select>
{/* Model & Pricing Section */}
<SectionTitle
style={{ marginBottom: 5, marginTop: 15, justifyContent: 'space-between', alignItems: 'center' }}>
{t('paintings.model_and_pricing')}
{selectedModel && selectedModel.pricing && (
<PricingContainer>
<PricingBadge>
{selectedModel.pricing.price} {selectedModel.pricing.currency}{' '}
{selectedModel.pricing.unit > 1 ? t('paintings.per_images') : t('paintings.per_image')}
</PricingBadge>
</PricingContainer>
)}
</SectionTitle>
<Select
style={{ width: '100%', marginBottom: 12 }}
value={selectedModel?.id}
onChange={handleModelChange}
placeholder={t('paintings.select_model')}>
{Object.entries(
models.reduce(
(acc, model) => {
const provider = model.model_provider || 'Other'
if (!acc[provider]) {
acc[provider] = []
}
acc[provider].push(model)
return acc
},
{} as Record<string, typeof models>
)
).map(([provider, providerModels]) => (
<Select.OptGroup key={provider} label={provider}>
{providerModels.map((model) => (
<Select.Option key={model.id} value={model.id}>
<Tooltip title={model.description} placement="right">
<ModelOptionContainer>
<ModelName>{model.name}</ModelName>
</ModelOptionContainer>
</Tooltip>
</Select.Option>
))}
</Select.OptGroup>
))}
</Select>
{/* Input Parameters Section */}
{selectedModel && selectedModel.input_schema && (
<>
<SectionTitle style={{ marginBottom: 5, marginTop: 10 }}>{t('paintings.input_parameters')}</SectionTitle>
<ParametersContainer>
{Object.entries(selectedModel.input_schema.properties).map(([key, property]: [string, any]) => {
if (key === 'prompt') return null // Skip prompt as it's handled separately
const isRequired = selectedModel.input_schema.required?.includes(key)
return (
<ParameterField key={key}>
<ParameterLabel>
<ParameterName>
{readI18nContext(property, 'title')}
{isRequired && <RequiredIndicator> *</RequiredIndicator>}
</ParameterName>
{property.description && (
<Tooltip title={readI18nContext(property, 'description')}>
<InfoIcon />
</Tooltip>
)}
</ParameterLabel>
<DynamicFormRender
schemaProperty={property}
propertyName={key}
value={formData[key]}
onChange={handleFormFieldChange}
/>
</ParameterField>
)
})}
</ParametersContainer>
</>
)}
</LeftContainer>
<MainContainer>
{/* Check if any form field contains an uploaded image */}
{Object.keys(formData).some((key) => key.toLowerCase().includes('image') && formData[key]) ? (
<ComparisonContainer>
<ImageComparisonSection>
<SectionLabel>{t('paintings.input_image')}</SectionLabel>
<UploadedImageContainer>
{Object.entries(formData).map(([key, value]) => {
if (key.toLowerCase().includes('image') && value) {
return (
<ImageWrapper key={key}>
<img
src={value}
alt={t('paintings.uploaded_input')}
style={{
maxWidth: '100%',
maxHeight: '70vh',
objectFit: 'contain',
backgroundColor: 'var(--color-background-soft)'
}}
/>
</ImageWrapper>
)
}
return null
})}
</UploadedImageContainer>
</ImageComparisonSection>
<ImageComparisonSection>
<SectionLabel>{t('paintings.generated_image')}</SectionLabel>
<Artboard
painting={painting}
isLoading={isLoading}
currentImageIndex={currentImageIndex}
onPrevImage={prevImage}
onNextImage={nextImage}
onCancel={onCancel}
/>
</ImageComparisonSection>
</ComparisonContainer>
) : (
<Artboard
painting={painting}
isLoading={isLoading}
currentImageIndex={currentImageIndex}
onPrevImage={prevImage}
onNextImage={nextImage}
onCancel={onCancel}
/>
)}
<InputContainer>
<Textarea
ref={textareaRef}
variant="borderless"
disabled={isLoading}
value={painting.prompt || ''}
spellCheck={false}
onChange={(e) => updatePaintingState({ prompt: e.target.value })}
placeholder={isTranslating ? t('paintings.translating') : t('paintings.prompt_placeholder')}
onKeyDown={handleKeyDown}
/>
<Toolbar>
<ToolbarMenu>
<TranslateButton
text={textareaRef.current?.resizableTextArea?.textArea?.value}
onTranslated={(translatedText) => updatePaintingState({ prompt: translatedText })}
disabled={isLoading || isTranslating}
isLoading={isTranslating}
style={{ marginRight: 6, borderRadius: '50%' }}
/>
<SendMessageButton sendMessage={onGenerate} disabled={isLoading} />
</ToolbarMenu>
</Toolbar>
</InputContainer>
</MainContainer>
<PaintingsList
namespace="tokenFluxPaintings"
paintings={tokenFluxPaintings}
selectedPainting={painting}
onSelectPainting={onSelectPainting as any}
onDeletePainting={onDeletePainting as any}
onNewPainting={handleAddPainting}
/>
</ContentContainer>
</Container>
)
}
const SectionTitle = styled.div`
font-size: 14px;
font-weight: 600;
color: var(--color-text);
margin-bottom: 12px;
display: flex;
align-items: center;
`
const ModelOptionContainer = styled.div`
display: flex;
flex-direction: column;
`
const ModelName = styled.div`
color: var(--color-text);
`
const PricingContainer = styled.div`
display: flex;
justify-content: flex-end;
`
const PricingBadge = styled.div`
background-color: var(--color-primary-bg);
color: var(--color-primary);
font-size: 11px;
font-weight: 500;
padding: 4px 0;
border-radius: 4px;
border: 1px solid var(--color-primary-border);
`
const ParametersContainer = styled.div`
display: flex;
flex-direction: column;
gap: 12px;
`
const ParameterField = styled.div`
display: flex;
flex-direction: column;
`
const ParameterLabel = styled.div`
display: flex;
align-items: center;
margin-bottom: 6px;
`
const ParameterName = styled.span`
font-size: 13px;
font-weight: 500;
color: var(--color-text);
text-transform: capitalize;
`
const RequiredIndicator = styled.span`
color: var(--color-error);
font-weight: 600;
`
const Container = styled.div`
display: flex;
flex: 1;
flex-direction: column;
height: 100%;
`
const ContentContainer = styled.div`
display: flex;
flex: 1;
flex-direction: row;
height: 100%;
background-color: var(--color-background);
overflow: hidden;
`
const LeftContainer = styled(Scrollbar)`
display: flex;
flex: 1;
flex-direction: column;
height: 100%;
padding: 20px;
background-color: var(--color-background);
max-width: var(--assistants-width);
border-right: 0.5px solid var(--color-border);
`
const MainContainer = styled.div`
display: flex;
flex: 1;
flex-direction: column;
height: 100%;
background-color: var(--color-background);
`
const ComparisonContainer = styled.div`
display: flex;
flex: 1;
flex-direction: row;
height: 100%;
gap: 1px;
`
const ImageComparisonSection = styled.div`
display: flex;
flex: 1;
flex-direction: column;
height: 100%;
background-color: var(--color-background);
&:first-child {
border-right: 0.5px solid var(--color-border);
}
`
const SectionLabel = styled.div`
padding: 10px 20px;
font-size: 14px;
font-weight: 500;
color: var(--color-text-2);
background-color: var(--color-background-soft);
border-bottom: 1px solid var(--color-border);
text-align: center;
`
const UploadedImageContainer = styled.div`
display: flex;
flex: 1;
justify-content: center;
align-items: center;
background-color: var(--color-background);
`
const ImageWrapper = styled.div`
position: relative;
display: flex;
justify-content: center;
align-items: center;
`
const InputContainer = styled.div`
display: flex;
flex-direction: column;
min-height: 95px;
max-height: 95px;
position: relative;
border: 1px solid var(--color-border-soft);
transition: all 0.3s ease;
margin: 0 20px 15px 20px;
border-radius: 10px;
`
const Textarea = styled(TextArea)`
padding: 10px;
border-radius: 0;
display: flex;
flex: 1;
resize: none !important;
overflow: auto;
width: auto;
`
const Toolbar = styled.div`
display: flex;
flex-direction: row;
justify-content: space-between;
justify-content: flex-end;
padding: 0 8px;
padding-bottom: 0;
height: 40px;
`
const ToolbarMenu = styled.div`
display: flex;
flex-direction: row;
align-items: center;
gap: 6px;
`
const InfoIcon = styled(Info)`
margin-left: 5px;
cursor: help;
color: var(--color-text-2);
opacity: 0.6;
width: 14px;
height: 16px;
&:hover {
opacity: 1;
}
`
const ProviderLogo = styled(Avatar)`
border: 0.5px solid var(--color-border);
`
const ProviderTitleContainer = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 5px;
`
const SelectOptionContainer = styled.div`
display: flex;
align-items: center;
gap: 8px;
`
export default TokenFluxPage

View File

@@ -0,0 +1,213 @@
import { CloseOutlined, LinkOutlined, RedoOutlined, UploadOutlined } from '@ant-design/icons'
import { convertToBase64 } from '@renderer/utils'
import { Button, Input, InputNumber, Select, Switch, Upload } from 'antd'
import TextArea from 'antd/es/input/TextArea'
import { useCallback } from 'react'
interface DynamicFormRenderProps {
schemaProperty: any
propertyName: string
value: any
onChange: (field: string, value: any) => void
}
export const DynamicFormRender: React.FC<DynamicFormRenderProps> = ({
schemaProperty,
propertyName,
value,
onChange
}) => {
const { type, enum: enumValues, description, default: defaultValue, format } = schemaProperty
const handleImageUpload = useCallback(
async (
propertyName: string,
fileOrUrl: File | string,
onChange: (field: string, value: any) => void
): Promise<void> => {
try {
if (typeof fileOrUrl === 'string') {
// Handle URL case - validate and set directly
if (fileOrUrl.match(/^https?:\/\/.+\.(jpg|jpeg|png|gif|webp|svg)(\?.*)?$/i)) {
onChange(propertyName, fileOrUrl)
} else {
window.message?.error('Invalid image URL format')
}
} else {
// Handle File case - convert to base64
const base64Image = await convertToBase64(fileOrUrl)
if (typeof base64Image === 'string') {
onChange(propertyName, base64Image)
} else {
console.error('Failed to convert image to base64')
}
}
} catch (error) {
console.error('Error processing image:', error)
}
},
[]
)
if (type === 'string' && propertyName.toLowerCase().includes('image') && format === 'uri') {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<div style={{ display: 'flex', gap: '0' }}>
<Input
style={{
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
borderRight: 'none'
}}
value={value || defaultValue || ''}
onChange={(e) => onChange(propertyName, e.target.value)}
placeholder="Enter image URL or upload file"
prefix={<LinkOutlined style={{ color: '#999' }} />}
/>
<Upload
accept="image/*"
showUploadList={false}
beforeUpload={(file) => {
handleImageUpload(propertyName, file, onChange)
return false
}}>
<Button
icon={<UploadOutlined />}
title="Upload image file"
style={{
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
height: '32px'
}}
/>
</Upload>
</div>
{value && (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px',
backgroundColor: 'var(--color-fill-quaternary)',
borderRadius: '6px',
border: '1px solid var(--color-border)'
}}>
<img
src={value}
alt="Image preview"
style={{
width: '48px',
height: '48px',
objectFit: 'cover',
borderRadius: '4px',
border: '1px solid var(--color-border-secondary)',
boxShadow: '0 1px 4px rgba(0, 0, 0, 0.1)',
flexShrink: 0
}}
/>
<div
style={{
flex: 1,
fontSize: '12px',
color: 'var(--color-text-secondary)',
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis'
}}>
{value.startsWith('data:') ? 'Uploaded image' : 'Image URL'}
</div>
<Button
size="small"
danger
icon={<CloseOutlined />}
onClick={() => onChange(propertyName, '')}
title="Remove image"
style={{ flexShrink: 0, minWidth: 'auto', padding: '0 8px' }}
/>
</div>
)}
</div>
)
}
if (type === 'string' && enumValues) {
return (
<Select
style={{ width: '100%' }}
value={value || defaultValue}
options={enumValues.map((val: string) => ({ label: val, value: val }))}
onChange={(v) => onChange(propertyName, v)}
/>
)
}
if (type === 'string') {
if (propertyName.toLowerCase().includes('prompt') && propertyName !== 'prompt') {
return (
<TextArea
value={value || defaultValue || ''}
onChange={(e) => onChange(propertyName, e.target.value)}
rows={3}
placeholder={description}
/>
)
}
return (
<Input
value={value || defaultValue || ''}
onChange={(e) => onChange(propertyName, e.target.value)}
placeholder={description}
/>
)
}
if (type === 'integer' && propertyName === 'seed') {
const generateRandomSeed = () => Math.floor(Math.random() * 1000000)
return (
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<InputNumber
style={{ flex: 1 }}
value={value || defaultValue}
onChange={(v) => onChange(propertyName, v)}
step={1}
min={schemaProperty.minimum}
max={schemaProperty.maximum}
/>
<Button
size="small"
icon={<RedoOutlined />}
onClick={() => onChange(propertyName, generateRandomSeed())}
title="Generate random seed"
/>
</div>
)
}
if (type === 'integer' || type === 'number') {
const step = type === 'number' ? 0.1 : 1
return (
<InputNumber
style={{ width: '100%' }}
value={value || defaultValue}
onChange={(v) => onChange(propertyName, v)}
step={step}
min={schemaProperty.minimum}
max={schemaProperty.maximum}
/>
)
}
if (type === 'boolean') {
return (
<Switch
checked={value !== undefined ? value : defaultValue}
onChange={(checked) => onChange(propertyName, checked)}
style={{ width: '2px' }}
/>
)
}
return null
}

View File

@@ -0,0 +1,27 @@
import type { TokenFluxPainting } from '@renderer/types'
import { uuid } from '@renderer/utils'
export interface TokenFluxModel {
id: string
name: string
model_provider: string
description: string
tags: string[]
pricing: any
input_schema: {
type: string
properties: Record<string, any>
required: string[]
}
}
export const DEFAULT_TOKENFLUX_PAINTING: TokenFluxPainting = {
id: uuid(),
model: '',
prompt: '',
inputParams: {},
status: 'starting',
generationId: undefined,
urls: [],
files: []
}

View File

@@ -0,0 +1,237 @@
import { CacheService } from '@renderer/services/CacheService'
import { FileType, TokenFluxPainting } from '@renderer/types'
import type { TokenFluxModel } from '../config/tokenFluxConfig'
export interface TokenFluxGenerationRequest {
model: string
input: {
prompt: string
[key: string]: any
}
}
export interface TokenFluxGenerationResponse {
success: boolean
data?: {
id: string
status: string
images?: Array<{ url: string }>
}
message?: string
}
export interface TokenFluxModelsResponse {
success: boolean
data?: TokenFluxModel[]
message?: string
}
export class TokenFluxService {
private apiHost: string
private apiKey: string
constructor(apiHost: string, apiKey: string) {
this.apiHost = apiHost
this.apiKey = apiKey
}
private getHeaders(): Record<string, string> {
return {
Authorization: `Bearer ${this.apiKey}`,
'Content-Type': 'application/json'
}
}
private async handleResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: 'Unknown error' }))
throw new Error(errorData.message || `HTTP ${response.status}: Request failed`)
}
return response.json()
}
/**
* Fetch available models from TokenFlux API
*/
async fetchModels(): Promise<TokenFluxModel[]> {
const cacheKey = `tokenflux_models_${this.apiHost}`
// Check cache first
const cachedModels = CacheService.get<TokenFluxModel[]>(cacheKey)
if (cachedModels) {
return cachedModels
}
const response = await fetch(`${this.apiHost}/v1/images/models`, {
headers: {
Authorization: `Bearer ${this.apiKey}`
}
})
const data: TokenFluxModelsResponse = await this.handleResponse(response)
if (!data.success || !data.data) {
throw new Error('Failed to fetch models')
}
// Cache for 60 minutes (3,600,000 milliseconds)
CacheService.set(cacheKey, data.data, 60 * 60 * 1000)
return data.data
}
/**
* Create a new image generation request
*/
async createGeneration(request: TokenFluxGenerationRequest, signal?: AbortSignal): Promise<string> {
const response = await fetch(`${this.apiHost}/v1/images/generations`, {
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify(request),
signal
})
const data: TokenFluxGenerationResponse = await this.handleResponse(response)
if (!data.success || !data.data?.id) {
throw new Error(data.message || 'Generation failed')
}
return data.data.id
}
/**
* Get the status and result of a generation
*/
async getGenerationResult(generationId: string): Promise<TokenFluxGenerationResponse['data']> {
const response = await fetch(`${this.apiHost}/v1/images/generations/${generationId}`, {
headers: {
Authorization: `Bearer ${this.apiKey}`
}
})
const data: TokenFluxGenerationResponse = await this.handleResponse(response)
if (!data.success || !data.data) {
throw new Error('Invalid response from generation service')
}
return data.data
}
/**
* Poll for generation result with automatic retry logic
*/
async pollGenerationResult(
generationId: string,
options: {
onStatusUpdate?: (updates: Partial<TokenFluxPainting>) => void
maxRetries?: number
timeoutMs?: number
intervalMs?: number
} = {}
): Promise<TokenFluxGenerationResponse['data']> {
const {
onStatusUpdate,
maxRetries = 10,
timeoutMs = 120000, // 2 minutes
intervalMs = 2000
} = options
const startTime = Date.now()
let retryCount = 0
return new Promise((resolve, reject) => {
const poll = async () => {
try {
// Check for timeout
if (Date.now() - startTime > timeoutMs) {
reject(new Error('Image generation timed out. Please try again.'))
return
}
const result = await this.getGenerationResult(generationId)
// Reset retry count on successful response
retryCount = 0
if (result) {
onStatusUpdate?.({ status: result.status as TokenFluxPainting['status'] })
if (result.status === 'succeeded') {
resolve(result)
return
} else if (result.status === 'failed') {
reject(new Error('Image generation failed'))
return
}
}
// Continue polling for other statuses (processing, queued, etc.)
setTimeout(poll, intervalMs)
} catch (error) {
console.error('Polling error:', error)
retryCount++
if (retryCount >= maxRetries) {
reject(new Error('Failed to check generation status after multiple attempts. Please try again.'))
return
}
// Retry after interval
setTimeout(poll, intervalMs)
}
}
// Start polling
poll()
})
}
/**
* Create generation and poll for result in one call
*/
async generateAndWait(
request: TokenFluxGenerationRequest,
options: {
onStatusUpdate?: (updates: Partial<TokenFluxPainting>) => void
signal?: AbortSignal
maxRetries?: number
timeoutMs?: number
intervalMs?: number
} = {}
): Promise<TokenFluxGenerationResponse['data']> {
const { signal, onStatusUpdate, ...pollOptions } = options
const generationId = await this.createGeneration(request, signal)
if (onStatusUpdate) {
onStatusUpdate({ generationId })
}
return this.pollGenerationResult(generationId, { ...pollOptions, onStatusUpdate })
}
async downloadImages(urls: string[]) {
const downloadedFiles = await Promise.all(
urls.map(async (url) => {
try {
if (!url?.trim()) {
console.error('Image URL is empty')
window.message.warning({
content: 'Image URL is empty',
key: 'empty-url-warning'
})
return null
}
return await window.api.file.download(url)
} catch (error) {
console.error('Failed to download image:', error)
return null
}
})
)
return downloadedFiles.filter((file): file is FileType => file !== null)
}
}
export default TokenFluxService

View File

@@ -13,6 +13,8 @@ import { useTranslation } from 'react-i18next'
import ReactMarkdown from 'react-markdown'
import styled from 'styled-components'
import { SettingDivider } from '..'
interface Props {
assistant: Assistant
updateAssistant: (assistant: Assistant) => void
@@ -90,7 +92,8 @@ const AssistantPromptSettings: React.FC<Props> = ({ assistant, updateAssistant }
style={{ flex: 1 }}
/>
</HStack>
<HStack mt={8} mb={8} alignItems="center" gap={4}>
<SettingDivider />
<HStack mb={8} alignItems="center" gap={4}>
<Box style={{ fontWeight: 'bold' }}>{t('common.prompt')}</Box>
<Tooltip title={t('agents.add.prompt.variables.tip')}>
<QuestionCircleOutlined size={14} color="var(--color-text-2)" />
@@ -139,7 +142,6 @@ const Container = styled.div`
flex: 1;
flex-direction: column;
overflow: hidden;
padding: 5px;
`
const EmojiButtonWrapper = styled.div`

View File

@@ -8,7 +8,7 @@ import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import { v4 as uuidv4 } from 'uuid'
import { SettingContainer, SettingDivider, SettingGroup, SettingRow, SettingTitle } from '..'
import { SettingDivider, SettingRow, SettingTitle } from '..'
const { TextArea } = Input
@@ -79,52 +79,50 @@ const AssistantRegularPromptsSettings: FC<AssistantRegularPromptsSettingsProps>
const reversedPrompts = [...promptsList].reverse()
return (
<SettingContainer style={{ padding: 0, background: '#0000' }}>
<SettingGroup style={{ marginBottom: 0, padding: 0, border: 'none' }}>
<SettingTitle>
{t('assistants.settings.regular_phrases.title', 'Regular Prompts')}
<Button type="text" icon={<PlusOutlined />} onClick={handleAdd} />
</SettingTitle>
<SettingDivider />
<SettingRow>
<StyledPromptList>
<DragableList
list={reversedPrompts}
onUpdate={(newPrompts) => handleUpdateOrder([...newPrompts].reverse())}
style={{ paddingBottom: dragging ? '34px' : 0 }}
onDragStart={() => setDragging(true)}
onDragEnd={() => setDragging(false)}>
{(prompt) => (
<FileItem
key={prompt.id}
fileInfo={{
name: prompt.title,
ext: '.txt',
extra: prompt.content,
actions: (
<Flex gap={4} style={{ opacity: 0.6 }}>
<Button key="edit" type="text" icon={<EditOutlined />} onClick={() => handleEdit(prompt)} />
<Popconfirm
title={t('assistants.settings.regular_phrases.delete', 'Delete Prompt')}
description={t(
'assistants.settings.regular_phrases.deleteConfirm',
'Are you sure to delete this prompt?'
)}
okText={t('common.confirm')}
cancelText={t('common.cancel')}
onConfirm={() => handleDelete(prompt.id)}
icon={<ExclamationCircleOutlined style={{ color: 'red' }} />}>
<Button key="delete" type="text" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Flex>
)
}}
/>
)}
</DragableList>
</StyledPromptList>
</SettingRow>
</SettingGroup>
<Container>
<SettingTitle>
{t('assistants.settings.regular_phrases.title', 'Regular Prompts')}
<Button type="text" icon={<PlusOutlined />} onClick={handleAdd} />
</SettingTitle>
<SettingDivider />
<SettingRow>
<StyledPromptList>
<DragableList
list={reversedPrompts}
onUpdate={(newPrompts) => handleUpdateOrder([...newPrompts].reverse())}
style={{ paddingBottom: dragging ? '34px' : 0 }}
onDragStart={() => setDragging(true)}
onDragEnd={() => setDragging(false)}>
{(prompt) => (
<FileItem
key={prompt.id}
fileInfo={{
name: prompt.title,
ext: '.txt',
extra: prompt.content,
actions: (
<Flex gap={4} style={{ opacity: 0.6 }}>
<Button key="edit" type="text" icon={<EditOutlined />} onClick={() => handleEdit(prompt)} />
<Popconfirm
title={t('assistants.settings.regular_phrases.delete', 'Delete Prompt')}
description={t(
'assistants.settings.regular_phrases.deleteConfirm',
'Are you sure to delete this prompt?'
)}
okText={t('common.confirm')}
cancelText={t('common.cancel')}
onConfirm={() => handleDelete(prompt.id)}
icon={<ExclamationCircleOutlined style={{ color: 'red' }} />}>
<Button key="delete" type="text" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Flex>
)
}}
/>
)}
</DragableList>
</StyledPromptList>
</SettingRow>
<Modal
title={
@@ -159,10 +157,16 @@ const AssistantRegularPromptsSettings: FC<AssistantRegularPromptsSettingsProps>
</div>
</Space>
</Modal>
</SettingContainer>
</Container>
)
}
const Container = styled.div`
display: flex;
flex: 1;
flex-direction: column;
`
const Label = styled.div`
font-size: 14px;
color: var(--color-text);
@@ -171,8 +175,6 @@ const Label = styled.div`
const StyledPromptList = styled.div`
width: 100%;
height: calc(100vh - 162px); // Adjusted height to match other settings pages
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 8px;

View File

@@ -3,14 +3,14 @@ import { HStack } from '@renderer/components/Layout'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
import { RootState, useAppDispatch } from '@renderer/store'
import { setJoplinToken, setJoplinUrl } from '@renderer/store/settings'
import { Button, Tooltip } from 'antd'
import { setJoplinExportReasoning, setJoplinToken, setJoplinUrl } from '@renderer/store/settings'
import { Button, Switch, Tooltip } from 'antd'
import Input from 'antd/es/input/Input'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { useSelector } from 'react-redux'
import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..'
import { SettingDivider, SettingGroup, SettingHelpText, SettingRow, SettingRowTitle, SettingTitle } from '..'
const JoplinSettings: FC = () => {
const { t } = useTranslation()
@@ -20,6 +20,7 @@ const JoplinSettings: FC = () => {
const joplinToken = useSelector((state: RootState) => state.settings.joplinToken)
const joplinUrl = useSelector((state: RootState) => state.settings.joplinUrl)
const joplinExportReasoning = useSelector((state: RootState) => state.settings.joplinExportReasoning)
const handleJoplinTokenChange = (e: React.ChangeEvent<HTMLInputElement>) => {
dispatch(setJoplinToken(e.target.value))
@@ -72,6 +73,10 @@ const JoplinSettings: FC = () => {
})
}
const handleToggleJoplinExportReasoning = (checked: boolean) => {
dispatch(setJoplinExportReasoning(checked))
}
return (
<SettingGroup theme={theme}>
<SettingTitle>{t('settings.data.joplin.title')}</SettingTitle>
@@ -111,6 +116,14 @@ const JoplinSettings: FC = () => {
<Button onClick={handleJoplinConnectionCheck}>{t('settings.data.joplin.check.button')}</Button>
</HStack>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.joplin.export_reasoning.title')}</SettingRowTitle>
<Switch checked={joplinExportReasoning} onChange={handleToggleJoplinExportReasoning} />
</SettingRow>
<SettingRow>
<SettingHelpText>{t('settings.data.joplin.export_reasoning.help')}</SettingHelpText>
</SettingRow>
</SettingGroup>
)
}

View File

@@ -5,6 +5,8 @@ import { RootState, useAppDispatch } from '@renderer/store'
import {
setForceDollarMathInMarkdown,
setmarkdownExportPath,
setShowModelNameInMarkdown,
setShowModelProviderInMarkdown,
setUseTopicNamingForMessageTitle
} from '@renderer/store/settings'
import { Button, Switch } from 'antd'
@@ -23,6 +25,8 @@ const MarkdownExportSettings: FC = () => {
const markdownExportPath = useSelector((state: RootState) => state.settings.markdownExportPath)
const forceDollarMathInMarkdown = useSelector((state: RootState) => state.settings.forceDollarMathInMarkdown)
const useTopicNamingForMessageTitle = useSelector((state: RootState) => state.settings.useTopicNamingForMessageTitle)
const showModelNameInExport = useSelector((state: RootState) => state.settings.showModelNameInMarkdown)
const showModelProviderInMarkdown = useSelector((state: RootState) => state.settings.showModelProviderInMarkdown)
const handleSelectFolder = async () => {
const path = await window.api.file.selectFolder()
@@ -43,6 +47,14 @@ const MarkdownExportSettings: FC = () => {
dispatch(setUseTopicNamingForMessageTitle(checked))
}
const handleToggleShowModelName = (checked: boolean) => {
dispatch(setShowModelNameInMarkdown(checked))
}
const handleToggleShowModelProvider = (checked: boolean) => {
dispatch(setShowModelProviderInMarkdown(checked))
}
return (
<SettingGroup theme={theme}>
<SettingTitle>{t('settings.data.markdown_export.title')}</SettingTitle>
@@ -86,6 +98,22 @@ const MarkdownExportSettings: FC = () => {
<SettingRow>
<SettingHelpText>{t('settings.data.message_title.use_topic_naming.help')}</SettingHelpText>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.markdown_export.show_model_name.title')}</SettingRowTitle>
<Switch checked={showModelNameInExport} onChange={handleToggleShowModelName} />
</SettingRow>
<SettingRow>
<SettingHelpText>{t('settings.data.markdown_export.show_model_name.help')}</SettingHelpText>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.markdown_export.show_model_provider.title')}</SettingRowTitle>
<Switch checked={showModelProviderInMarkdown} onChange={handleToggleShowModelProvider} />
</SettingRow>
<SettingRow>
<SettingHelpText>{t('settings.data.markdown_export.show_model_provider.help')}</SettingHelpText>
</SettingRow>
</SettingGroup>
)
}

View File

@@ -6,12 +6,11 @@ import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
import { RootState, useAppDispatch } from '@renderer/store'
import {
setNotionApiKey,
setNotionAutoSplit,
setNotionDatabaseID,
setNotionPageNameKey,
setNotionSplitSize
setNotionExportReasoning,
setNotionPageNameKey
} from '@renderer/store/settings'
import { Button, InputNumber, Switch, Tooltip } from 'antd'
import { Button, Switch, Tooltip } from 'antd'
import Input from 'antd/es/input/Input'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
@@ -27,8 +26,7 @@ const NotionSettings: FC = () => {
const notionApiKey = useSelector((state: RootState) => state.settings.notionApiKey)
const notionDatabaseID = useSelector((state: RootState) => state.settings.notionDatabaseID)
const notionPageNameKey = useSelector((state: RootState) => state.settings.notionPageNameKey)
const notionAutoSplit = useSelector((state: RootState) => state.settings.notionAutoSplit)
const notionSplitSize = useSelector((state: RootState) => state.settings.notionSplitSize)
const notionExportReasoning = useSelector((state: RootState) => state.settings.notionExportReasoning)
const handleNotionTokenChange = (e: React.ChangeEvent<HTMLInputElement>) => {
dispatch(setNotionApiKey(e.target.value))
@@ -76,14 +74,8 @@ const NotionSettings: FC = () => {
})
}
const handleNotionAutoSplitChange = (checked: boolean) => {
dispatch(setNotionAutoSplit(checked))
}
const handleNotionSplitSizeChange = (value: number | null) => {
if (value !== null) {
dispatch(setNotionSplitSize(value))
}
const handleNotionExportReasoningChange = (checked: boolean) => {
dispatch(setNotionExportReasoning(checked))
}
return (
@@ -140,38 +132,14 @@ const NotionSettings: FC = () => {
<Button onClick={handleNotionConnectionCheck}>{t('settings.data.notion.check.button')}</Button>
</HStack>
</SettingRow>
<SettingDivider /> {/* 添加分割线 */}
<SettingDivider />
<SettingRow>
<SettingRowTitle>
<Tooltip title={t('settings.data.notion.auto_split_tip')} placement="right">
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
{t('settings.data.notion.auto_split')}
<InfoCircleOutlined style={{ cursor: 'pointer' }} />
</span>
</Tooltip>
</SettingRowTitle>
<Switch checked={notionAutoSplit} onChange={handleNotionAutoSplitChange} />
<SettingRowTitle>{t('settings.data.notion.export_reasoning.title')}</SettingRowTitle>
<Switch checked={notionExportReasoning} onChange={handleNotionExportReasoningChange} />
</SettingRow>
<SettingRow>
<SettingHelpText>{t('settings.data.notion.export_reasoning.help')}</SettingHelpText>
</SettingRow>
{notionAutoSplit && (
<>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.notion.split_size')}</SettingRowTitle>
<InputNumber
min={30}
max={25000}
value={notionSplitSize}
onChange={handleNotionSplitSizeChange}
keyboard={true}
controls={true}
style={{ width: 120 }}
/>
</SettingRow>
<SettingRow>
<SettingHelpText style={{ marginLeft: 10 }}>{t('settings.data.notion.split_size_help')}</SettingHelpText>
</SettingRow>
</>
)}
</SettingGroup>
)
}

View File

@@ -1,7 +1,9 @@
import { CheckCircleOutlined, QuestionCircleOutlined, WarningOutlined } from '@ant-design/icons'
import { Center, VStack } from '@renderer/components/Layout'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { setIsBunInstalled, setIsUvInstalled } from '@renderer/store/mcp'
import { Alert, Button } from 'antd'
import { FC, useEffect, useState } from 'react'
import { FC, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router'
import styled from 'styled-components'
@@ -13,8 +15,10 @@ interface Props {
}
const InstallNpxUv: FC<Props> = ({ mini = false }) => {
const [isUvInstalled, setIsUvInstalled] = useState(true)
const [isBunInstalled, setIsBunInstalled] = useState(true)
const dispatch = useAppDispatch()
const isUvInstalled = useAppSelector((state) => state.mcp.isUvInstalled)
const isBunInstalled = useAppSelector((state) => state.mcp.isBunInstalled)
const [isInstallingUv, setIsInstallingUv] = useState(false)
const [isInstallingBun, setIsInstallingBun] = useState(false)
const [uvPath, setUvPath] = useState<string | null>(null)
@@ -22,18 +26,17 @@ const InstallNpxUv: FC<Props> = ({ mini = false }) => {
const [binariesDir, setBinariesDir] = useState<string | null>(null)
const { t } = useTranslation()
const navigate = useNavigate()
const checkBinaries = async () => {
const checkBinaries = useCallback(async () => {
const uvExists = await window.api.isBinaryExist('uv')
const bunExists = await window.api.isBinaryExist('bun')
const { uvPath, bunPath, dir } = await window.api.mcp.getInstallInfo()
setIsUvInstalled(uvExists)
setIsBunInstalled(bunExists)
dispatch(setIsUvInstalled(uvExists))
dispatch(setIsBunInstalled(bunExists))
setUvPath(uvPath)
setBunPath(bunPath)
setBinariesDir(dir)
}
}, [dispatch])
const installUV = async () => {
try {
@@ -66,7 +69,7 @@ const InstallNpxUv: FC<Props> = ({ mini = false }) => {
useEffect(() => {
checkBinaries()
}, [])
}, [checkBinaries])
if (mini) {
const installed = isUvInstalled && isBunInstalled

View File

@@ -307,17 +307,21 @@ const ModelList: React.FC<ModelListProps> = ({ providerId, modelStatuses = [], s
</CustomCollapse>
</CustomCollapseWrapper>
))}
{docsWebsite && (
{(docsWebsite || modelsWebsite) && (
<SettingHelpTextRow>
<SettingHelpText>{t('settings.provider.docs_check')} </SettingHelpText>
<SettingHelpLink target="_blank" href={docsWebsite}>
{t(`provider.${provider.id}`) + ' '}
{t('common.docs')}
</SettingHelpLink>
<SettingHelpText>{t('common.and')}</SettingHelpText>
<SettingHelpLink target="_blank" href={modelsWebsite}>
{t('common.models')}
</SettingHelpLink>
{docsWebsite && (
<SettingHelpLink target="_blank" href={docsWebsite}>
{t(`provider.${provider.id}`) + ' '}
{t('common.docs')}
</SettingHelpLink>
)}
{docsWebsite && modelsWebsite && <SettingHelpText>{t('common.and')}</SettingHelpText>}
{modelsWebsite && (
<SettingHelpLink target="_blank" href={modelsWebsite}>
{t('common.models')}
</SettingHelpLink>
)}
<SettingHelpText>{t('settings.provider.docs_more_details')}</SettingHelpText>
</SettingHelpTextRow>
)}

View File

@@ -445,6 +445,14 @@ export default class AnthropicProvider extends BaseProvider {
)
}
if (thinking_content) {
onChunk({
type: ChunkType.THINKING_COMPLETE,
text: thinking_content,
thinking_millsec: new Date().getTime() - time_first_token_millsec
})
}
userMessages.push({
role: message.role,
content: message.content
@@ -464,18 +472,31 @@ export default class AnthropicProvider extends BaseProvider {
}
}
finalUsage.prompt_tokens += message.usage?.input_tokens || 0
finalUsage.completion_tokens += message.usage?.output_tokens || 0
finalUsage.total_tokens += finalUsage.prompt_tokens + finalUsage.completion_tokens
finalMetrics.completion_tokens = finalUsage.completion_tokens
finalMetrics.time_completion_millsec += new Date().getTime() - start_time_millsec
finalMetrics.time_first_token_millsec = time_first_token_millsec - start_time_millsec
// 直接修改finalUsage对象会报错TypeError: Cannot assign to read only property 'prompt_tokens' of object '#<Object>'
// 暂未找到原因
const updatedUsage: Usage = {
...finalUsage,
prompt_tokens: finalUsage.prompt_tokens + (message.usage?.input_tokens || 0),
completion_tokens: finalUsage.completion_tokens + (message.usage?.output_tokens || 0)
}
updatedUsage.total_tokens = updatedUsage.prompt_tokens + updatedUsage.completion_tokens
const updatedMetrics: Metrics = {
...finalMetrics,
completion_tokens: updatedUsage.completion_tokens,
time_completion_millsec:
finalMetrics.time_completion_millsec + (new Date().getTime() - start_time_millsec),
time_first_token_millsec: time_first_token_millsec - start_time_millsec
}
Object.assign(finalUsage, updatedUsage)
Object.assign(finalMetrics, updatedMetrics)
onChunk({
type: ChunkType.BLOCK_COMPLETE,
response: {
usage: finalUsage,
metrics: finalMetrics
usage: updatedUsage,
metrics: updatedMetrics
}
})
resolve()
@@ -488,7 +509,9 @@ export default class AnthropicProvider extends BaseProvider {
}
onChunk({ type: ChunkType.LLM_RESPONSE_CREATED })
const start_time_millsec = new Date().getTime()
await processStream(body, 0).finally(cleanup)
await processStream(body, 0).finally(() => {
cleanup()
})
}
/**

View File

@@ -300,7 +300,11 @@ export default class GeminiProvider extends BaseProvider {
const effortRatio = EFFORT_RATIO[reasoningEffort]
if (effortRatio > 1) {
return {}
return {
thinkingConfig: {
includeThoughts: true
}
}
}
const { max } = findTokenLimit(model.id) || { max: 0 }

View File

@@ -644,8 +644,6 @@ export default class OpenAIProvider extends BaseOpenAIProvider {
yield { type: 'finish', finishReason, usage: chunk.usage, delta, chunk }
break
}
} else {
yield { type: 'unknown', chunk }
}
}
}
@@ -1022,14 +1020,20 @@ export default class OpenAIProvider extends BaseOpenAIProvider {
await this.checkIsCopilot()
// @ts-ignore key is not typed
const response = await this.sdk.chat.completions.create({
const params = {
model: model.id,
messages: [systemMessage, userMessage] as ChatCompletionMessageParam[],
stream: false,
keep_alive: this.keepAliveTime,
max_tokens: 1000
})
}
if (isSupportedThinkingTokenQwenModel(model)) {
params['enable_thinking'] = false
}
// @ts-ignore key is not typed
const response = await this.sdk.chat.completions.create(params as ChatCompletionCreateParamsNonStreaming)
// 针对思考类模型的返回,总结仅截取</think>之后的内容
let content = response.choices[0].message?.content || ''

View File

@@ -49,11 +49,13 @@ import {
} from '@renderer/utils/mcp-tools'
import { findFileBlocks, findImageBlocks, getMainTextContent } from '@renderer/utils/messageUtils/find'
import { buildSystemPrompt } from '@renderer/utils/prompt'
import { Base64 } from 'js-base64'
import { isEmpty, takeRight } from 'lodash'
import mime from 'mime'
import OpenAI from 'openai'
import { ChatCompletionContentPart, ChatCompletionMessageParam } from 'openai/resources/chat/completions'
import { Stream } from 'openai/streaming'
import { FileLike, toFile } from 'openai/uploads'
import { toFile, Uploadable } from 'openai/uploads'
import { CompletionsParams } from '.'
import BaseProvider from './BaseProvider'
@@ -569,6 +571,16 @@ export abstract class BaseOpenAIProvider extends BaseProvider {
if (time_first_token_millsec === 0) {
time_first_token_millsec = new Date().getTime()
}
// Insert separation between summary parts
if (thinkContent.length > 0) {
const separator = '\n\n'
onChunk({
type: ChunkType.THINKING_DELTA,
text: separator,
thinking_millsec: new Date().getTime() - time_first_token_millsec
})
thinkContent += separator
}
break
case 'response.reasoning_summary_text.delta':
onChunk({
@@ -942,28 +954,32 @@ export abstract class BaseOpenAIProvider extends BaseProvider {
if (!model) {
return { valid: false, error: new Error('No model found') }
}
if (stream) {
const response = await this.sdk.responses.create({
model: model.id,
input: [{ role: 'user', content: 'hi' }],
stream: true
})
for await (const chunk of response) {
if (chunk.type === 'response.output_text.delta') {
return { valid: true, error: null }
try {
if (stream) {
const response = await this.sdk.responses.create({
model: model.id,
input: [{ role: 'user', content: 'hi' }],
stream: true
})
for await (const chunk of response) {
if (chunk.type === 'response.output_text.delta') {
return { valid: true, error: null }
}
}
return { valid: false, error: new Error('No streaming response') }
} else {
const response = await this.sdk.responses.create({
model: model.id,
input: [{ role: 'user', content: 'hi' }],
stream: false
})
if (!response.output_text) {
return { valid: false, error: new Error('No response') }
}
return { valid: true, error: null }
}
throw new Error('Empty streaming response')
} else {
const response = await this.sdk.responses.create({
model: model.id,
input: [{ role: 'user', content: 'hi' }],
stream: false
})
if (!response.output_text) {
throw new Error('Empty response')
}
return { valid: true, error: null }
} catch (error: any) {
return { valid: false, error: error }
}
}
@@ -1036,7 +1052,7 @@ export abstract class BaseOpenAIProvider extends BaseProvider {
const { signal } = abortController
const content = getMainTextContent(lastUserMessage!)
let response: OpenAI.Images.ImagesResponse | null = null
let images: FileLike[] = []
let images: Uploadable[] = []
try {
if (lastUserMessage) {
@@ -1059,19 +1075,16 @@ export abstract class BaseOpenAIProvider extends BaseProvider {
const assistantFiles = findImageBlocks(lastAssistantMessage)
const assistantImages = await Promise.all(
assistantFiles.filter(Boolean).map(async (f) => {
const base64Data = f?.url?.replace(/^data:image\/\w+;base64,/, '')
if (!base64Data) return null
const binary = atob(base64Data)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i)
}
return await toFile(bytes, 'assistant_image.png', {
type: 'image/png'
})
const match = f?.url?.match(/^data:(image\/\w+);base64,(.+)$/)
if (!match) return null
const mimeType = match[1]
const extension = mime.getExtension(mimeType) || 'bin'
const bytes = Base64.toUint8Array(match[2])
const fileName = `assistant_image.${extension}`
return await toFile(bytes, fileName, { type: mimeType })
})
)
images = images.concat(assistantImages.filter(Boolean) as FileLike[])
images = images.concat(assistantImages.filter(Boolean) as Uploadable[])
}
onChunk({

View File

@@ -471,7 +471,7 @@ export function checkApiProvider(provider: Provider): {
}
}
export async function checkApi(provider: Provider, model: Model) {
export async function checkApi(provider: Provider, model: Model): Promise<{ valid: boolean; error: Error | null }> {
const validation = checkApiProvider(provider)
if (!validation.valid) {
return {
@@ -484,9 +484,16 @@ export async function checkApi(provider: Provider, model: Model) {
// Try streaming check first
const result = await ai.check(model, true)
if (result.valid && !result.error) {
return result
}
return ai.check(model, false)
// 不应该假设错误由流式引发。多次发起检测请求可能触发429掩盖了真正的问题。
// 但这里错误类型做的很粗糙,暂时先这样
if (result.error && result.error.message.includes('stream')) {
return ai.check(model, false)
} else {
return result
}
}

View File

@@ -229,6 +229,8 @@ export async function getMessageTitle(message: Message, length = 30): Promise<st
if (title) {
window.message.success({ content: t('chat.topics.export.title_naming_success'), key: 'message-title-naming' })
return title
} else {
window.message?.error(t('message.error.fetchTopicName'))
}
} catch (e) {
window.message.error({ content: t('chat.topics.export.title_naming_failed'), key: 'message-title-naming' })

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