Compare commits

..

47 Commits

Author SHA1 Message Date
kangfenmao
73e760132c fix: Gemini reasoning model check and improve citation popover structure
- Added a new condition to the Gemini reasoning model check to include models with IDs starting with 'gemini' and containing 'thinking'.
- Refactored the CitationsList component to improve the structure of popover content for web search and knowledge citations.
- Updated styled components for better layout and responsiveness in the citation popover.
- Adjusted margin styles in ErrorBlock for consistent spacing.
- Fix Anthropic request cannot handle webSearch and knowbase references
2025-06-26 12:26:13 +08:00
one
5e0cae06db fix(CodeEditor): save to db (#7504) 2025-06-26 11:19:11 +08:00
fullex
1f09c8a022 refactor(SelectionAssistant): make all Toolbar CSS variables customizable (#7532)
refactor: update selection toolbar styles and structure

- Enhanced the selection toolbar's HTML structure for better readability.
- Updated CSS variables for improved theming and consistency across the toolbar.
- Refactored the styled components in SelectionToolbar.tsx to utilize new CSS variables for layout and styling.
- Added support for hover states and improved button styling for better user experience.
2025-06-26 10:17:09 +08:00
suyao
751879d42e feat(AihubmixAPIClient): add getBaseURL method to handle client base URL retrieval 2025-06-26 01:30:55 +08:00
fullex
5f2d0d4bfc fix(SelectionAssistant): opacity slider too slow when sliding in settings page (#7537)
feat: enhance opacity control in Selection Assistant Settings

- Added state management for opacity value in SelectionAssistantSettings component.
- Updated Slider component to use the new opacity state instead of the previous actionWindowOpacity variable.
- Ensured onChangeComplete updates the actionWindowOpacity accordingly.
2025-06-26 01:16:17 +08:00
fullex
3d535d0e68 feat: enhance WindowFooter with show/hide functionality for UI elements
- Added state management to control visibility of UI elements in the WindowFooter.
- Implemented a timer to automatically hide elements after a period of inactivity.
- Updated hotkey handlers to reset the visibility timer on user interaction.
- Modified styled component to reflect the new visibility logic.
2025-06-25 22:56:48 +08:00
SuYao
9362304db0 refactor(GeminiAPIClient): separate model and user message handling to adapt vertex (#7511)
- Introduced a new modelParts array to manage model-related messages separately from user messages.
- Updated the logic to push model messages to currentReqMessages only if they exist, improving clarity and structure.
- Adjusted the return order of messages in buildSdkMessages to ensure history is appended correctly.
- Enhanced McpToolChunkMiddleware to reset tool processing state output when output is present.
2025-06-25 22:16:27 +08:00
陈天寒
17a8f0a724 feat: Support custom registry address when configuring mcp for npm & fix lint error (#7531)
* feat: Support custom registry address when configuring mcp for npm

* fix: lint
2025-06-25 21:37:10 +08:00
Wang Jiyuan
066aad7fed fix: Resolve vllm bad request caused by always sending dimensions in embedding requests (#7525)
fix(知识库): 将dimensions字段改为可选并修复相关逻辑
2025-06-25 21:15:05 +08:00
one
5138f5b314 fix: clear search cache on resending (#7510) 2025-06-25 21:10:15 +08:00
自由的世界人
839c44eb7a refactor: data migration modal logic in DataSettings (#7503)
* refactor: data migration modal logic in DataSettings

Moved showProgressModal and startMigration functions inside the useEffect hook and added t as a dependency. This improves encapsulation and ensures translation updates are handled correctly.

* remove trailing whitespace in DataSettings.tsx

Cleaned up a line by removing unnecessary trailing whitespace in the DataSettings component.
2025-06-25 21:07:40 +08:00
fullex
0001bc60a9 refactor: rename isWindows to isWin for consistency across main/renderer (#7530)
refactor: rename isWindows to isWin for consistency across components
2025-06-25 19:59:47 +08:00
LiuVaayne
04e6f2c1ad feat: implement Python MCP server using existing Pyodide infrastructure (#7506) 2025-06-25 18:21:10 +08:00
Chen Tao
a94847faeb fix: bailian reranker (#7518) 2025-06-25 15:48:04 +08:00
Teo
64b01cce47 feat: 一些UI上的优化和重构 (#7479)
- 调整AntdProvider中主题配置,包括颜色、尺寸
- 重构聊天气泡模式的样式
- 重构多选模式的样式
- 添加Selector组件取代ant Select组件
- 重构消息搜索弹窗界面
- 重构知识库搜索弹窗界面
- 优化其他弹框UI
2025-06-25 14:34:18 +08:00
kangfenmao
3df5aeb3c3 Revert "feat: Update API Key Management Interface (#3444)"
This reverts commit 31b3ce1049.
2025-06-25 13:10:46 +08:00
SuYao
9fe5fb9a91 fix: non streamoutput sometimes (#7512)
* feat(migrate): add default settings for assistants during migration

- Introduced a new migration step to assign default settings for assistants that lack configuration.
- Default settings include temperature, context count, and other parameters to ensure consistent behavior across the application.

* chore(store): increment version number to 115 for persisted reducer
2025-06-25 12:49:00 +08:00
chenxue
17951ad157 feat: add appcode (#7507)
Co-authored-by: zhaochenxue <zhaochenxue@bixin.cn>
2025-06-25 09:17:27 +08:00
beyondkmp
3640d846b9 feat: Implement occupied directories handling during data copy (#7485)
* feat: Implement occupied directories handling during data copy

- Added `occupiedDirs` constant to manage directories that should not be copied.
- Enhanced the `copyOccupiedDirsInMainProcess` function to copy occupied directories to a new app data path in the main process.
- Updated IPC and preload APIs to support passing occupied directories during the copy operation.
- Modified the DataSettings component to utilize the new copy functionality with occupied directories.

* fix: Improve occupied directories handling during data copy

- Updated the filter logic in the `registerIpc` function to resolve directory paths correctly.
- Modified the `DataSettings` component to pass the correct occupied directories format during the copy operation.
2025-06-25 00:39:28 +08:00
one
becb6543e0 fix(model): doubao thinking param (#7499) 2025-06-24 23:42:55 +08:00
自由的世界人
1055903456 fix: add tag collapse state management for assistants (#7436)
Add tag collapse state management for assistants

Introduces a collapsedTags state to manage the collapsed/expanded state of tag groups in the assistants list. Updates useTags and AssistantsTab to use this state, and adds actions to toggle and initialize tag collapse in the Redux store.
2025-06-24 21:12:49 +08:00
自由的世界人
e2b8133729 refactor: file actions into FileAction service (#7413)
* refactor: file actions into FileAction service

Moved file sorting, deletion, and renaming logic from FilesPage to a new FileAction service for better modularity and reuse. Updated FileList and FilesPage to use the new service functions, and improved the delete button UI in FileList.
2025-06-24 18:51:58 +08:00
one
f2c9bf433e refactor(CodePreview): auto resize gutters (#7481)
* refactor(CodePreview): auto resize gutters

* refactor: remove unnecessary usememo
2025-06-24 04:01:05 +08:00
Xunjin ZHENG
31b3ce1049 feat: Update API Key Management Interface (#3444)
* feat: enhance API key management in ApiCheckPopup: allow users to add new API key

- Enhanced ApiCheckPopup component to allow users to add new API key, including validation for duplicate entries and improved user feedback.

* feat: update localization strings and refactor API key management components

- Added "Invalid API key" message to localization files for English, Japanese, Russian, Simplified Chinese, and Traditional Chinese.
- Refactored API key management by replacing the ApiCheckPopup with a new ApiKeyList component, enhancing user experience and modularity in handling API keys across provider settings.

* refactor: update OAuthButton and ApiKeyList components for improved UI and localization

- Commented out the translation key in OAuthButton for future use.
- Removed unnecessary localization strings related to API key tips across multiple languages.
- Enhanced ApiKeyList component with styled components for better layout and user interaction.
- Updated ProviderSetting and WebSearchProviderSetting to streamline API key management UI.

* refactor: streamline ApiKeyList component and update localization strings

- Removed the "Check Multiple API Keys" translation key from English, Japanese, Russian, Simplified Chinese, and Traditional Chinese localization files.
- Updated ApiKeyList component to eliminate the model prop, enhancing its simplicity and usability.
- Improved error handling in API key validation by integrating model selection directly within the check process.

* feat: add latency tooltip to API key validation in ApiKeyList component and update localization strings

- Introduced a latency tooltip in the ApiKeyList component to display the time taken for API key validation.
- Updated localization files for English, Japanese, Russian, Simplified Chinese, and Traditional Chinese to include the new latency tooltip string.

* refactor: remove unused imports in WebSearchProviderSetting component

* refactor: improve error handling and latency tracking in ApiKeyList component

- Enhanced error handling during model selection to prevent failures when the user cancels the popup.
- Introduced latency tracking for API key validation, ensuring accurate measurement of response times.
- Streamlined the code for better readability and maintainability.

* refactor: improve styling in ApiKeyList component for better UI consistency

- Updated padding styles for error messages and list items in the ApiKeyList component to enhance visual clarity and user experience.
- Adjusted Card component properties to ensure consistent styling across the interface.

* refactor: extract key formatting logic into a separate function in ApiKeyList component

- Created a new function `formatAndConvertKeysToArray` to handle the formatting and conversion of API keys into an array of unique key objects.
- Updated the state initialization and effect hook in the ApiKeyList component to utilize the new function, improving code readability and maintainability.

* refactor: conditionally render API key section for non-copilot providers

- Updated the ProviderSetting component to conditionally display the API key section only for providers other than 'copilot', improving the user interface and experience.
- Maintained existing functionality for API key management while enhancing code readability.

* refactor: enhance ApiKeyList component for copilot provider handling

- Introduced a new condition to manage the rendering and functionality of buttons in the ApiKeyList component based on the provider type, specifically for 'copilot'.
- Updated the ProviderSetting component to ensure the API key section is consistently displayed for all providers, improving overall user experience and code clarity.

* fix model type error

* feat(ApiKeyList): exclude rerank models from being checked for API key validation after #3969 is merged

* refactor(ApiKeyList): conditionally render check and remove buttons based on key statuses

* refactor(ApiKeyList): using Promise.all for improved performance after #4066 is merged

* refactor(ProviderSettings): update API key display and tooltip integration for improved layout and accessibility

* fix(ApiKeyList): prevent notifications from showing when checking multiple API keys

* feat(ApiKeyList): enhance API key handling with improved key formatting and auto-focus logic for add button

* refactor: clean up WebSearchProviderSetting component

* refactor(ApiKeyList): replace icon buttons with styled components for save and cancel actions

* refactor: API key list UI and remove unused components

Simplified the API key list UI by removing custom styled components for status and actions, replacing them with Ant Design icons and buttons. Improved the key checking logic and removed the tooltip for key check results. Also removed an unused help text in ProviderSetting.

* refactor: add edit functionality to API key list

Introduces the ability to edit existing API keys in the ApiKeyList component. Removes custom save/cancel icon buttons in favor of standard input blur/enter and icon actions. Also adjusts styling for help text in ProviderSetting.

* refactor(ApiKeyList): enhance key status display with tooltips and color coding

* feat(i18n): add "checking" status message in multiple languages

* feat(ApiKeyList): enhance API key management with confirmation for deletion and improved state handling

- Added confirmation for deleting API keys, allowing users to confirm before removal.
- Introduced a cancel state for adding new keys to improve user experience.
- Enhanced key status updates to prevent unnecessary re-renders.
- Improved UI interactions with better handling of edit and cancel actions.
- Added escape key functionality for canceling edits and new key entries.

* fix(ApiKeyList): adjust layout of API key list for improved spacing and alignment

- Updated the Flex component to justify content between elements, enhancing the visual layout of the API key list.
- Minor style adjustment to maintain consistency in the user interface.

* fix(ApiKeyList): refine padding for API key list items to enhance visual consistency

- Adjusted padding for API key list text and items to improve overall layout and alignment.
- Ensured consistent spacing across different states of the API key list.

---------

Co-authored-by: Pleasurecruise <3196812536@qq.com>
Co-authored-by: suyao <sy20010504@gmail.com>
2025-06-24 01:54:12 +08:00
Ying-xi
f69ea8648c fix: display updated timestamp when available in knowledge base (#7453)
* fix: display updated timestamp when available in knowledge base

- Add updated_at field when creating knowledge items
- Show updated_at timestamp if it's newer than created_at
- Fallback to created_at if updated_at is not available or older

Fixes #4587

Signed-off-by: Ying-xi <62348590+Ying-xi@users.noreply.github.com>

* refactor(knowledge): extract display time logic into a reusable function

Signed-off-by: Ying-xi <62348590+Ying-xi@users.noreply.github.com>

---------

Signed-off-by: Ying-xi <62348590+Ying-xi@users.noreply.github.com>
2025-06-24 00:06:52 +08:00
beyondkmp
bbe380cc9e feat(ContextMenu): add spell check and dictionary suggestions to context menu (#7067)
* feat(ContextMenu): add spell check and dictionary suggestions to context menu

- Implemented spell check functionality in the context menu with options to learn spelling and view dictionary suggestions.
- Updated WindowService to enable spellcheck in the webview.
- Enabled spell check in Inputbar and MessageEditor components.

* feat(SpellCheck): implement spell check language settings and initialization

- Added support for configuring spell check languages based on user-selected language.
- Introduced IPC channel for setting spell check languages.
- Updated settings to manage spell check enablement and languages.
- Enhanced UI to allow users to toggle spell check functionality and select languages.
- Default spell check languages are set based on the current UI language if none are specified.

* refactor(SpellCheck): enhance spell check language mapping and UI settings

- Updated spell check language mapping to default to English for unsupported languages.
- Improved UI logic to only update spell check languages when enabled and no manual selections are made.
- Added a new selection component for users to choose from commonly supported spell check languages.

* feat(SpellCheck): integrate spell check functionality into Inputbar and MessageEditor

- Added enableSpellCheck setting to control spell check functionality in both Inputbar and MessageEditor components.
- Updated spellCheck prop to utilize the new setting, enhancing user experience by allowing customization of spell check behavior.

* refactor(SpellCheck): move spell check initialization to WindowService

- Removed spell check language initialization from index.ts and integrated it into WindowService.
- Added setupSpellCheck method to configure spell check languages based on user settings.
- Enhanced error handling for spell check language setup.

* feat(SpellCheck): add enable spell check functionality and IPC channel

- Introduced a new IPC channel for enabling/disabling spell check functionality.
- Updated the preload API to include a method for setting spell check enablement.
- Modified the main IPC handler to manage spell check settings based on user input.
- Simplified spell check language handling in the settings component by directly invoking the new API method.

* refactor(SpellCheck): remove spellcheck option from WindowService configuration

- Removed the spellcheck property from the WindowService configuration object.
- This change streamlines the configuration setup as spell check functionality is now managed through IPC channels.

* feat(i18n): add spell check translations for Japanese, Russian, and Traditional Chinese

- Added new translations for spell check functionality in ja-jp, ru-ru, and zh-tw locale files.
- Included descriptions and language selection options for spell check settings to enhance user experience.

* feat(migrate): add spell check configuration migration

- Implemented migration for spell check settings, disabling spell check and clearing selected languages in the new configuration.
- Enhanced error handling to ensure state consistency during migration process.

* fix(migrate): ensure spell check settings are updated safely

- Added a check to ensure state.settings exists before modifying spell check settings during migration.
- Removed redundant error handling that returned the state unmodified in case of an error.

* fix(WindowService): set default values for spell check configuration and update related UI texts

* refactor(Inputbar, MessageEditor): remove contextMenu attribute and add context menu handling in MessageEditor

---------

Co-authored-by: beyondkmp <beyondkmkp@gmail.com>
2025-06-23 21:19:21 +08:00
beyondkmp
be15206234 fix: Data config improvement (#7471)
* fix: update localization files for data migration warnings and path validation messages

* fix: update app data path validation and localization messages for installation path consistency

* fix: enhance app data flushing process by adding connection closure and delay in DataSettings component
2025-06-23 17:18:46 +08:00
Wang Jiyuan
aee8fe6196 feat(mcpServers): Add a thought field to sequential thinking mcp server (#7465)
feat(mcpServers): 在sequentialthinking中添加thought字段
2025-06-23 15:27:20 +08:00
one
4f2c8bd905 fix(Markdown): improve latex brackets handling (#7358) 2025-06-23 15:19:21 +08:00
Murphy
a2e2eb3b73 fix: re-add newline separator between reasoning_summary parts after openai middleware refactor (#7390)
re-add newline separator between reasoning_summary parts after openai client refactor

Signed-off-by: MurphyLo <murphylo@mail.bnu.edu.cn>
2025-06-23 12:51:08 +08:00
Wang Jiyuan
32d6c2e1d8 feat(TopicsTab): Allow deletion of inactive topics (#7415)
* fix(主题列表): 修复主题列表项悬停样式和菜单显示条件

调整主题列表项悬停时的背景色过渡效果,并修正菜单显示逻辑,仅在非挂起状态显示

* fix(TopicsTab): 移除话题待处理状态检查

* fix(TopicsTab): 修复删除话题时未检查当前活跃话题的问题
2025-06-23 09:59:50 +08:00
purefkh
b4c8e42d87 fix(rename): disable thinking for topic rename (#7461) 2025-06-23 09:48:29 +08:00
Tristan Zhang
a8e23966fa feat(FileStorage): add support for .doc files using word-extractor (#7374)
* feat(FileStorage): add support for .doc files and integrate word-extractor

* chore(package): add word-extractor to devdependencies
2025-06-23 08:55:03 +08:00
Wang Jiyuan
2350919f36 fix: use shouldThrow param in checkApi instead of adding error property to CompletionsResult (#7457)
* Revert "refactor(middleware): Add error property to CompletionResult and handle errors when checking API (#7407)"

This reverts commit 50d6f1f831.

* fix: use shouldThrow param in checkApi
2025-06-22 21:33:17 +08:00
kangfenmao
355d2aebb4 chore(version): 1.4.5 2025-06-22 17:31:43 +08:00
Wang Jiyuan
50d6f1f831 refactor(middleware): Add error property to CompletionResult and handle errors when checking API (#7407)
* refactor(aiCore): 添加错误处理

* remove console.log
2025-06-22 17:03:43 +08:00
自由的世界人
d9b8e68c30 fix: update source language handling and persist user selection in TranslatePage component (#7243) 2025-06-22 12:28:31 +08:00
beyondkmp
c660aaba3d fix: 修复数据目录迁移的bug (#7386)
* fix: move initAppDataDir function inline and remove export from utils/file.ts

* fix some bugs

* fix shouldcopy error

* fix: handle appDataPath initialization and update logic in file.ts; update defaultChecked in DataSettings component

* fix: improve appDataPath handling and migration logic in file.ts

* fix: add error message for selecting the same app data path in DataSettings component and update localization files

* fix: ensure migration confirmation modal is shown correctly in DataSettings component

* feat: add new IPC channel for retrieving data path from arguments and update related components for migration handling

* fix: update app data path validation to check for prefix match in DataSettings component

* refactor: simplify data migration logic in DataSettings component by removing unnecessary flag

* fix: update initAppDataDir invocation to check for app packaging status in bootstrap.ts
2025-06-22 10:32:23 +08:00
Wang Jiyuan
60b37876b1 fix: remove duplicated deepseek-v3 in volcengine (#7406)
fix: 移除重复的DeepSeek-V3模型配置
2025-06-21 21:20:40 +08:00
beyondkmp
37aaaee086 fix: add node-stream-zip for zip file extraction in install-bun script (#7403)
* chore(package): add node-stream-zip for zip file extraction in install-bun script

* refactor(install-uv): replace AdmZip with node-stream-zip for improved zip file extraction

* fix(install-uv): ensure correct extraction of uv binary for Unix/Linux/macOS

* refactor(install-uv): remove redundant file handling and cleanup for Unix/Linux/macOS installation

* fix(install-uv): update tar extraction command to strip leading components for Unix/Linux/macOS

* fix(install-uv): clarify comment for zip file extraction on Windows

* fix(install-bun): correct extraction directory for bun binary

* fix(install-bun, install-uv): update default versions and improve zip extraction process

* fix(install-bun): remove redundant cleanup of source directory after bun installation
2025-06-21 19:47:15 +08:00
Wang Jiyuan
b91ac0de1d fix(models): Unexpected inability to disable image generation feature (#7401)
* fix(models): 修复禁用图片生成模型检查逻辑

* fix(models): use getBaseName()
2025-06-20 22:30:14 +08:00
Wang Jiyuan
8d247add98 fix(ApiService): correct enableWebSearch conditional logic error (#7396)
* fix(ApiService): 修复enableWebSearch条件判断逻辑错误

* fix(web搜索): 修正web搜索模型判断逻辑
2025-06-20 18:06:44 +08:00
Wang Jiyuan
a813df993c fix: Chat does not work properly when configuring multiple API keys (#7385)
* refactor(openai): 使用getApiKey方法替代直接访问apiKey属性

* refactor(openai): 使用getApiKey方法替代直接访问provider.apiKey

* refactor(api客户端): 直接使用apiKey属性替代getApiKey方法
2025-06-20 17:46:45 +08:00
SuYao
1915ba5bfb fix(GeminiAPIClient): update abortSignal option and ensure userLastMessage is pushed to messages (#7387) 2025-06-20 14:46:22 +08:00
George·Dong
3e142f67ad fix(i18n): fix model name export help text (#7372) 2025-06-19 23:32:32 +08:00
Tristan Zhang
b4b456ae06 fix(AssistantService): add default settings configuration to assistant initialization (#7371) 2025-06-19 22:56:46 +08:00
one
ed0bb7fd16 feat(Markdown): disable indented code blocks (#7288)
* feat(Markdown): disable indented code blocks

* chore: update remark/rehype packages
2025-06-19 19:39:33 +08:00
153 changed files with 4023 additions and 2102 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "CherryStudio",
"version": "1.4.4",
"version": "1.4.5",
"private": true,
"description": "A powerful AI assistant for producer.",
"main": "./out/main/index.js",
@@ -62,6 +62,7 @@
"@libsql/win32-x64-msvc": "^0.4.7",
"@strongtz/win32-arm64-msvc": "^0.4.7",
"jsdom": "26.1.0",
"node-stream-zip": "^1.15.0",
"notion-helper": "^1.3.22",
"os-proxy-config": "^1.1.2",
"selection-hook": "^0.9.23",
@@ -111,6 +112,7 @@
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@tryfabric/martian": "^1.2.4",
"@types/balanced-match": "^3",
"@types/diff": "^7",
"@types/fs-extra": "^11",
"@types/lodash": "^4.17.5",
@@ -123,6 +125,7 @@
"@types/react-infinite-scroll-component": "^5.0.0",
"@types/react-window": "^1",
"@types/tinycolor2": "^1",
"@types/word-extractor": "^1",
"@uiw/codemirror-extensions-langs": "^4.23.12",
"@uiw/codemirror-themes-all": "^4.23.12",
"@uiw/react-codemirror": "^4.23.12",
@@ -136,6 +139,7 @@
"archiver": "^7.0.1",
"async-mutex": "^0.5.0",
"axios": "^1.7.3",
"balanced-match": "^3.0.1",
"browser-image-compression": "^2.0.2",
"color": "^5.0.0",
"dayjs": "^1.11.11",
@@ -176,7 +180,6 @@
"mermaid": "^11.6.0",
"mime": "^4.0.4",
"motion": "^12.10.5",
"node-stream-zip": "^1.15.0",
"npx-scope-finder": "^1.2.0",
"officeparser": "^4.1.1",
"openai": "patch:openai@npm%3A5.1.0#~/.yarn/patches/openai-npm-5.1.0-0e7b3ccb07.patch",
@@ -190,7 +193,7 @@
"react-hotkeys-hook": "^4.6.1",
"react-i18next": "^14.1.2",
"react-infinite-scroll-component": "^6.1.0",
"react-markdown": "^9.0.1",
"react-markdown": "^10.1.0",
"react-redux": "^9.1.2",
"react-router": "6",
"react-router-dom": "6",
@@ -199,10 +202,10 @@
"redux": "^5.0.1",
"redux-persist": "^6.0.0",
"rehype-katex": "^7.0.1",
"rehype-mathjax": "^7.0.0",
"rehype-mathjax": "^7.1.0",
"rehype-raw": "^7.0.0",
"remark-cjk-friendly": "^1.1.0",
"remark-gfm": "^4.0.0",
"remark-cjk-friendly": "^1.2.0",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"remove-markdown": "^0.6.2",
"rollup-plugin-visualizer": "^5.12.0",
@@ -218,6 +221,7 @@
"vite": "6.2.6",
"vitest": "^3.1.4",
"webdav": "^5.8.0",
"word-extractor": "^1.0.4",
"zipread": "^1.3.3"
},
"resolutions": {

View File

@@ -3,6 +3,8 @@ export enum IpcChannel {
App_ClearCache = 'app:clear-cache',
App_SetLaunchOnBoot = 'app:set-launch-on-boot',
App_SetLanguage = 'app:set-language',
App_SetEnableSpellCheck = 'app:set-enable-spell-check',
App_SetSpellCheckLanguages = 'app:set-spell-check-languages',
App_ShowUpdateDialog = 'app:show-update-dialog',
App_CheckForUpdate = 'app:check-for-update',
App_Reload = 'app:reload',
@@ -20,6 +22,9 @@ export enum IpcChannel {
App_Copy = 'app:copy',
App_SetStopQuitApp = 'app:set-stop-quit-app',
App_SetAppDataPath = 'app:set-app-data-path',
App_GetDataPathFromArgs = 'app:get-data-path-from-args',
App_FlushAppData = 'app:flush-app-data',
App_IsNotEmptyDir = 'app:is-not-empty-dir',
App_RelaunchApp = 'app:relaunch-app',
App_IsBinaryExist = 'app:is-binary-exist',
App_GetBinaryPath = 'app:get-binary-path',
@@ -64,6 +69,9 @@ export enum IpcChannel {
Mcp_ServersUpdated = 'mcp:servers-updated',
Mcp_CheckConnectivity = 'mcp:check-connectivity',
// Python
Python_Execute = 'python:execute',
//copilot
Copilot_GetAuthMessage = 'copilot:get-auth-message',
Copilot_GetCopilotToken = 'copilot:get-copilot-token',

View File

@@ -1,7 +1,7 @@
export const imageExts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp']
export const videoExts = ['.mp4', '.avi', '.mov', '.wmv', '.flv', '.mkv']
export const audioExts = ['.mp3', '.wav', '.ogg', '.flac', '.aac']
export const documentExts = ['.pdf', '.docx', '.pptx', '.xlsx', '.odt', '.odp', '.ods']
export const documentExts = ['.pdf', '.doc', '.docx', '.pptx', '.xlsx', '.odt', '.odp', '.ods']
export const thirdPartyApplicationExts = ['.draftsExport']
export const bookExts = ['.epub']
const textExtsByCategory = new Map([
@@ -409,3 +409,5 @@ export enum FeedUrl {
EARLY_ACCESS = 'https://github.com/CherryHQ/cherry-studio/releases/latest/download'
}
export const defaultTimeout = 5 * 1000 * 60
export const occupiedDirs = ['logs', 'Network', 'Partitions/webview/Network']

View File

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

View File

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

33
src/main/bootstrap.ts Normal file
View File

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

View File

@@ -1,7 +1,11 @@
// don't reorder this file, it's used to initialize the app data dir and
// other which should be run before the main process is ready
// eslint-disable-next-line
import './bootstrap'
import '@main/config'
import { electronApp, optimizer } from '@electron-toolkit/utils'
import { initAppDataDir } from '@main/utils/file'
import { replaceDevtoolsFont } from '@main/utils/windowUtil'
import { app } from 'electron'
import installExtension, { REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS } from 'electron-devtools-installer'
@@ -22,7 +26,6 @@ import { registerShortcuts } from './services/ShortcutService'
import { TrayService } from './services/TrayService'
import { windowService } from './services/WindowService'
initAppDataDir()
Logger.initialize()
/**

View File

@@ -1,5 +1,6 @@
import fs from 'node:fs'
import { arch } from 'node:os'
import path from 'node:path'
import { isMac, isWin } from '@main/constant'
import { getBinaryPath, isBinaryExists, runInstallScript } from '@main/utils/process'
@@ -24,6 +25,7 @@ import NotificationService from './services/NotificationService'
import * as NutstoreService from './services/NutstoreService'
import ObsidianVaultService from './services/ObsidianVaultService'
import { ProxyConfig, proxyManager } from './services/ProxyManager'
import { pythonService } from './services/PythonService'
import { searchService } from './services/SearchService'
import { SelectionService } from './services/SelectionService'
import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService'
@@ -34,7 +36,7 @@ import { setOpenLinkExternal } from './services/WebviewService'
import { windowService } from './services/WindowService'
import { calculateDirectorySize, getResourcePath } from './utils'
import { decrypt, encrypt } from './utils/aes'
import { getCacheDir, getConfigDir, getFilesDir, hasWritePermission, updateConfig } from './utils/file'
import { getCacheDir, getConfigDir, getFilesDir, hasWritePermission, updateAppDataConfig } from './utils/file'
import { compress, decompress } from './utils/zip'
const fileManager = new FileStorage()
@@ -47,6 +49,9 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
const appUpdater = new AppUpdater(mainWindow)
const notificationService = new NotificationService(mainWindow)
// Initialize Python service with main window
pythonService.setMainWindow(mainWindow)
ipcMain.handle(IpcChannel.App_Info, () => ({
version: app.getVersion(),
isPackaged: app.isPackaged,
@@ -57,7 +62,8 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
resourcesPath: getResourcePath(),
logsPath: log.transports.file.getFile().path,
arch: arch(),
isPortable: isWin && 'PORTABLE_EXECUTABLE_DIR' in process.env
isPortable: isWin && 'PORTABLE_EXECUTABLE_DIR' in process.env,
installPath: path.dirname(app.getPath('exe'))
}))
ipcMain.handle(IpcChannel.App_Proxy, async (_, proxy: string) => {
@@ -85,6 +91,26 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
configManager.setLanguage(language)
})
// spell check
ipcMain.handle(IpcChannel.App_SetEnableSpellCheck, (_, isEnable: boolean) => {
const windows = BrowserWindow.getAllWindows()
windows.forEach((window) => {
window.webContents.session.setSpellCheckerEnabled(isEnable)
})
})
// spell check languages
ipcMain.handle(IpcChannel.App_SetSpellCheckLanguages, (_, languages: string[]) => {
if (languages.length === 0) {
return
}
const windows = BrowserWindow.getAllWindows()
windows.forEach((window) => {
window.webContents.session.setSpellCheckerLanguages(languages)
})
configManager.set('spellCheckLanguages', languages)
})
// launch on boot
ipcMain.handle(IpcChannel.App_SetLaunchOnBoot, (_, openAtLogin: boolean) => {
// Set login item settings for windows and mac
@@ -218,14 +244,46 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
// Set app data path
ipcMain.handle(IpcChannel.App_SetAppDataPath, async (_, filePath: string) => {
updateConfig(filePath)
updateAppDataConfig(filePath)
app.setPath('userData', filePath)
})
ipcMain.handle(IpcChannel.App_GetDataPathFromArgs, () => {
return process.argv
.slice(1)
.find((arg) => arg.startsWith('--new-data-path='))
?.split('--new-data-path=')[1]
})
ipcMain.handle(IpcChannel.App_FlushAppData, () => {
BrowserWindow.getAllWindows().forEach((w) => {
w.webContents.session.flushStorageData()
w.webContents.session.cookies.flushStore()
w.webContents.session.closeAllConnections()
})
session.defaultSession.flushStorageData()
session.defaultSession.cookies.flushStore()
session.defaultSession.closeAllConnections()
})
ipcMain.handle(IpcChannel.App_IsNotEmptyDir, async (_, path: string) => {
return fs.readdirSync(path).length > 0
})
// Copy user data to new location
ipcMain.handle(IpcChannel.App_Copy, async (_, oldPath: string, newPath: string) => {
ipcMain.handle(IpcChannel.App_Copy, async (_, oldPath: string, newPath: string, occupiedDirs: string[] = []) => {
try {
await fs.promises.cp(oldPath, newPath, { recursive: true })
await fs.promises.cp(oldPath, newPath, {
recursive: true,
filter: (src) => {
if (occupiedDirs.some((dir) => src.startsWith(path.resolve(dir)))) {
return false
}
return true
}
})
return { success: true }
} catch (error: any) {
log.error('Failed to copy user data:', error)
@@ -234,8 +292,8 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
})
// Relaunch app
ipcMain.handle(IpcChannel.App_RelaunchApp, () => {
app.relaunch()
ipcMain.handle(IpcChannel.App_RelaunchApp, (_, options?: Electron.RelaunchOptions) => {
app.relaunch(options)
app.exit(0)
})
@@ -377,6 +435,14 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.Mcp_GetInstallInfo, mcpService.getInstallInfo)
ipcMain.handle(IpcChannel.Mcp_CheckConnectivity, mcpService.checkMcpConnectivity)
// Register Python execution handler
ipcMain.handle(
IpcChannel.Python_Execute,
async (_, script: string, context?: Record<string, any>, timeout?: number) => {
return await pythonService.executeScript(script, context, timeout)
}
)
ipcMain.handle(IpcChannel.App_IsBinaryExist, (_, name: string) => isBinaryExists(name))
ipcMain.handle(IpcChannel.App_GetBinaryPath, (_, name: string) => getBinaryPath(name))
ipcMain.handle(IpcChannel.App_InstallUvBinary, () => runInstallScript('install-uv.js'))

View File

@@ -16,6 +16,7 @@ const FILE_LOADER_MAP: Record<string, string> = {
// 内置类型
'.pdf': 'common',
'.csv': 'common',
'.doc': 'common',
'.docx': 'common',
'.pptx': 'common',
'.xlsx': 'common',

View File

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

View File

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

View File

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

View File

@@ -17,7 +17,7 @@ export default abstract class BaseReranker {
* Get Rerank Request Url
*/
protected getRerankUrl() {
if (this.base.rerankModelProvider === 'dashscope') {
if (this.base.rerankModelProvider === 'bailian') {
return 'https://dashscope.aliyuncs.com/api/v1/services/rerank/text-rerank/text-rerank'
}
@@ -50,7 +50,7 @@ export default abstract class BaseReranker {
documents,
top_k: topN
}
} else if (provider === 'dashscope') {
} else if (provider === 'bailian') {
return {
model: this.base.rerankModel,
input: {
@@ -82,7 +82,7 @@ export default abstract class BaseReranker {
*/
protected extractRerankResult(data: any) {
const provider = this.base.rerankModelProvider
if (provider === 'dashscope') {
if (provider === 'bailian') {
return data.output.results
} else if (provider === 'voyageai') {
return data.data

View File

@@ -9,7 +9,18 @@ class ContextMenu {
const template: MenuItemConstructorOptions[] = this.createEditMenuItems(properties)
const filtered = template.filter((item) => item.visible !== false)
if (filtered.length > 0) {
const menu = Menu.buildFromTemplate([...filtered, ...this.createInspectMenuItems(w)])
let template = [...filtered, ...this.createInspectMenuItems(w)]
const dictionarySuggestions = this.createDictionarySuggestions(properties, w)
if (dictionarySuggestions.length > 0) {
template = [
...dictionarySuggestions,
{ type: 'separator' },
this.createSpellCheckMenuItem(properties, w),
{ type: 'separator' },
...template
]
}
const menu = Menu.buildFromTemplate(template)
menu.popup()
}
})
@@ -72,6 +83,53 @@ class ContextMenu {
return template
}
private createSpellCheckMenuItem(
properties: Electron.ContextMenuParams,
mainWindow: Electron.BrowserWindow
): MenuItemConstructorOptions {
const hasText = properties.selectionText.length > 0
return {
id: 'learnSpelling',
label: '&Learn Spelling',
visible: Boolean(properties.isEditable && hasText && properties.misspelledWord),
click: () => {
mainWindow.webContents.session.addWordToSpellCheckerDictionary(properties.misspelledWord)
}
}
}
private createDictionarySuggestions(
properties: Electron.ContextMenuParams,
mainWindow: Electron.BrowserWindow
): MenuItemConstructorOptions[] {
const hasText = properties.selectionText.length > 0
if (!hasText || !properties.misspelledWord) {
return []
}
if (properties.dictionarySuggestions.length === 0) {
return [
{
id: 'dictionarySuggestions',
label: 'No Guesses Found',
visible: true,
enabled: false
}
]
}
return properties.dictionarySuggestions.map((suggestion) => ({
id: 'dictionarySuggestions',
label: suggestion,
visible: Boolean(properties.isEditable && hasText && properties.misspelledWord),
click: (menuItem: Electron.MenuItem) => {
mainWindow.webContents.replaceMisspelling(menuItem.label)
}
}))
}
}
export const contextMenu = new ContextMenu()

View File

@@ -220,10 +220,21 @@ class FileStorage {
public readFile = async (_: Electron.IpcMainInvokeEvent, id: string): Promise<string> => {
const filePath = path.join(this.storageDir, id)
if (documentExts.includes(path.extname(filePath))) {
const fileExtension = path.extname(filePath)
if (documentExts.includes(fileExtension)) {
const originalCwd = process.cwd()
try {
chdir(this.tempDir)
if (fileExtension === '.doc') {
const WordExtractor = require('word-extractor')
const extractor = new WordExtractor()
const extracted = await extractor.extract(filePath)
chdir(originalCwd)
return extracted.getBody()
}
const data = await officeParser.parseOfficeAsync(filePath)
chdir(originalCwd)
return data

View File

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

View File

@@ -95,6 +95,7 @@ export class WindowService {
this.setupMaximize(mainWindow, mainWindowState.isMaximized)
this.setupContextMenu(mainWindow)
this.setupSpellCheck(mainWindow)
this.setupWindowEvents(mainWindow)
this.setupWebContentsHandlers(mainWindow)
this.setupWindowLifecycleEvents(mainWindow)
@@ -102,6 +103,18 @@ export class WindowService {
this.loadMainWindowContent(mainWindow)
}
private setupSpellCheck(mainWindow: BrowserWindow) {
const enableSpellCheck = configManager.get('enableSpellCheck', false)
if (enableSpellCheck) {
try {
const spellCheckLanguages = configManager.get('spellCheckLanguages', []) as string[]
spellCheckLanguages.length > 0 && mainWindow.webContents.session.setSpellCheckerLanguages(spellCheckLanguages)
} catch (error) {
Logger.error('Failed to set spell check languages:', error as Error)
}
}
}
private setupMainWindowMonitor(mainWindow: BrowserWindow) {
mainWindow.webContents.on('render-process-gone', (_, details) => {
Logger.error(`Renderer process crashed with: ${JSON.stringify(details)}`)

View File

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

View File

@@ -8,6 +8,20 @@ import { FileType, FileTypes } from '@types'
import { app } from 'electron'
import { v4 as uuidv4 } from 'uuid'
export function initAppDataDir() {
const appDataPath = getAppDataPathFromConfig()
if (appDataPath) {
app.setPath('userData', appDataPath)
return
}
if (isPortable) {
const portableDir = process.env.PORTABLE_EXECUTABLE_DIR
app.setPath('userData', path.join(portableDir || app.getPath('exe'), 'data'))
return
}
}
// 创建文件类型映射表,提高查找效率
const fileTypeMap = new Map<string, FileTypes>()
@@ -35,46 +49,70 @@ export function hasWritePermission(path: string) {
function getAppDataPathFromConfig() {
try {
const configPath = path.join(getConfigDir(), 'config.json')
if (fs.existsSync(configPath)) {
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
if (config.appDataPath && fs.existsSync(config.appDataPath) && hasWritePermission(config.appDataPath)) {
return config.appDataPath
}
if (!fs.existsSync(configPath)) {
return null
}
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
if (!config.appDataPath) {
return null
}
let appDataPath = null
// 兼容旧版本
if (config.appDataPath && typeof config.appDataPath === 'string') {
appDataPath = config.appDataPath
// 将旧版本数据迁移到新版本
appDataPath && updateAppDataConfig(appDataPath)
} else {
appDataPath = config.appDataPath.find(
(item: { executablePath: string }) => item.executablePath === app.getPath('exe')
)?.dataPath
}
if (appDataPath && fs.existsSync(appDataPath) && hasWritePermission(appDataPath)) {
return appDataPath
}
return null
} catch (error) {
return null
}
return null
}
export function initAppDataDir() {
const appDataPath = getAppDataPathFromConfig()
if (appDataPath) {
app.setPath('userData', appDataPath)
return
}
if (isPortable) {
const portableDir = process.env.PORTABLE_EXECUTABLE_DIR
app.setPath('userData', path.join(portableDir || app.getPath('exe'), 'data'))
return
}
}
export function updateConfig(appDataPath: string) {
export function updateAppDataConfig(appDataPath: string) {
const configDir = getConfigDir()
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true })
}
// config.json
// appDataPath: [{ executablePath: string, dataPath: string }]
const configPath = path.join(getConfigDir(), 'config.json')
if (!fs.existsSync(configPath)) {
fs.writeFileSync(configPath, JSON.stringify({ appDataPath }, null, 2))
fs.writeFileSync(
configPath,
JSON.stringify({ appDataPath: [{ executablePath: app.getPath('exe'), dataPath: appDataPath }] }, null, 2)
)
return
}
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
config.appDataPath = appDataPath
if (!config.appDataPath || (config.appDataPath && typeof config.appDataPath !== 'object')) {
config.appDataPath = []
}
const existingPath = config.appDataPath.find(
(item: { executablePath: string }) => item.executablePath === app.getPath('exe')
)
if (existingPath) {
existingPath.dataPath = appDataPath
} else {
config.appDataPath.push({ executablePath: app.getPath('exe'), dataPath: appDataPath })
}
fs.writeFileSync(configPath, JSON.stringify(config, null, 2))
}

View File

@@ -17,6 +17,8 @@ const api = {
checkForUpdate: () => ipcRenderer.invoke(IpcChannel.App_CheckForUpdate),
showUpdateDialog: () => ipcRenderer.invoke(IpcChannel.App_ShowUpdateDialog),
setLanguage: (lang: string) => ipcRenderer.invoke(IpcChannel.App_SetLanguage, lang),
setEnableSpellCheck: (isEnable: boolean) => ipcRenderer.invoke(IpcChannel.App_SetEnableSpellCheck, isEnable),
setSpellCheckLanguages: (languages: string[]) => ipcRenderer.invoke(IpcChannel.App_SetSpellCheckLanguages, languages),
setLaunchOnBoot: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetLaunchOnBoot, isActive),
setLaunchToTray: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetLaunchToTray, isActive),
setTray: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetTray, isActive),
@@ -29,9 +31,13 @@ const api = {
select: (options: Electron.OpenDialogOptions) => ipcRenderer.invoke(IpcChannel.App_Select, options),
hasWritePermission: (path: string) => ipcRenderer.invoke(IpcChannel.App_HasWritePermission, path),
setAppDataPath: (path: string) => ipcRenderer.invoke(IpcChannel.App_SetAppDataPath, path),
copy: (oldPath: string, newPath: string) => ipcRenderer.invoke(IpcChannel.App_Copy, oldPath, newPath),
getDataPathFromArgs: () => ipcRenderer.invoke(IpcChannel.App_GetDataPathFromArgs),
copy: (oldPath: string, newPath: string, occupiedDirs: string[] = []) =>
ipcRenderer.invoke(IpcChannel.App_Copy, oldPath, newPath, occupiedDirs),
setStopQuitApp: (stop: boolean, reason: string) => ipcRenderer.invoke(IpcChannel.App_SetStopQuitApp, stop, reason),
relaunchApp: () => ipcRenderer.invoke(IpcChannel.App_RelaunchApp),
flushAppData: () => ipcRenderer.invoke(IpcChannel.App_FlushAppData),
isNotEmptyDir: (path: string) => ipcRenderer.invoke(IpcChannel.App_IsNotEmptyDir, path),
relaunchApp: (options?: Electron.RelaunchOptions) => ipcRenderer.invoke(IpcChannel.App_RelaunchApp, options),
openWebsite: (url: string) => ipcRenderer.invoke(IpcChannel.Open_Website, url),
getCacheSize: () => ipcRenderer.invoke(IpcChannel.App_GetCacheSize),
clearCache: () => ipcRenderer.invoke(IpcChannel.App_ClearCache),
@@ -176,6 +182,10 @@ const api = {
getInstallInfo: () => ipcRenderer.invoke(IpcChannel.Mcp_GetInstallInfo),
checkMcpConnectivity: (server: any) => ipcRenderer.invoke(IpcChannel.Mcp_CheckConnectivity, server)
},
python: {
execute: (script: string, context?: Record<string, any>, timeout?: number) =>
ipcRenderer.invoke(IpcChannel.Python_Execute, script, context, timeout)
},
shell: {
openExternal: (url: string, options?: Electron.OpenExternalOptions) => shell.openExternal(url, options)
},

View File

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

View File

@@ -42,11 +42,19 @@ export class AihubmixAPIClient extends BaseApiClient {
constructor(provider: Provider) {
super(provider)
const providerExtraHeaders = {
...provider,
extra_headers: {
...provider.extra_headers,
'APP-Code': 'MLTG2087'
}
}
// 初始化各个client - 现在有类型安全
const claudeClient = new AnthropicAPIClient(provider)
const geminiClient = new GeminiAPIClient({ ...provider, apiHost: 'https://aihubmix.com/gemini' })
const openaiClient = new OpenAIResponseAPIClient(provider)
const defaultClient = new OpenAIAPIClient(provider)
const claudeClient = new AnthropicAPIClient(providerExtraHeaders)
const geminiClient = new GeminiAPIClient({ ...providerExtraHeaders, apiHost: 'https://aihubmix.com/gemini' })
const openaiClient = new OpenAIResponseAPIClient(providerExtraHeaders)
const defaultClient = new OpenAIAPIClient(providerExtraHeaders)
this.clients.set('claude', claudeClient)
this.clients.set('gemini', geminiClient)
@@ -58,6 +66,13 @@ export class AihubmixAPIClient extends BaseApiClient {
this.currentClient = this.defaultClient as BaseApiClient
}
override getBaseURL(): string {
if (!this.currentClient) {
return this.provider.apiHost
}
return this.currentClient.getBaseURL()
}
/**
* 类型守卫确保client是BaseApiClient的实例
*/

View File

@@ -90,11 +90,12 @@ export class AnthropicAPIClient extends BaseApiClient<
return this.sdkInstance
}
this.sdkInstance = new Anthropic({
apiKey: this.getApiKey(),
apiKey: this.apiKey,
baseURL: this.getBaseURL(),
dangerouslyAllowBrowser: true,
defaultHeaders: {
'anthropic-beta': 'output-128k-2025-02-19'
'anthropic-beta': 'output-128k-2025-02-19',
...this.provider.extra_headers
}
})
return this.sdkInstance
@@ -191,7 +192,7 @@ export class AnthropicAPIClient extends BaseApiClient<
const parts: MessageParam['content'] = [
{
type: 'text',
text: getMainTextContent(message)
text: await this.getMessageContent(message)
}
]

View File

@@ -85,7 +85,7 @@ export class GeminiAPIClient extends BaseApiClient<
...rest,
config: {
...rest.config,
abortSignal: options?.abortSignal,
abortSignal: options?.signal,
httpOptions: {
...rest.config?.httpOptions,
timeout: options?.timeout
@@ -176,7 +176,10 @@ export class GeminiAPIClient extends BaseApiClient<
apiVersion: this.getApiVersion(),
httpOptions: {
baseUrl: this.getBaseURL(),
apiVersion: this.getApiVersion()
apiVersion: this.getApiVersion(),
headers: {
...this.provider.extra_headers
}
}
})
@@ -479,6 +482,7 @@ export class GeminiAPIClient extends BaseApiClient<
for (const message of messages) {
history.push(await this.convertMessageToSdkParam(message))
}
messages.push(userLastMessage)
}
}
@@ -682,16 +686,19 @@ export class GeminiAPIClient extends BaseApiClient<
toolCalls: FunctionCall[]
): Content[] {
const parts: Part[] = []
const modelParts: Part[] = []
if (output) {
parts.push({
modelParts.push({
text: output
})
}
toolCalls.forEach((toolCall) => {
parts.push({
modelParts.push({
functionCall: toolCall
})
})
parts.push(
...toolResults
.map((ts) => ts.parts)
@@ -699,10 +706,22 @@ export class GeminiAPIClient extends BaseApiClient<
.filter((p) => p !== undefined)
)
const lastMessage = currentReqMessages[currentReqMessages.length - 1]
if (lastMessage) {
lastMessage.parts?.push(...parts)
const userMessage: Content = {
role: 'user',
parts: []
}
if (modelParts.length > 0) {
currentReqMessages.push({
role: 'model',
parts: modelParts
})
}
if (parts.length > 0) {
userMessage.parts?.push(...parts)
currentReqMessages.push(userMessage)
}
return currentReqMessages
}
@@ -743,7 +762,7 @@ export class GeminiAPIClient extends BaseApiClient<
}
})
}
return [messageParam, ...(sdkPayload.history || [])]
return [...(sdkPayload.history || []), messageParam]
}
private async uploadFile(file: FileType): Promise<File> {

View File

@@ -135,7 +135,7 @@ export abstract class OpenAIBaseClient<
return this.sdkInstance
}
let apiKeyForSdkInstance = this.provider.apiKey
let apiKeyForSdkInstance = this.apiKey
if (this.provider.id === 'copilot') {
const defaultHeaders = store.getState().copilot.defaultHeaders
@@ -159,6 +159,7 @@ export abstract class OpenAIBaseClient<
baseURL: this.getBaseURL(),
defaultHeaders: {
...this.defaultHeaders(),
...this.provider.extra_headers,
...(this.provider.id === 'copilot' ? { 'editor-version': 'vscode/1.97.2' } : {}),
...(this.provider.id === 'copilot' ? { 'copilot-vision-request': 'true' } : {})
}

View File

@@ -78,10 +78,11 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
return new OpenAI({
dangerouslyAllowBrowser: true,
apiKey: this.provider.apiKey,
apiKey: this.apiKey,
baseURL: this.getBaseURL(),
defaultHeaders: {
...this.defaultHeaders()
...this.defaultHeaders(),
...this.provider.extra_headers
}
})
}
@@ -425,6 +426,7 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
const toolCalls: OpenAIResponseSdkToolCall[] = []
const outputItems: OpenAI.Responses.ResponseOutputItem[] = []
let hasBeenCollectedToolCalls = false
let hasReasoningSummary = false
return () => ({
async transform(chunk: OpenAIResponseSdkRawChunk, controller: TransformStreamDefaultController<GenericChunk>) {
// 处理chunk
@@ -496,6 +498,16 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
outputItems.push(chunk.item)
}
break
case 'response.reasoning_summary_part.added':
if (hasReasoningSummary) {
const separator = '\n\n'
controller.enqueue({
type: ChunkType.THINKING_DELTA,
text: separator
})
}
hasReasoningSummary = true
break
case 'response.reasoning_summary_text.delta':
controller.enqueue({
type: ChunkType.THINKING_DELTA,

View File

@@ -255,6 +255,10 @@ function buildParamsWithToolResults(
// 从回复中构建助手消息
const newReqMessages = apiClient.buildSdkMessages(currentReqMessages, output, toolResults, toolCalls)
if (output && ctx._internal.toolProcessingState) {
ctx._internal.toolProcessingState.output = undefined
}
// 估算新增消息的 token 消耗并累加到 usage 中
if (ctx._internal.observer?.usage && newReqMessages.length > currentReqMessages.length) {
try {

View File

@@ -58,166 +58,80 @@
}
}
.mention-models-dropdown {
&.ant-dropdown {
background: rgba(var(--color-base-rgb), 0.65) !important;
backdrop-filter: blur(35px) saturate(150%) !important;
animation-duration: 0.15s !important;
}
/* 移动其他样式到 mention-models-dropdown 类下 */
.ant-slide-up-enter .ant-dropdown-menu,
.ant-slide-up-appear .ant-dropdown-menu,
.ant-slide-up-leave .ant-dropdown-menu,
.ant-slide-up-enter-active .ant-dropdown-menu,
.ant-slide-up-appear-active .ant-dropdown-menu,
.ant-slide-up-leave-active .ant-dropdown-menu {
background: rgba(var(--color-base-rgb), 0.65) !important;
backdrop-filter: blur(35px) saturate(150%) !important;
}
.ant-dropdown-menu {
/* 保持原有的下拉菜单样式,但限定在 mention-models-dropdown 类下 */
max-height: 400px;
overflow-y: auto;
overflow-x: hidden;
padding: 4px 12px;
position: relative;
background: rgba(var(--color-base-rgb), 0.65) !important;
backdrop-filter: blur(35px) saturate(150%) !important;
border: 0.5px solid rgba(var(--color-border-rgb), 0.3);
border-radius: 10px;
box-shadow:
0 0 0 0.5px rgba(0, 0, 0, 0.15),
0 4px 16px rgba(0, 0, 0, 0.15),
0 2px 8px rgba(0, 0, 0, 0.12),
inset 0 0 0 0.5px rgba(255, 255, 255, var(--inner-glow-opacity, 0.1));
transform-origin: top;
will-change: transform, opacity;
transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
margin-bottom: 0;
&.no-scrollbar {
padding-right: 12px;
}
&.has-scrollbar {
padding-right: 2px;
}
// Scrollbar styles
&::-webkit-scrollbar {
width: 14px;
height: 6px;
}
&::-webkit-scrollbar-thumb {
border: 4px solid transparent;
background-clip: padding-box;
border-radius: 7px;
background-color: var(--color-scrollbar-thumb);
min-height: 50px;
transition: all 0.2s;
}
&:hover::-webkit-scrollbar-thumb {
background-color: var(--color-scrollbar-thumb);
}
&::-webkit-scrollbar-thumb:hover {
background-color: var(--color-scrollbar-thumb-hover);
}
&::-webkit-scrollbar-thumb:active {
background-color: var(--color-scrollbar-thumb-hover);
}
&::-webkit-scrollbar-track {
background: transparent;
border-radius: 7px;
}
}
.ant-dropdown-menu-item-group {
margin-bottom: 4px;
&:not(:first-child) {
margin-top: 4px;
}
.ant-dropdown-menu-item-group-title {
padding: 5px 12px;
color: var(--color-text-3);
font-size: 12px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.03em;
opacity: 0.7;
}
}
// Handle no-results case margin
.no-results {
padding: 8px 12px;
color: var(--color-text-3);
cursor: default;
font-size: 13px;
opacity: 0.8;
margin-bottom: 40px;
&:hover {
background: none;
}
}
.ant-dropdown-menu-item {
padding: 5px 12px;
margin: 0 -12px;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
align-items: center;
gap: 8px;
border-radius: 6px;
font-size: 13px;
&:hover {
background: rgba(var(--color-hover-rgb), 0.5);
}
&.ant-dropdown-menu-item-selected {
background-color: rgba(var(--color-primary-rgb), 0.12);
color: var(--color-primary);
}
.ant-dropdown-menu-item-icon {
margin-right: 0;
opacity: 0.9;
}
}
.ant-dropdown-menu .ant-dropdown-menu-sub {
max-height: 50vh;
width: max-content;
overflow-y: auto;
overflow-x: hidden;
border: 0.5px solid var(--color-border);
}
.ant-dropdown {
background-color: var(--ant-color-bg-elevated);
overflow: hidden;
border-radius: var(--ant-border-radius-lg);
.ant-dropdown-menu {
max-height: 50vh;
overflow-y: auto;
border: 0.5px solid var(--color-border);
.ant-dropdown-menu-sub {
max-height: 50vh;
width: max-content;
overflow-y: auto;
overflow-x: hidden;
border: 0.5px solid var(--color-border);
}
}
.ant-dropdown-arrow + .ant-dropdown-menu {
border: none;
}
}
.ant-select-dropdown {
border: 0.5px solid var(--color-border);
}
.ant-dropdown-menu-submenu {
background-color: var(--ant-color-bg-elevated);
overflow: hidden;
border-radius: var(--ant-border-radius-lg);
}
.ant-popover {
.ant-popover-inner {
border: 0.5px solid var(--color-border);
.ant-popover-inner-content {
max-height: 70vh;
overflow-y: auto;
}
}
.ant-popover-arrow + .ant-popover-content {
.ant-popover-inner {
border: none;
}
}
}
.ant-modal:not(.ant-modal-confirm) {
.ant-modal-confirm-body-has-title {
padding: 16px 0 0 0;
}
.ant-modal-content {
border-radius: 10px;
border: 0.5px solid var(--color-border);
padding: 0 0 8px 0;
.ant-modal-header {
padding: 16px 16px 0 16px;
border-radius: 10px;
}
.ant-modal-body {
max-height: 80vh;
overflow-y: auto;
padding: 0 16px 0 16px;
}
.ant-modal-footer {
padding: 0 16px 8px 16px;
}
.ant-modal-confirm-btns {
margin-bottom: 8px;
}
}
}
.ant-modal.ant-modal-confirm.ant-modal-confirm-confirm {
.ant-modal-content {
padding: 16px;
}
}
.ant-collapse {
border: 1px solid var(--color-border);
@@ -227,8 +141,14 @@
}
.ant-collapse-content {
border-top: 1px solid var(--color-border) !important;
border-top: 0.5px solid var(--color-border) !important;
.ant-color-picker & {
border-top: none !important;
}
}
.ant-slider {
.ant-slider-handle::after {
box-shadow: 0 1px 4px 0px rgb(128 128 128 / 50%) !important;
}
}

View File

@@ -47,7 +47,7 @@
--color-list-item: #222;
--color-list-item-hover: #1e1e1e;
--modal-background: #1f1f1f;
--modal-background: #111111;
--color-highlight: rgba(0, 0, 0, 1);
--color-background-highlight: rgba(255, 255, 0, 0.9);
@@ -66,9 +66,9 @@
--settings-width: 250px;
--scrollbar-width: 5px;
--chat-background: #111111;
--chat-background-user: #28b561;
--chat-background-assistant: #2c2c2c;
--chat-background: transparent;
--chat-background-user: rgba(255, 255, 255, 0.08);
--chat-background-assistant: transparent;
--chat-text-user: var(--color-black);
--list-item-border-radius: 20px;
@@ -132,8 +132,8 @@
--navbar-background-mac: rgba(255, 255, 255, 0.55);
--navbar-background: rgba(244, 244, 244);
--chat-background: #f3f3f3;
--chat-background-user: #95ec69;
--chat-background-assistant: #ffffff;
--chat-background: transparent;
--chat-background-user: rgba(0, 0, 0, 0.045);
--chat-background-assistant: transparent;
--chat-text-user: var(--color-text);
}

View File

@@ -111,27 +111,7 @@ ul {
word-wrap: break-word;
}
.bubble {
background-color: var(--chat-background);
#chat-main {
background-color: var(--chat-background);
}
#messages {
background-color: var(--chat-background);
}
#inputbar {
margin: -5px 15px 15px 15px;
background: var(--color-background);
}
.system-prompt {
background-color: var(--chat-background-assistant);
}
.message-content-container {
margin: 5px 0;
border-radius: 8px;
padding: 0.5rem 1rem;
}
.bubble:not(.multi-select-mode) {
.block-wrapper {
display: flow-root;
}
@@ -149,30 +129,35 @@ ul {
}
.message-user {
color: var(--chat-text-user);
.message-content-container-user .anticon {
color: var(--chat-text-user) !important;
.message-header {
flex-direction: row-reverse;
text-align: right;
.message-header-info-wrap {
flex-direction: row-reverse;
text-align: right;
}
}
.markdown {
color: var(--chat-text-user);
}
}
.group-grid-container.horizontal,
.group-grid-container.grid {
.message-content-container-assistant {
padding: 0;
}
}
.group-message-wrapper {
background-color: var(--color-background);
.message-content-container {
width: 100%;
border-radius: 10px 0 10px 10px;
padding: 10px 16px 10px 16px;
background-color: var(--chat-background-user);
align-self: self-end;
}
.MessageFooter {
margin-top: 2px;
align-self: self-end;
}
}
.group-menu-bar {
background-color: var(--color-background);
.message-assistant {
.message-content-container {
padding-left: 0;
}
.MessageFooter {
margin-left: 0;
}
}
code {
color: var(--color-text);
}
@@ -196,3 +181,9 @@ span.highlight {
span.highlight.selected {
background-color: var(--color-background-highlight-accent);
}
textarea {
&::-webkit-resizer {
display: none;
}
}

View File

@@ -98,7 +98,6 @@
border: none;
border-top: 0.5px solid var(--color-border);
margin: 20px 0;
background-color: var(--color-border);
}
span {
@@ -119,7 +118,7 @@
}
pre {
border-radius: 5px;
border-radius: 8px;
overflow-x: auto;
font-family: 'Fira Code', 'Courier New', Courier, monospace;
background-color: var(--color-background-mute);
@@ -157,15 +156,28 @@
}
table {
border-collapse: collapse;
--table-border-radius: 8px;
margin: 1em 0;
width: 100%;
border-radius: var(--table-border-radius);
overflow: hidden;
border-collapse: separate;
border: 0.5px solid var(--color-border);
border-spacing: 0;
}
th,
td {
border: 0.5px solid var(--color-border);
border-right: 0.5px solid var(--color-border);
border-bottom: 0.5px solid var(--color-border);
padding: 0.5em;
&:last-child {
border-right: none;
}
}
tr:last-child td {
border-bottom: none;
}
th {
@@ -238,6 +250,10 @@
text-decoration: underline;
}
}
> *:last-child {
margin-bottom: 0 !important;
}
}
.footnotes {
@@ -309,7 +325,7 @@ mjx-container {
/* CodeMirror 相关样式 */
.cm-editor {
border-radius: 5px;
border-radius: inherit;
&.cm-focused {
outline: none;
@@ -317,7 +333,7 @@ mjx-container {
.cm-scroller {
font-family: var(--code-font-family);
border-radius: 5px;
border-radius: inherit;
.cm-gutters {
line-height: 1.6;

View File

@@ -5,22 +5,57 @@ html {
}
:root {
--color-selection-toolbar-background: rgba(20, 20, 20, 0.95);
--color-selection-toolbar-border: rgba(55, 55, 55, 0.5);
--color-selection-toolbar-shadow: rgba(50, 50, 50, 0.3);
--color-selection-toolbar-text: rgba(255, 255, 245, 0.9);
--color-selection-toolbar-hover-bg: #222222;
// Basic Colors
--color-primary: #00b96b;
--color-error: #f44336;
--selection-toolbar-color-primary: var(--color-primary);
--selection-toolbar-color-error: var(--color-error);
// Toolbar
--selection-toolbar-height: 36px; // default: 36px max: 42px
--selection-toolbar-font-size: 14px; // default: 14px
--selection-toolbar-logo-display: flex; // values: flex | none
--selection-toolbar-logo-size: 22px; // default: 22px
--selection-toolbar-logo-margin: 0 0 0 5px; // default: 0 0 05px
// DO NOT MODIFY THESE VALUES, IF YOU DON'T KNOW WHAT YOU ARE DOING
--selection-toolbar-padding: 2px 4px 2px 2px; // default: 2px 4px 2px 2px
--selection-toolbar-margin: 2px 3px 5px 3px; // default: 2px 3px 5px 3px
// ------------------------------------------------------------
--selection-toolbar-border-radius: 6px;
--selection-toolbar-border: 1px solid rgba(55, 55, 55, 0.5);
--selection-toolbar-box-shadow: 0px 2px 3px rgba(50, 50, 50, 0.3);
--selection-toolbar-background: rgba(20, 20, 20, 0.95);
// Buttons
--selection-toolbar-button-icon-size: 16px; // default: 16px
--selection-toolbar-button-text-margin: 0 0 0 3px; // default: 0 0 0 3px
--selection-toolbar-button-margin: 0 2px; // default: 0 2px
--selection-toolbar-button-padding: 4px 6px; // default: 4px 6px
--selection-toolbar-button-border-radius: 4px; // default: 4px
--selection-toolbar-button-border: none; // default: none
--selection-toolbar-button-box-shadow: none; // default: none
--selection-toolbar-button-text-color: rgba(255, 255, 245, 0.9);
--selection-toolbar-button-icon-color: var(--selection-toolbar-button-text-color);
--selection-toolbar-button-text-color-hover: var(--selection-toolbar-color-primary);
--selection-toolbar-button-icon-color-hover: var(--selection-toolbar-color-primary);
--selection-toolbar-button-bgcolor: transparent; // default: transparent
--selection-toolbar-button-bgcolor-hover: #222222;
}
[theme-mode='light'] {
--color-selection-toolbar-background: rgba(245, 245, 245, 0.95);
--color-selection-toolbar-border: rgba(200, 200, 200, 0.5);
--color-selection-toolbar-shadow: rgba(50, 50, 50, 0.3);
--selection-toolbar-border: 1px solid rgba(200, 200, 200, 0.5);
--selection-toolbar-box-shadow: 0px 2px 3px rgba(50, 50, 50, 0.3);
--selection-toolbar-background: rgba(245, 245, 245, 0.95);
--color-selection-toolbar-text: rgba(0, 0, 0, 1);
--color-selection-toolbar-hover-bg: rgba(0, 0, 0, 0.04);
--selection-toolbar-button-text-color: rgba(0, 0, 0, 1);
--selection-toolbar-button-icon-color: var(--selection-toolbar-button-text-color);
--selection-toolbar-button-text-color-hover: var(--selection-toolbar-color-primary);
--selection-toolbar-button-icon-color-hover: var(--selection-toolbar-color-primary);
--selection-toolbar-button-bgcolor-hover: rgba(0, 0, 0, 0.04);
}

View File

@@ -168,9 +168,15 @@ const CodePreview = ({ children, language, setTools }: CodePreviewProps) => {
}
}, [highlightCode])
const hasHighlightedCode = useMemo(() => {
return tokenLines.length > 0
}, [tokenLines.length])
useEffect(() => {
const container = codeContentRef.current
if (!container || !codeShowLineNumbers) return
const digits = Math.max(tokenLines.length.toString().length, 1)
container.style.setProperty('--line-digits', digits.toString())
}, [codeShowLineNumbers, tokenLines.length])
const hasHighlightedCode = tokenLines.length > 0
return (
<ContentContainer
@@ -238,12 +244,16 @@ const ContentContainer = styled.div<{
}>`
position: relative;
overflow: auto;
border: 0.5px solid transparent;
border-radius: 5px;
border-radius: inherit;
margin-top: 0;
/* 动态宽度计算 */
--line-digits: 0;
--gutter-width: max(calc(var(--line-digits) * 0.7rem), 2.1rem);
.shiki {
padding: 1em;
border-radius: inherit;
code {
display: flex;
@@ -252,7 +262,7 @@ const ContentContainer = styled.div<{
.line {
display: block;
min-height: 1.3rem;
padding-left: ${(props) => (props.$lineNumbers ? '2rem' : '0')};
padding-left: ${(props) => (props.$lineNumbers ? 'var(--gutter-width)' : '0')};
* {
overflow-wrap: ${(props) => (props.$wrap ? 'break-word' : 'normal')};
@@ -291,7 +301,7 @@ const ContentContainer = styled.div<{
}
}
animation: ${(props) => (props.$fadeIn ? 'contentFadeIn 0.3s ease-in-out forwards' : 'none')};
animation: ${(props) => (props.$fadeIn ? 'contentFadeIn 0.1s ease-in forwards' : 'none')};
`
const CodePlaceholder = styled.div`

View File

@@ -273,6 +273,7 @@ const CodeHeader = styled.div<{ $isInSpecialView: boolean }>`
align-items: center;
color: var(--color-text);
font-size: 14px;
line-height: 1;
font-weight: bold;
padding: 0 10px;
border-top-left-radius: 8px;
@@ -288,6 +289,10 @@ const SplitViewWrapper = styled.div`
flex: 1 1 auto;
width: 100%;
}
&:not(:has(+ [class*='Container'])) {
border-radius: 0 0 8px 8px;
}
`
export default memo(CodeBlockView)

View File

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

View File

@@ -1,87 +1,59 @@
import { Dropdown } from 'antd'
import { useCallback, useEffect, useState } from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface ContextMenuProps {
children: React.ReactNode
onContextMenu?: (e: React.MouseEvent) => void
style?: React.CSSProperties
}
const ContextMenu: React.FC<ContextMenuProps> = ({ children, onContextMenu, style }) => {
const ContextMenu: React.FC<ContextMenuProps> = ({ children }) => {
const { t } = useTranslation()
const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null)
const [selectedText, setSelectedText] = useState<string>('')
const [selectedText, setSelectedText] = useState<string | undefined>(undefined)
const handleContextMenu = useCallback(
(e: React.MouseEvent) => {
e.preventDefault()
const _selectedText = window.getSelection()?.toString()
if (_selectedText) {
setContextMenuPosition({ x: e.clientX, y: e.clientY })
setSelectedText(_selectedText)
}
onContextMenu?.(e)
},
[onContextMenu]
)
const contextMenuItems = useMemo(() => {
if (!selectedText) return []
useEffect(() => {
const handleClick = () => {
setContextMenuPosition(null)
}
document.addEventListener('click', handleClick)
return () => {
document.removeEventListener('click', handleClick)
}
}, [])
// 获取右键菜单项
const getContextMenuItems = (t: (key: string) => string, selectedText: string) => [
{
key: 'copy',
label: t('common.copy'),
onClick: () => {
if (selectedText) {
navigator.clipboard
.writeText(selectedText)
.then(() => {
window.message.success({ content: t('message.copied'), key: 'copy-message' })
})
.catch(() => {
window.message.error({ content: t('message.copy.failed'), key: 'copy-message-failed' })
})
}
}
},
{
key: 'quote',
label: t('chat.message.quote'),
onClick: () => {
if (selectedText) {
window.api?.quoteToMainWindow(selectedText)
return [
{
key: 'copy',
label: t('common.copy'),
onClick: () => {
if (selectedText) {
navigator.clipboard
.writeText(selectedText)
.then(() => {
window.message.success({ content: t('message.copied'), key: 'copy-message' })
})
.catch(() => {
window.message.error({ content: t('message.copy.failed'), key: 'copy-message-failed' })
})
}
}
},
{
key: 'quote',
label: t('chat.message.quote'),
onClick: () => {
if (selectedText) {
window.api?.quoteToMainWindow(selectedText)
}
}
}
]
}, [selectedText, t])
const onOpenChange = (open: boolean) => {
if (open) {
const selectedText = window.getSelection()?.toString()
setSelectedText(selectedText)
}
]
}
return (
<ContextContainer onContextMenu={handleContextMenu} className="context-menu-container" style={style}>
{contextMenuPosition && (
<Dropdown
overlayStyle={{ position: 'fixed', left: contextMenuPosition.x, top: contextMenuPosition.y, zIndex: 1000 }}
menu={{ items: getContextMenuItems(t, selectedText) }}
open={true}
trigger={['contextMenu']}>
<div />
</Dropdown>
)}
<Dropdown onOpenChange={onOpenChange} menu={{ items: contextMenuItems }} trigger={['contextMenu']}>
{children}
</ContextContainer>
</Dropdown>
)
}
const ContextContainer = styled.div``
export default ContextMenu

View File

@@ -1,5 +1,6 @@
import { Collapse } from 'antd'
import { merge } from 'lodash'
import { ChevronRight } from 'lucide-react'
import { FC, memo, useMemo, useState } from 'react'
interface CustomCollapseProps {
@@ -78,6 +79,14 @@ const CustomCollapse: FC<CustomCollapseProps> = ({
destroyInactivePanel={destroyInactivePanel}
collapsible={collapsible}
onChange={setActiveKeys}
expandIcon={({ isActive }) => (
<ChevronRight
size={16}
color="var(--color-text-3)"
strokeWidth={1.5}
style={{ transform: isActive ? 'rotate(90deg)' : 'rotate(0deg)' }}
/>
)}
items={[
{
styles: collapseItemStyles,

View File

@@ -0,0 +1,114 @@
import { InputNumber } from 'antd'
import { FC, useEffect, useRef, useState } from 'react'
import styled from 'styled-components'
export interface EditableNumberProps {
value?: number | null
min?: number
max?: number
step?: number
precision?: number
placeholder?: string
disabled?: boolean
changeOnBlur?: boolean
onChange?: (value: number | null) => void
onBlur?: () => void
style?: React.CSSProperties
className?: string
size?: 'small' | 'middle' | 'large'
suffix?: string
prefix?: string
align?: 'start' | 'center' | 'end'
}
const EditableNumber: FC<EditableNumberProps> = ({
value,
min,
max,
step = 0.01,
precision,
placeholder,
disabled = false,
onChange,
onBlur,
changeOnBlur = false,
style,
className,
size = 'middle',
align = 'end'
}) => {
const [isEditing, setIsEditing] = useState(false)
const [inputValue, setInputValue] = useState(value)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
setInputValue(value)
}, [value])
const handleFocus = () => {
if (disabled) return
setIsEditing(true)
}
const handleInputChange = (newValue: number | null) => {
onChange?.(newValue ?? null)
}
const handleBlur = () => {
setIsEditing(false)
onBlur?.()
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleBlur()
} else if (e.key === 'Escape') {
setInputValue(value)
setIsEditing(false)
}
}
return (
<Container>
<InputNumber
style={{ ...style, opacity: isEditing ? 1 : 0 }}
ref={inputRef}
value={inputValue}
min={min}
max={max}
step={step}
precision={precision}
size={size}
onChange={handleInputChange}
onBlur={handleBlur}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
className={className}
controls={isEditing}
changeOnBlur={changeOnBlur}
/>
<DisplayText style={style} className={className} $align={align} $isEditing={isEditing}>
{value ?? placeholder}
</DisplayText>
</Container>
)
}
const Container = styled.div`
display: inline-block;
position: relative;
`
const DisplayText = styled.div<{
$align: 'start' | 'center' | 'end'
$isEditing: boolean
}>`
position: absolute;
inset: 0;
display: ${({ $isEditing }) => ($isEditing ? 'none' : 'flex')};
align-items: center;
justify-content: ${({ $align }) => $align};
pointer-events: none;
`
export default EditableNumber

View File

@@ -41,11 +41,10 @@ const MarkdownEditor: FC<MarkdownEditorProps> = ({
return (
<EditorContainer style={{ height }}>
<InputArea value={inputValue} onChange={handleChange} placeholder={placeholder} autoFocus={autoFocus} />
<PreviewArea>
<PreviewArea className="markdown">
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkCjkFriendly, remarkMath]}
rehypePlugins={[rehypeRaw, rehypeKatex]}
className="markdown">
rehypePlugins={[rehypeRaw, rehypeKatex]}>
{inputValue || t('settings.provider.notes.markdown_editor_default_value')}
</ReactMarkdown>
</PreviewArea>

View File

@@ -10,7 +10,7 @@ import {
PushpinOutlined,
ReloadOutlined
} from '@ant-design/icons'
import { isLinux, isMac, isWindows } from '@renderer/config/constant'
import { isLinux, isMac, isWin } from '@renderer/config/constant'
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
import { useBridge } from '@renderer/hooks/useBridge'
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
@@ -303,7 +303,7 @@ const MinappPopupContainer: React.FC = () => {
</Tooltip>
)}
<Spacer />
<ButtonsGroup className={isWindows || isLinux ? 'windows' : ''}>
<ButtonsGroup className={isWin || isLinux ? 'windows' : ''}>
<Tooltip title={t('minapp.popup.goBack')} mouseEnterDelay={0.8} placement="bottom">
<Button onClick={() => handleGoBack(appInfo.id)}>
<ArrowLeftOutlined />
@@ -452,7 +452,7 @@ const ButtonsGroup = styled.div`
gap: 5px;
-webkit-app-region: no-drag;
&.windows {
margin-right: ${isWindows ? '130px' : isLinux ? '100px' : 0};
margin-right: ${isWin ? '130px' : isLinux ? '100px' : 0};
background-color: var(--color-background-mute);
border-radius: 50px;
padding: 0 3px;

View File

@@ -35,17 +35,38 @@ const MultiSelectActionPopup: FC<Props> = ({ topic }) => {
<SelectionCount>{t('common.selectedMessages', { count: selectedMessageIds.length })}</SelectionCount>
<ActionButtons>
<Tooltip title={t('common.save')}>
<ActionButton icon={<Save size={16} />} disabled={isActionDisabled} onClick={() => handleAction('save')} />
<Button
shape="circle"
color="default"
variant="text"
icon={<Save size={16} />}
disabled={isActionDisabled}
onClick={() => handleAction('save')}
/>
</Tooltip>
<Tooltip title={t('common.copy')}>
<ActionButton icon={<Copy size={16} />} disabled={isActionDisabled} onClick={() => handleAction('copy')} />
<Button
shape="circle"
color="default"
variant="text"
icon={<Copy size={16} />}
disabled={isActionDisabled}
onClick={() => handleAction('copy')}
/>
</Tooltip>
<Tooltip title={t('common.delete')}>
<ActionButton danger icon={<Trash size={16} />} onClick={() => handleAction('delete')} />
<Button
shape="circle"
color="danger"
variant="text"
danger
icon={<Trash size={16} />}
onClick={() => handleAction('delete')}
/>
</Tooltip>
</ActionButtons>
<Tooltip title={t('chat.navigation.close')}>
<ActionButton icon={<X size={16} />} onClick={handleClose} />
<Button shape="circle" color="default" variant="text" icon={<X size={16} />} onClick={handleClose} />
</Tooltip>
</ActionBar>
</Container>
@@ -53,45 +74,38 @@ const MultiSelectActionPopup: FC<Props> = ({ topic }) => {
}
const Container = styled.div`
width: 100%;
padding: 36px 20px;
background-color: var(--color-background);
border-top: 1px solid var(--color-border);
position: fixed;
inset: auto 0 0 0;
z-index: 1000;
display: flex;
justify-content: center;
align-items: center;
padding: 16px;
`
const ActionBar = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
background-color: var(--color-background);
padding: 4px 4px;
border-radius: 99px;
box-shadow: 0 0px 5px 0px rgb(128 128 128 / 30%);
border: 0.5px solid var(--color-border);
gap: 16px;
`
const ActionButtons = styled.div`
display: flex;
gap: 16px;
`
const ActionButton = styled(Button)`
display: flex;
align-items: center;
justify-content: center;
padding: 8px 16px;
border-radius: 50%;
.anticon {
font-size: 16px;
}
&:hover {
background-color: var(--color-background-mute);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
gap: 8px;
`
const SelectionCount = styled.div`
margin-right: 15px;
color: var(--color-text-2);
font-size: 14px;
padding-left: 8px;
flex-shrink: 0;
`
export default MultiSelectActionPopup

View File

@@ -32,16 +32,23 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
onCancel={onCancel}
afterClose={onClose}
title={null}
width="920px"
width={700}
transitionName="animation-move-down"
styles={{
content: {
borderRadius: 20,
padding: 0,
border: `1px solid var(--color-frame-border)`
overflow: 'hidden',
paddingBottom: 16
},
body: { height: '85vh' }
body: {
height: '80vh',
maxHeight: 'inherit',
padding: 0
}
}}
centered
closable={false}
footer={null}>
<HistoryPage />
</Modal>

View File

@@ -388,8 +388,11 @@ const PopupContainer: React.FC<Props> = ({ model, resolve }) => {
borderRadius: 20,
padding: 0,
overflow: 'hidden',
paddingBottom: 20,
border: '1px solid var(--color-border)'
paddingBottom: 16
},
body: {
maxHeight: 'inherit',
padding: 0
}
}}
closeIcon={null}

View File

@@ -48,17 +48,17 @@ const Scrollbar: FC<Props> = ({ ref: passedRef, children, onScroll: externalOnSc
}, [throttledInternalScrollHandler, clearScrollingTimeout])
return (
<Container
<ScrollBarContainer
{...htmlProps} // Pass other HTML attributes
$isScrolling={isScrolling}
onScroll={combinedOnScroll} // Use the combined handler
ref={passedRef}>
{children}
</Container>
</ScrollBarContainer>
)
}
const Container = styled.div<{ $isScrolling: boolean }>`
const ScrollBarContainer = styled.div<{ $isScrolling: boolean }>`
overflow-y: auto;
&::-webkit-scrollbar-thumb {
transition: background 2s ease;

View File

@@ -0,0 +1,192 @@
import { Dropdown, DropdownProps } from 'antd'
import { Check, ChevronsUpDown } from 'lucide-react'
import { ReactNode, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled, { css } from 'styled-components'
interface SelectorOption<V = string | number> {
label: string | ReactNode
value: V
type?: 'group'
options?: SelectorOption<V>[]
disabled?: boolean
}
interface BaseSelectorProps<V = string | number> {
options: SelectorOption<V>[]
placeholder?: string
placement?: 'topLeft' | 'topCenter' | 'topRight' | 'bottomLeft' | 'bottomCenter' | 'bottomRight' | 'top' | 'bottom'
/** 字体大小 */
size?: number
/** 是否禁用 */
disabled?: boolean
}
interface SingleSelectorProps<V> extends BaseSelectorProps<V> {
multiple?: false
value?: V
onChange: (value: V) => void
}
interface MultipleSelectorProps<V> extends BaseSelectorProps<V> {
multiple: true
value?: V[]
onChange: (value: V[]) => void
}
type SelectorProps<V> = SingleSelectorProps<V> | MultipleSelectorProps<V>
const Selector = <V extends string | number>({
options,
value,
onChange = () => {},
placement = 'bottomRight',
size = 13,
placeholder,
disabled = false,
multiple = false
}: SelectorProps<V>) => {
const [open, setOpen] = useState(false)
const { t } = useTranslation()
const inputRef = useRef<any>(null)
useEffect(() => {
if (open) {
setTimeout(() => {
inputRef.current?.focus()
}, 1)
}
}, [open])
const selectedValues = useMemo(() => {
if (multiple) {
return (value as V[]) || []
}
return value !== undefined ? [value as V] : []
}, [value, multiple])
const label = useMemo(() => {
if (selectedValues.length > 0) {
const findLabels = (opts: SelectorOption<V>[]): (string | ReactNode)[] => {
const labels: (string | ReactNode)[] = []
for (const opt of opts) {
if (selectedValues.includes(opt.value)) {
labels.push(opt.label)
}
if (opt.options) {
labels.push(...findLabels(opt.options))
}
}
return labels
}
const labels = findLabels(options)
if (labels.length === 0) return placeholder
if (labels.length === 1) return labels[0]
return t('common.selectedItems', { count: labels.length })
}
return placeholder
}, [selectedValues, placeholder, options, t])
const items = useMemo(() => {
const mapOption = (option: SelectorOption<V>) => ({
key: option.value,
label: option.label,
extra: <CheckIcon>{selectedValues.includes(option.value) && <Check size={14} />}</CheckIcon>,
disabled: option.disabled,
type: option.type || (option.options ? 'group' : undefined),
children: option.options?.map(mapOption)
})
return options.map(mapOption)
}, [options, selectedValues])
function onClick(e: { key: string }) {
if (disabled) return
const newValue = e.key as V
if (multiple) {
const newValues = selectedValues.includes(newValue)
? selectedValues.filter((v) => v !== newValue)
: [...selectedValues, newValue]
;(onChange as MultipleSelectorProps<V>['onChange'])(newValues)
} else {
;(onChange as SingleSelectorProps<V>['onChange'])(newValue)
setOpen(false)
}
}
const handleOpenChange: DropdownProps['onOpenChange'] = (nextOpen, info) => {
if (disabled) return
if (info.source === 'trigger' || nextOpen) {
setOpen(nextOpen)
}
}
return (
<Dropdown
overlayClassName="selector-dropdown"
menu={{ items, onClick }}
trigger={['click']}
placement={placement}
open={open && !disabled}
onOpenChange={handleOpenChange}>
<Label $size={size} $open={open} $disabled={disabled} $isPlaceholder={label === placeholder}>
{label}
<LabelIcon size={size + 3} />
</Label>
</Dropdown>
)
}
const LabelIcon = styled(ChevronsUpDown)`
border-radius: 4px;
padding: 2px 0;
background-color: var(--color-background-soft);
transition: background-color 0.2s;
`
const Label = styled.div<{ $size: number; $open: boolean; $disabled: boolean; $isPlaceholder: boolean }>`
display: flex;
align-items: center;
gap: 4px;
border-radius: 99px;
padding: 3px 2px 3px 10px;
font-size: ${({ $size }) => $size}px;
line-height: 1;
cursor: ${({ $disabled }) => ($disabled ? 'not-allowed' : 'pointer')};
opacity: ${({ $disabled }) => ($disabled ? 0.6 : 1)};
color: ${({ $isPlaceholder }) => ($isPlaceholder ? 'var(--color-text-2)' : 'inherit')};
transition:
background-color 0.2s,
opacity 0.2s;
&:hover {
${({ $disabled }) =>
!$disabled &&
css`
background-color: var(--color-background-mute);
${LabelIcon} {
background-color: var(--color-background-mute);
}
`}
}
${({ $open, $disabled }) =>
$open &&
!$disabled &&
css`
background-color: var(--color-background-mute);
${LabelIcon} {
background-color: var(--color-background-mute);
}
`}
`
const CheckIcon = styled.div`
width: 20px;
display: flex;
align-items: center;
justify-content: end;
`
export default Selector

View File

@@ -1,4 +1,4 @@
import { isLinux, isMac, isWindows } from '@renderer/config/constant'
import { isLinux, isMac, isWin } from '@renderer/config/constant'
import { useFullscreen } from '@renderer/hooks/useFullscreen'
import useNavBackgroundColor from '@renderer/hooks/useNavBackgroundColor'
import type { FC, PropsWithChildren } from 'react'
@@ -78,7 +78,7 @@ const NavbarRightContainer = styled.div<{ $isFullscreen: boolean }>`
display: flex;
align-items: center;
padding: 0 12px;
padding-right: ${({ $isFullscreen }) => ($isFullscreen ? '12px' : isWindows ? '140px' : isLinux ? '120px' : '12px')};
padding-right: ${({ $isFullscreen }) => ($isFullscreen ? '12px' : isWin ? '140px' : isLinux ? '120px' : '12px')};
justify-content: flex-end;
`
@@ -91,5 +91,5 @@ const NavbarMainContainer = styled.div<{ $isFullscreen: boolean }>`
padding: 0 ${isMac ? '20px' : 0};
font-weight: bold;
color: var(--color-text-1);
padding-right: ${({ $isFullscreen }) => ($isFullscreen ? '12px' : isWindows ? '140px' : isLinux ? '120px' : '12px')};
padding-right: ${({ $isFullscreen }) => ($isFullscreen ? '12px' : isWin ? '140px' : isLinux ? '120px' : '12px')};
`

View File

@@ -6,7 +6,7 @@ export const DEFAULT_KNOWLEDGE_THRESHOLD = 0.0
export const platform = window.electron?.process?.platform
export const isMac = platform === 'darwin'
export const isWindows = platform === 'win32' || platform === 'win64'
export const isWin = platform === 'win32' || platform === 'win64'
export const isLinux = platform === 'linux'
export const SILICON_CLIENT_ID = 'SFaJLLq0y6CAMoyDm81aMu'

View File

@@ -1352,12 +1352,6 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
name: 'DeepSeek-V3',
group: 'DeepSeek'
},
{
id: 'deepseek-v3-250324',
provider: 'doubao',
name: 'DeepSeek-V3',
group: 'DeepSeek'
},
{
id: 'doubao-pro-32k-241215',
provider: 'doubao',
@@ -2475,6 +2469,10 @@ export function isGeminiReasoningModel(model?: Model): boolean {
return false
}
if (model.id.startsWith('gemini') && model.id.includes('thinking')) {
return true
}
if (model.id.includes('gemini-2.5')) {
return true
}
@@ -2688,7 +2686,7 @@ export function isOpenRouterBuiltInWebSearchModel(model: Model): boolean {
return false
}
return isOpenAIWebSearchModel(model) || model.id.includes('sonar')
return isOpenAIWebSearchChatCompletionOnlyModel(model) || model.id.includes('sonar')
}
export function isGenerateImageModel(model: Model): boolean {
@@ -2720,7 +2718,7 @@ export function isSupportedDisableGenerationModel(model: Model): boolean {
return false
}
return SUPPORTED_DISABLE_GENERATION_MODELS.includes(model.id)
return SUPPORTED_DISABLE_GENERATION_MODELS.includes(getBaseModelName(model.id))
}
export function getOpenAIWebSearchParams(model: Model, isEnableWebSearch?: boolean): Record<string, any> {
@@ -2853,7 +2851,7 @@ export const findTokenLimit = (modelId: string): { min: number; max: number } |
// Doubao 支持思考模式的模型正则
export const DOUBAO_THINKING_MODEL_REGEX =
/doubao-(?:1[.-]5-thinking-vision-pro|1[.-]5-thinking-pro-m|seed-1[.-]6(?:-flash)?)(?:-[\w-]+)?/i
/doubao-(?:1[.-]5-thinking-vision-pro|1[.-]5-thinking-pro-m|seed-1[.-]6(?:-flash)?)(?:-\d{6})?$/i
// 支持 auto 的 Doubao 模型 doubao-seed-1.6-xxx doubao-seed-1-6-xxx doubao-1-5-thinking-pro-m-xxx
export const DOUBAO_THINKING_AUTO_MODEL_REGEX = /doubao-(1-5-thinking-pro-m|seed-1\.6|seed-1-6-[\w-]+)(?:-[\w-]+)*/i

View File

@@ -38,7 +38,20 @@ const AntdProvider: FC<PropsWithChildren> = ({ children }) => {
boxShadowSecondary: 'none',
defaultShadow: 'none',
dangerShadow: 'none',
primaryShadow: 'none'
primaryShadow: 'none',
controlHeight: 30,
paddingInline: 10
},
Input: {
controlHeight: 30,
colorBorder: 'var(--color-border)'
},
InputNumber: {
colorBorder: 'var(--color-border)'
},
Select: {
controlHeight: 30,
colorBorder: 'var(--color-border)'
},
Collapse: {
headerBg: 'transparent'
@@ -50,13 +63,47 @@ const AntdProvider: FC<PropsWithChildren> = ({ children }) => {
fontFamily: 'var(--code-font-family)'
},
Segmented: {
itemActiveBg: 'var(--color-background-mute)',
itemHoverBg: 'var(--color-background-mute)'
itemActiveBg: 'var(--color-background-soft)',
itemHoverBg: 'var(--color-background-soft)',
trackBg: 'rgba(153,153,153,0.15)'
},
Switch: {
colorTextQuaternary: 'rgba(153,153,153,0.20)',
trackMinWidth: 40,
handleSize: 19,
trackMinWidthSM: 28,
trackHeightSM: 17,
handleSizeSM: 14,
trackPadding: 1.5
},
Dropdown: {
controlPaddingHorizontal: 8,
borderRadiusLG: 10,
borderRadiusSM: 8
},
Popover: {
borderRadiusLG: 10
},
Slider: {
handleLineWidth: 1.5,
handleSize: 15,
handleSizeHover: 15,
dotSize: 7,
railSize: 5,
colorBgElevated: '#ffffff'
},
Modal: {
colorBgElevated: 'var(--modal-background)'
},
Divider: {
colorSplit: 'rgba(128,128,128,0.15)'
}
},
token: {
colorPrimary: colorPrimary,
fontFamily: 'var(--font-family)'
fontFamily: 'var(--font-family)',
colorBgMask: _theme === 'dark' ? 'rgba(0,0,0,0.7)' : 'rgba(255,255,255,0.8)',
motionDurationMid: '100ms'
}
}}>
{children}

View File

@@ -30,6 +30,14 @@ export function useAppInit() {
console.timeEnd('init')
}, [])
useEffect(() => {
window.api.getDataPathFromArgs().then((dataPath) => {
if (dataPath) {
window.navigate('/settings/data', { replace: true })
}
})
}, [])
useUpdateHandler()
useFullScreenNotice()

View File

@@ -1,4 +1,4 @@
import { isWindows } from '@renderer/config/constant'
import { isWin } from '@renderer/config/constant'
import { IpcChannel } from '@shared/IpcChannel'
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
@@ -8,7 +8,7 @@ export function useFullScreenNotice() {
useEffect(() => {
const cleanup = window.electron.ipcRenderer.on(IpcChannel.FullscreenStatusChanged, (_, isFullscreen) => {
if (isWindows && isFullscreen) {
if (isWin && isFullscreen) {
window.message.info({
content: t('common.fullscreen'),
duration: 3,

View File

@@ -169,7 +169,8 @@ export const useKnowledge = (baseId: string) => {
processingStatus: 'pending',
processingProgress: 0,
processingError: '',
uniqueId: undefined
uniqueId: undefined,
updated_at: Date.now()
})
setTimeout(() => KnowledgeQueue.checkAllBases(), 0)
}

View File

@@ -1,4 +1,4 @@
import { isMac, isWindows } from '@renderer/config/constant'
import { isMac, isWin } from '@renderer/config/constant'
import { useAppSelector } from '@renderer/store'
import { orderBy } from 'lodash'
import { useCallback } from 'react'
@@ -72,7 +72,7 @@ export function useShortcutDisplay(key: string) {
case 'ctrl':
return isMac ? '⌃' : 'Ctrl'
case 'command':
return isMac ? '⌘' : isWindows ? 'Win' : 'Super'
return isMac ? '⌘' : isWin ? 'Win' : 'Super'
case 'alt':
return isMac ? '⌥' : 'Alt'
case 'shift':

View File

@@ -1,6 +1,6 @@
import { createSelector } from '@reduxjs/toolkit'
import { RootState, useAppDispatch, useAppSelector } from '@renderer/store'
import { setTagsOrder, updateAssistants } from '@renderer/store/assistants'
import { setTagsOrder, updateTagCollapse } from '@renderer/store/assistants'
import { flatMap, groupBy, uniq } from 'lodash'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
@@ -12,6 +12,8 @@ const selectAssistantsState = (state: RootState) => state.assistants
// 记忆化 tagsOrder 选择器(自动处理默认值)--- 这是一个选择器,用于从 store 中获取 tagsOrder 的值。因为之前的tagsOrder是后面新加的不这样做会报错所以这里需要处理一下默认值
const selectTagsOrder = createSelector([selectAssistantsState], (assistants) => assistants.tagsOrder ?? [])
const selectCollapsedTags = createSelector([selectAssistantsState], (assistants) => assistants.collapsedTags ?? {})
// 定义useTags的返回类型包含所有标签和获取特定标签的助手函数
// 为了不增加新的概念,标签直接作为助手的属性,所以这里的标签是指助手的标签属性
// 但是为了方便管理,增加了一个获取特定标签的助手函数
@@ -20,6 +22,7 @@ export const useTags = () => {
const { t } = useTranslation()
const dispatch = useAppDispatch()
const savedTagsOrder = useAppSelector(selectTagsOrder)
const collapsedTags = useAppSelector(selectCollapsedTags)
// 计算所有标签
const allTags = useMemo(() => {
@@ -38,28 +41,6 @@ export const useTags = () => {
[assistants]
)
const updateTagsOrder = useCallback(
(newOrder: string[]) => {
dispatch(setTagsOrder(newOrder))
updateAssistants(
assistants.map((assistant) => {
if (!assistant.tags || assistant.tags.length === 0) {
return assistant
}
const newTags = [...assistant.tags]
newTags.sort((a, b) => {
return newOrder.indexOf(a) - newOrder.indexOf(b)
})
return {
...assistant,
tags: newTags
}
})
)
},
[assistants, dispatch]
)
const getGroupedAssistants = useMemo(() => {
// 按标签分组,处理多标签的情况
const assistantsByTags = flatMap(assistants, (assistant) => {
@@ -100,10 +81,26 @@ export const useTags = () => {
return grouped
}, [assistants, t, savedTagsOrder])
const updateTagsOrder = useCallback(
(newOrder: string[]) => {
dispatch(setTagsOrder(newOrder))
},
[dispatch]
)
const toggleTagCollapse = useCallback(
(tag: string) => {
dispatch(updateTagCollapse(tag))
},
[dispatch]
)
return {
allTags,
getAssistantsByTag,
getGroupedAssistants,
updateTagsOrder
updateTagsOrder,
collapsedTags,
toggleTagCollapse
}
}

View File

@@ -412,6 +412,7 @@
"search": "Search",
"select": "Select",
"selectedMessages": "Selected {{count}} messages",
"selectedItems": "Selected {{count}} items",
"success": "Success",
"topics": "Topics",
"warning": "Warning",
@@ -864,7 +865,7 @@
"paint_course": "tutorial",
"prompt_placeholder_edit": "Enter your image description, text drawing uses \"double quotes\" to wrap",
"prompt_placeholder_en": "Enter your image description, currently Imagen only supports English prompts",
"proxy_required": "Open the proxy and enable TUN mode to view generated images or copy them to the browser for opening. In the future, domestic direct connection will be supported",
"proxy_required": "Open the proxy and enable \"TUN mode\" to view generated images or copy them to the browser for opening. In the future, domestic direct connection will be supported",
"image_file_required": "Please upload an image first",
"image_file_retry": "Please re-upload an image first",
"image_placeholder": "No image available",
@@ -1087,11 +1088,11 @@
"app_data": "App Data",
"app_data.select": "Modify Directory",
"app_data.select_title": "Change App Data Directory",
"app_data.restart_notice": "The app will need to restart to apply the changes",
"app_data.copy_data_option": "Copy data from original directory to new directory",
"app_data.restart_notice": "The app may need to restart multiple times to apply the changes",
"app_data.copy_data_option": "Copy data, will automatically restart after copying the original directory data to the new directory",
"app_data.copy_time_notice": "Copying data may take a while, do not force quit app",
"app_data.path_changed_without_copy": "Path changed successfully, but data not copied",
"app_data.copying_warning": "Data copying, do not force quit app",
"app_data.path_changed_without_copy": "Path changed successfully",
"app_data.copying_warning": "Data copying, do not force quit app, the app will restart after copied",
"app_data.copying": "Copying data to new location...",
"app_data.copy_success": "Successfully copied data to new location",
"app_data.copy_failed": "Failed to copy data",
@@ -1103,6 +1104,10 @@
"app_data.select_error_root_path": "New path cannot be the root path",
"app_data.select_error_write_permission": "New path does not have write permission",
"app_data.stop_quit_app_reason": "The app is currently migrating data and cannot be exited",
"app_data.select_not_empty_dir": "New path is not empty",
"app_data.select_not_empty_dir_content": "New path is not empty, it will overwrite the data in the new path, there is a risk of data loss and copy failure, continue?",
"app_data.select_error_same_path": "New path is the same as the old path, please select another path",
"app_data.select_error_in_app_path": "New path is the same as the application installation path, please select another path",
"app_knowledge": "Knowledge Base Files",
"app_knowledge.button.delete": "Delete File",
"app_knowledge.remove_all": "Remove Knowledge Base Files",
@@ -1163,7 +1168,7 @@
"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_name.help": "When enabled, the model name will be displayed when exporting to Markdown. 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",
@@ -1388,6 +1393,8 @@
"general.user_name": "User Name",
"general.user_name.placeholder": "Enter your name",
"general.view_webdav_settings": "View WebDAV settings",
"general.spell_check": "Spell Check",
"general.spell_check.languages": "Use spell check for",
"input.auto_translate_with_space": "Quickly translate with 3 spaces",
"input.show_translate_confirm": "Show translation confirmation dialog",
"input.target_language": "Target language",
@@ -1512,6 +1519,7 @@
"registry": "Package Registry",
"registryTooltip": "Choose the registry for package installation to resolve network issues with the default registry.",
"registryDefault": "Default",
"customRegistryPlaceholder": "Enter private registry URL, e.g.: https://npm.company.com",
"not_support": "Model not supported",
"user": "User",
"system": "System",

View File

@@ -412,6 +412,7 @@
"search": "検索",
"select": "選択",
"selectedMessages": "{{count}}件のメッセージを選択しました",
"selectedItems": "{{count}}件の項目を選択しました",
"success": "成功",
"topics": "トピック",
"warning": "警告",
@@ -1085,11 +1086,11 @@
"app_data": "アプリデータ",
"app_data.select": "ディレクトリを変更",
"app_data.select_title": "アプリデータディレクトリの変更",
"app_data.restart_notice": "変更を適用するには、アプリを再起動する必要があります",
"app_data.copy_data_option": "データをコピーする, 開くと元のディレクトリのデータが新しいディレクトリにコピーされます",
"app_data.copy_time_notice": "データコピーには時間がかかります。アプリを強制終了しないでください",
"app_data.path_changed_without_copy": "パスが変更されましたが、データがコピーされていません",
"app_data.copying_warning": "データコピー中、アプリを強制終了しないでください",
"app_data.restart_notice": "変更を適用するには、アプリを再起動する必要があります",
"app_data.copy_data_option": "データをコピーする, 開くと元のディレクトリのデータが新しいディレクトリにコピーされます",
"app_data.copy_time_notice": "データコピーには時間がかかります。アプリを強制終了しないでください",
"app_data.path_changed_without_copy": "パスが変更されました",
"app_data.copying_warning": "データコピー中、アプリを強制終了しないでください。コピーが完了すると、アプリが自動的に再起動します。",
"app_data.copying": "新しい場所にデータをコピーしています...",
"app_data.copy_success": "データを新しい場所に正常にコピーしました",
"app_data.copy_failed": "データのコピーに失敗しました",
@@ -1101,6 +1102,10 @@
"app_data.select_error_root_path": "新しいパスはルートパスにできません",
"app_data.select_error_write_permission": "新しいパスに書き込み権限がありません",
"app_data.stop_quit_app_reason": "アプリは現在データを移行しているため、終了できません",
"app_data.select_not_empty_dir": "新しいパスは空ではありません",
"app_data.select_not_empty_dir_content": "新しいパスは空ではありません。新しいパスのデータが上書きされます。データが失われるリスクがあります。続行しますか?",
"app_data.select_error_same_path": "新しいパスは元のパスと同じです。別のパスを選択してください",
"app_data.select_error_in_app_path": "新しいパスはアプリのインストールパスと同じです。別のパスを選択してください",
"app_knowledge": "知識ベースファイル",
"app_knowledge.button.delete": "ファイルを削除",
"app_knowledge.remove_all": "ナレッジベースファイルを削除",
@@ -1161,7 +1166,7 @@
"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_name.help": "有効にすると、Markdownエクスポート時にモデル名を表示します。注意この設定はNotion、Yuqueなど、Markdownを通じたすべてのエクスポート方法にも影響します。",
"markdown_export.show_model_provider.title": "モデルプロバイダーを表示",
"markdown_export.show_model_provider.help": "Markdownエクスポート時にモデルプロバイダーOpenAI、Geminiなどを表示します。",
"minute_interval_one": "{{count}} 分",
@@ -1383,6 +1388,8 @@
"general.user_name": "ユーザー名",
"general.user_name.placeholder": "ユーザー名を入力",
"general.view_webdav_settings": "WebDAV設定を表示",
"general.spell_check": "スペルチェック",
"general.spell_check.languages": "スペルチェック言語",
"input.auto_translate_with_space": "スペースを3回押して翻訳",
"input.target_language": "目標言語",
"input.target_language.chinese": "簡体字中国語",
@@ -1506,6 +1513,7 @@
"registry": "パッケージ管理レジストリ",
"registryTooltip": "デフォルトのレジストリでネットワークの問題が発生した場合、パッケージインストールに使用するレジストリを選択してください。",
"registryDefault": "デフォルト",
"customRegistryPlaceholder": "プライベート倉庫のアドレスを入力してくださいhttps://npm.company.com",
"not_support": "モデルはサポートされていません",
"user": "ユーザー",
"system": "システム",

View File

@@ -412,6 +412,7 @@
"search": "Поиск",
"select": "Выбрать",
"selectedMessages": "Выбрано {{count}} сообщений",
"selectedItems": "Выбрано {{count}} элементов",
"success": "Успешно",
"topics": "Топики",
"warning": "Предупреждение",
@@ -1085,11 +1086,11 @@
"app_data": "Данные приложения",
"app_data.select": "Изменить директорию",
"app_data.select_title": "Изменить директорию данных приложения",
"app_data.restart_notice": "Для применения изменений потребуется перезапуск приложения",
"app_data.copy_data_option": "Копировать данные из исходной директории в новую директорию",
"app_data.restart_notice": "Для применения изменений может потребоваться несколько перезапусков приложения",
"app_data.copy_data_option": "Копировать данные, будет автоматически перезапущено после копирования данных из исходной директории в новую директорию",
"app_data.copy_time_notice": "Копирование данных из исходной директории займет некоторое время, пожалуйста, будьте терпеливы",
"app_data.path_changed_without_copy": "Путь изменен успешно, но данные не скопированы",
"app_data.copying_warning": "Копирование данных, нельзя взаимодействовать с приложением, не закрывайте приложение",
"app_data.path_changed_without_copy": "Путь изменен успешно",
"app_data.copying_warning": "Копирование данных, нельзя взаимодействовать с приложением, не закрывайте приложение, приложение будет перезапущено после копирования",
"app_data.copying": "Копирование данных в новое место...",
"app_data.copy_success": "Данные успешно скопированы в новое место",
"app_data.copy_failed": "Не удалось скопировать данные",
@@ -1101,6 +1102,10 @@
"app_data.select_error_root_path": "Новый путь не может быть корневым",
"app_data.select_error_write_permission": "Новый путь не имеет разрешения на запись",
"app_data.stop_quit_app_reason": "Приложение в настоящее время перемещает данные и не может быть закрыто",
"app_data.select_not_empty_dir": "Новый путь не пуст",
"app_data.select_not_empty_dir_content": "Новый путь не пуст, он перезапишет данные в новом пути, есть риск потери данных и ошибки копирования, продолжить?",
"app_data.select_error_in_app_path": "Новый путь совпадает с исходным путем, пожалуйста, выберите другой путь",
"app_data.select_error_same_path": "Новый путь совпадает с исходным путем, пожалуйста, выберите другой путь",
"app_knowledge": "Файлы базы знаний",
"app_knowledge.button.delete": "Удалить файл",
"app_knowledge.remove_all": "Удалить файлы базы знаний",
@@ -1161,7 +1166,7 @@
"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_name.help": "Если включено, при экспорте в Markdown будет отображаться имя модели. Примечание: Эта опция также влияет на все методы экспорта через Markdown, такие как Notion, Yuque и т.д.",
"markdown_export.show_model_provider.title": "Показать поставщика модели",
"markdown_export.show_model_provider.help": "Показывать поставщика модели (например, OpenAI, Gemini) при экспорте в Markdown",
"minute_interval_one": "{{count}} минута",
@@ -1383,6 +1388,8 @@
"general.user_name": "Имя пользователя",
"general.user_name.placeholder": "Введите ваше имя",
"general.view_webdav_settings": "Просмотр настроек WebDAV",
"general.spell_check": "Проверка орфографии",
"general.spell_check.languages": "Языки проверки орфографии",
"input.auto_translate_with_space": "Быстрый перевод с помощью 3-х пробелов",
"input.target_language": "Целевой язык",
"input.target_language.chinese": "Китайский упрощенный",
@@ -1506,6 +1513,7 @@
"registry": "Реестр пакетов",
"registryTooltip": "Выберите реестр для установки пакетов, если возникают проблемы с сетью при использовании реестра по умолчанию.",
"registryDefault": "По умолчанию",
"customRegistryPlaceholder": "Введите адрес частного склада, например: https://npm.company.com",
"not_support": "Модель не поддерживается",
"user": "Пользователь",
"system": "Система",

View File

@@ -412,6 +412,7 @@
"search": "搜索",
"select": "选择",
"selectedMessages": "选中 {{count}} 条消息",
"selectedItems": "已选择 {{count}} 项",
"success": "成功",
"topics": "话题",
"warning": "警告",
@@ -863,8 +864,8 @@
"learn_more": "了解更多",
"paint_course": "教程",
"prompt_placeholder_edit": "输入你的图片描述,文本绘制用 \"双引号\" 包裹",
"prompt_placeholder_en": "输入”英文“图片描述,目前 Imagen 仅支持英文提示词",
"proxy_required": "打开代理并开启TUN模式查看生成图片或复制到浏览器打开,后续会支持国内直连",
"prompt_placeholder_en": "输入\"英文\"图片描述,目前 Imagen 仅支持英文提示词",
"proxy_required": "打开代理并开启\"TUN模式\"查看生成图片或复制到浏览器打开,后续会支持国内直连",
"image_file_required": "请先上传图片",
"image_file_retry": "请重新上传图片",
"image_placeholder": "暂无图片",
@@ -960,7 +961,7 @@
"magic_prompt_option_tip": "智能优化放大提示词"
},
"text_desc_required": "请先输入图片描述",
"req_error_text": "运行失败,请重试。提示词避免版权词”和”敏感词哦。",
"req_error_text": "运行失败,请重试。提示词避免\"版权词\"和\"敏感词\"哦。",
"req_error_token": "请检查令牌有效性",
"req_error_no_balance": "请检查令牌有效性",
"image_handle_required": "请先上传图片",
@@ -1087,11 +1088,11 @@
"app_data": "应用数据",
"app_data.select": "修改目录",
"app_data.select_title": "更改应用数据目录",
"app_data.restart_notice": "应用需要重启以应用更改",
"app_data.copy_data_option": "复制数据,启后将原始目录数据复制到新目录",
"app_data.restart_notice": "应用可能会重启多次以应用更改",
"app_data.copy_data_option": "复制数据,会自动重启后将原始目录数据复制到新目录",
"app_data.copy_time_notice": "复制数据将需要一些时间,复制期间不要关闭应用",
"app_data.path_changed_without_copy": "路径已更改成功,但数据未复制",
"app_data.copying_warning": "数据复制中不要强制退出app",
"app_data.path_changed_without_copy": "路径已更改成功",
"app_data.copying_warning": "数据复制中不要强制退出app, 复制完成后会自动重启应用",
"app_data.copying": "正在将数据复制到新位置...",
"app_data.copy_success": "已成功复制数据到新位置",
"app_data.copy_failed": "复制数据失败",
@@ -1103,6 +1104,10 @@
"app_data.select_error_root_path": "新路径不能是根路径",
"app_data.select_error_write_permission": "新路径没有写入权限",
"app_data.stop_quit_app_reason": "应用目前在迁移数据, 不能退出",
"app_data.select_not_empty_dir": "新路径不为空",
"app_data.select_not_empty_dir_content": "新路径不为空,将覆盖新路径中的数据, 有数据丢失和复制失败的风险,是否继续?",
"app_data.select_error_same_path": "新路径与旧路径相同,请选择其他路径",
"app_data.select_error_in_app_path": "新路径与应用安装路径相同,请选择其他路径",
"app_knowledge": "知识库文件",
"app_knowledge.button.delete": "删除文件",
"app_knowledge.remove_all": "删除知识库文件",
@@ -1163,7 +1168,7 @@
"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_name.help": "开启后,导出Markdown时会显示模型名称。注意该项也会影响所有通过Markdown导出的方式如Notion、语雀等。",
"markdown_export.show_model_provider.title": "显示模型供应商",
"markdown_export.show_model_provider.help": "在导出Markdown时显示模型供应商如OpenAI、Gemini等",
"message_title.use_topic_naming.title": "使用话题命名模型为导出的消息创建标题",
@@ -1386,9 +1391,11 @@
"general.restore.button": "恢复",
"general.title": "常规设置",
"general.user_name": "用户名",
"general.user_name.placeholder": "输入用户名",
"general.user_name.placeholder": "输入您的姓名",
"general.view_webdav_settings": "查看 WebDAV 设置",
"input.auto_translate_with_space": "快速敲击3次空格翻译",
"general.spell_check": "拼写检查",
"general.spell_check.languages": "拼写检查语言",
"input.auto_translate_with_space": "3个空格快速翻译",
"input.show_translate_confirm": "显示翻译确认对话框",
"input.target_language": "目标语言",
"input.target_language.chinese": "简体中文",
@@ -1512,6 +1519,7 @@
"registry": "包管理源",
"registryTooltip": "选择用于安装包的源,以解决默认源的网络问题",
"registryDefault": "默认",
"customRegistryPlaceholder": "请输入私有仓库地址,如: https://npm.company.com",
"not_support": "模型不支持",
"user": "用户",
"system": "系统",

View File

@@ -412,6 +412,7 @@
"search": "搜尋",
"select": "選擇",
"selectedMessages": "選中 {{count}} 條訊息",
"selectedItems": "已選擇 {{count}} 項",
"success": "成功",
"topics": "話題",
"warning": "警告",
@@ -1087,11 +1088,11 @@
"app_data": "應用數據",
"app_data.select": "修改目錄",
"app_data.select_title": "變更應用數據目錄",
"app_data.restart_notice": "變更數據目錄後需要重啟應用才能生效",
"app_data.copy_data_option": "複製數據, 啟後將原始目錄數據複製到新目錄",
"app_data.restart_notice": "變更數據目錄後可能需要重啟應用才能生效",
"app_data.copy_data_option": "複製數據, 會自動重啟後將原始目錄數據複製到新目錄",
"app_data.copy_time_notice": "複製數據將需要一些時間,複製期間不要關閉應用",
"app_data.path_changed_without_copy": "路徑已變更成功,但數據未複製",
"app_data.copying_warning": "數據複製中,不要強制退出應用",
"app_data.path_changed_without_copy": "路徑已變更成功",
"app_data.copying_warning": "數據複製中,不要強制退出應用, 複製完成後會自動重啟應用",
"app_data.copying": "正在複製數據到新位置...",
"app_data.copy_success": "成功複製數據到新位置",
"app_data.copy_failed": "複製數據失敗",
@@ -1103,6 +1104,10 @@
"app_data.select_error_root_path": "新路徑不能是根路徑",
"app_data.select_error_write_permission": "新路徑沒有寫入權限",
"app_data.stop_quit_app_reason": "應用目前正在遷移數據,不能退出",
"app_data.select_not_empty_dir": "新路徑不為空",
"app_data.select_not_empty_dir_content": "新路徑不為空,選擇複製將覆蓋新路徑中的數據, 有數據丟失和複製失敗的風險,是否繼續?",
"app_data.select_error_same_path": "新路徑與舊路徑相同,請選擇其他路徑",
"app_data.select_error_in_app_path": "新路徑與應用安裝路徑相同,請選擇其他路徑",
"app_knowledge": "知識庫文件",
"app_knowledge.button.delete": "刪除檔案",
"app_knowledge.remove_all": "刪除知識庫檔案",
@@ -1163,7 +1168,7 @@
"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_name.help": "啟用後,匯出Markdown時會顯示模型名稱。注意該項也會影響所有透過Markdown匯出的方式如Notion、語雀等。",
"markdown_export.show_model_provider.title": "顯示模型供應商",
"markdown_export.show_model_provider.help": "在匯出Markdown時顯示模型供應商如OpenAI、Gemini等",
"minute_interval_one": "{{count}} 分鐘",
@@ -1385,6 +1390,8 @@
"general.user_name": "使用者名稱",
"general.user_name.placeholder": "輸入您的名稱",
"general.view_webdav_settings": "檢視 WebDAV 設定",
"general.spell_check": "拼寫檢查",
"general.spell_check.languages": "拼寫檢查語言",
"input.auto_translate_with_space": "快速敲擊 3 次空格翻譯",
"input.show_translate_confirm": "顯示翻譯確認對話框",
"input.target_language": "目標語言",
@@ -1509,6 +1516,7 @@
"registry": "套件管理源",
"registryTooltip": "選擇用於安裝套件的源,以解決預設源的網路問題",
"registryDefault": "預設",
"customRegistryPlaceholder": "請輸入私有倉庫位址,如: https://npm.company.com",
"not_support": "不支援此模型",
"user": "用戶",
"system": "系統",

View File

@@ -75,8 +75,8 @@ const AgentsPage: FC = () => {
{agent.description && <AgentDescription>{agent.description}</AgentDescription>}
{agent.prompt && (
<AgentPrompt>
<ReactMarkdown className="markdown">{agent.prompt}</ReactMarkdown>{' '}
<AgentPrompt className="markdown">
<ReactMarkdown>{agent.prompt}</ReactMarkdown>
</AgentPrompt>
)}
</Flex>

View File

@@ -14,6 +14,7 @@ import { Agent, KnowledgeBase } from '@renderer/types'
import { getLeadingEmoji, uuid } from '@renderer/utils'
import { Button, Form, FormInstance, Input, Modal, Popover, Select, SelectProps } from 'antd'
import TextArea from 'antd/es/input/TextArea'
import { ChevronDown } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import stringWidth from 'string-width'
@@ -150,7 +151,7 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
maskClosable={false}
afterClose={onClose}
okText={t('agents.add.title')}
width={800}
width={600}
transitionName="animation-move-down"
centered>
<Form
@@ -212,6 +213,7 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
.toLowerCase()
.includes(input.toLowerCase())
}
suffixIcon={<ChevronDown size={16} color="var(--color-border)" />}
/>
</Form.Item>
)}

View File

@@ -4,7 +4,7 @@ import { getDefaultModel } from '@renderer/services/AssistantService'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { Agent } from '@renderer/types'
import { uuid } from '@renderer/utils'
import { Button, Form, Input, Modal, Radio, Space } from 'antd'
import { Button, Flex, Form, Input, Modal, Radio } from 'antd'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -98,7 +98,14 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
title={t('agents.import.title')}
open={open}
onCancel={onCancel}
footer={null}
footer={
<Flex justify="end" gap={8}>
<Button onClick={onCancel}>{t('common.cancel')}</Button>
<Button type="primary" onClick={() => form.submit()} loading={loading}>
{t('agents.import.button')}
</Button>
</Flex>
}
transitionName="animation-move-down"
centered>
<Form form={form} onFinish={onFinish} layout="vertical">
@@ -120,15 +127,6 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
<Button onClick={() => form.submit()}>{t('agents.import.select_file')}</Button>
</Form.Item>
)}
<Form.Item>
<Space>
<Button onClick={onCancel}>{t('common.cancel')}</Button>
<Button type="primary" onClick={() => form.submit()} loading={loading}>
{t('agents.import.button')}
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
)

View File

@@ -1,3 +1,5 @@
import { DeleteOutlined, ExclamationCircleOutlined } from '@ant-design/icons'
import { handleDelete } from '@renderer/services/FileAction'
import FileManager from '@renderer/services/FileManager'
import { FileType, FileTypes } from '@renderer/types'
import { formatFileSize } from '@renderer/utils'
@@ -48,6 +50,24 @@ const FileList: React.FC<FileItemProps> = ({ id, list, files }) => {
<ImageInfo>
<div>{formatFileSize(file.size)}</div>
</ImageInfo>
<DeleteButton
title={t('files.delete.title')}
onClick={(e) => {
e.stopPropagation()
window.modal.confirm({
title: t('files.delete.title'),
content: t('files.delete.content'),
okText: t('common.confirm'),
cancelText: t('common.cancel'),
centered: true,
onOk: () => {
handleDelete(file.id, t)
},
icon: <ExclamationCircleOutlined style={{ color: 'red' }} />
})
}}>
<DeleteOutlined />
</DeleteButton>
</ImageWrapper>
</Col>
))}
@@ -159,4 +179,26 @@ const ImageInfo = styled.div`
}
`
const DeleteButton = styled.div`
position: absolute;
top: 8px;
right: 8px;
width: 24px;
height: 24px;
border-radius: 50%;
background-color: rgba(0, 0, 0, 0.6);
color: white;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
opacity: 0;
transition: opacity 0.3s ease;
z-index: 1;
&:hover {
background-color: rgba(255, 0, 0, 0.8);
}
`
export default memo(FileList)

View File

@@ -7,13 +7,10 @@ import {
} from '@ant-design/icons'
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
import ListItem from '@renderer/components/ListItem'
import TextEditPopup from '@renderer/components/Popups/TextEditPopup'
import Logger from '@renderer/config/logger'
import db from '@renderer/databases'
import { handleDelete, handleRename, sortFiles, tempFilesSort } from '@renderer/services/FileAction'
import FileManager from '@renderer/services/FileManager'
import store from '@renderer/store'
import { FileType, FileTypes } from '@renderer/types'
import { Message } from '@renderer/types/newMessage'
import { formatFileSize } from '@renderer/utils'
import { Button, Empty, Flex, Popconfirm } from 'antd'
import dayjs from 'dayjs'
@@ -34,34 +31,6 @@ const FilesPage: FC = () => {
const [sortField, setSortField] = useState<SortField>('created_at')
const [sortOrder, setSortOrder] = useState<SortOrder>('desc')
const tempFilesSort = (files: FileType[]) => {
return files.sort((a, b) => {
const aIsTemp = a.origin_name.startsWith('temp_file')
const bIsTemp = b.origin_name.startsWith('temp_file')
if (aIsTemp && !bIsTemp) return 1
if (!aIsTemp && bIsTemp) return -1
return 0
})
}
const sortFiles = (files: FileType[]) => {
return [...files].sort((a, b) => {
let comparison = 0
switch (sortField) {
case 'created_at':
comparison = dayjs(a.created_at).unix() - dayjs(b.created_at).unix()
break
case 'size':
comparison = a.size - b.size
break
case 'name':
comparison = a.origin_name.localeCompare(b.origin_name)
break
}
return sortOrder === 'asc' ? comparison : -comparison
})
}
const files = useLiveQuery<FileType[]>(() => {
if (fileType === 'all') {
return db.files.orderBy('count').toArray().then(tempFilesSort)
@@ -69,106 +38,7 @@ const FilesPage: FC = () => {
return db.files.where('type').equals(fileType).sortBy('count').then(tempFilesSort)
}, [fileType])
const sortedFiles = files ? sortFiles(files) : []
const handleDelete = async (fileId: string) => {
const file = await FileManager.getFile(fileId)
if (!file) return
const paintings = await store.getState().paintings.paintings
const paintingsFiles = paintings.flatMap((p) => p.files)
if (paintingsFiles.some((p) => p.id === fileId)) {
window.modal.warning({ content: t('files.delete.paintings.warning'), centered: true })
return
}
if (file) {
await FileManager.deleteFile(fileId, true)
}
const relatedBlocks = await db.message_blocks.where('file.id').equals(fileId).toArray()
const blockIdsToDelete = relatedBlocks.map((block) => block.id)
const blocksByMessageId: Record<string, string[]> = {}
for (const block of relatedBlocks) {
if (!blocksByMessageId[block.messageId]) {
blocksByMessageId[block.messageId] = []
}
blocksByMessageId[block.messageId].push(block.id)
}
try {
const affectedMessageIds = [...new Set(relatedBlocks.map((b) => b.messageId))]
if (affectedMessageIds.length === 0 && blockIdsToDelete.length > 0) {
// This case should ideally not happen if relatedBlocks were found,
// but handle it just in case: only delete blocks.
await db.message_blocks.bulkDelete(blockIdsToDelete)
Logger.log(
`Deleted ${blockIdsToDelete.length} blocks related to file ${fileId}. No associated messages found (unexpected).`
)
return
}
await db.transaction('rw', db.topics, db.message_blocks, async () => {
// Fetch all topics (potential performance bottleneck if many topics)
const allTopics = await db.topics.toArray()
const topicsToUpdate: Record<string, { messages: Message[] }> = {} // Store updates keyed by topicId
for (const topic of allTopics) {
let topicModified = false
// Ensure topic.messages exists and is an array before mapping
const currentMessages = Array.isArray(topic.messages) ? topic.messages : []
const updatedMessages = currentMessages.map((message) => {
// Check if this message is affected
if (affectedMessageIds.includes(message.id)) {
// Ensure message.blocks exists and is an array
const currentBlocks = Array.isArray(message.blocks) ? message.blocks : []
const originalBlockCount = currentBlocks.length
// Filter out the blocks marked for deletion
const newBlocks = currentBlocks.filter((blockId) => !blockIdsToDelete.includes(blockId))
if (newBlocks.length < originalBlockCount) {
topicModified = true
return { ...message, blocks: newBlocks } // Return updated message
}
}
return message // Return original message
})
if (topicModified) {
// Store the update for this topic
topicsToUpdate[topic.id] = { messages: updatedMessages }
}
}
// Apply updates to topics
const updatePromises = Object.entries(topicsToUpdate).map(([topicId, updateData]) =>
db.topics.update(topicId, updateData)
)
await Promise.all(updatePromises)
// Finally, delete the MessageBlocks
await db.message_blocks.bulkDelete(blockIdsToDelete)
})
Logger.log(`Deleted ${blockIdsToDelete.length} blocks and updated relevant topic messages for file ${fileId}.`)
} catch (error) {
Logger.error(`Error updating topics or deleting blocks for file ${fileId}:`, error)
window.modal.error({ content: t('files.delete.db_error'), centered: true }) // 提示数据库操作失败
// Consider whether to attempt to restore the physical file (usually difficult)
}
}
const handleRename = async (fileId: string) => {
const file = await FileManager.getFile(fileId)
if (file) {
const newName = await TextEditPopup.show({ text: file.origin_name })
if (newName) {
FileManager.updateFile({ ...file, origin_name: newName })
}
}
}
const sortedFiles = files ? sortFiles(files, sortField, sortOrder) : []
const dataSource = sortedFiles?.map((file) => {
return {
@@ -189,7 +59,7 @@ const FilesPage: FC = () => {
description={t('files.delete.content')}
okText={t('common.confirm')}
cancelText={t('common.cancel')}
onConfirm={() => handleDelete(file.id)}
onConfirm={() => handleDelete(file.id, t)}
icon={<ExclamationCircleOutlined style={{ color: 'red' }} />}>
<Button type="text" danger icon={<DeleteOutlined />} />
</Popconfirm>
@@ -310,7 +180,6 @@ const SideNav = styled.div`
background-color: var(--color-background-soft);
color: var(--color-primary);
border: 0.5px solid var(--color-border);
color: var(--color-text);
}
}
`

View File

@@ -1,11 +1,11 @@
import { ArrowLeftOutlined, EnterOutlined } from '@ant-design/icons'
import { HStack } from '@renderer/components/Layout'
import { useAppDispatch } from '@renderer/store'
import { loadTopicMessagesThunk } from '@renderer/store/thunk/messageThunk'
import { Topic } from '@renderer/types'
import type { Message } from '@renderer/types/newMessage'
import { Input, InputRef } from 'antd'
import { Divider, Input, InputRef } from 'antd'
import { last } from 'lodash'
import { Search } from 'lucide-react'
import { ChevronLeft, CornerDownLeft, Search } from 'lucide-react'
import { FC, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@@ -73,26 +73,35 @@ const TopicsPage: FC = () => {
return (
<Container>
<Header>
{stack.length > 1 && (
<HeaderLeft>
<MenuIcon onClick={goBack}>
<ArrowLeftOutlined />
</MenuIcon>
</HeaderLeft>
)}
<SearchInput
placeholder={t('history.search.placeholder')}
type="search"
value={search}
autoFocus
allowClear
<HStack style={{ padding: '0 12px', marginTop: 8 }}>
<Input
prefix={
stack.length > 1 ? (
<SearchIcon className="back-icon" onClick={goBack}>
<ChevronLeft size={16} />
</SearchIcon>
) : (
<SearchIcon>
<Search size={15} />
</SearchIcon>
)
}
suffix={search.length >= 2 ? <CornerDownLeft size={16} /> : null}
ref={inputRef}
placeholder={t('history.search.placeholder')}
value={search}
onChange={(e) => setSearch(e.target.value.trimStart())}
suffix={search.length >= 2 ? <EnterOutlined /> : <Search size={16} />}
allowClear
autoFocus
spellCheck={false}
style={{ paddingLeft: 0 }}
variant="borderless"
size="middle"
onPressEnter={onSearch}
/>
</Header>
</HStack>
<Divider style={{ margin: 0, marginTop: 4, borderBlockStartWidth: 0.5 }} />
<TopicsHistory
keywords={search}
onClick={onTopicClick as any}
@@ -118,50 +127,23 @@ const Container = styled.div`
height: 100%;
`
const Header = styled.div`
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding: 12px 0;
width: 100%;
position: relative;
background-color: var(--color-background-mute);
border-top-left-radius: 8px;
border-top-right-radius: 8px;
border-bottom: 0.5px solid var(--color-frame-border);
`
const HeaderLeft = styled.div`
display: flex;
flex-direction: row;
align-items: center;
position: absolute;
top: 12px;
left: 15px;
`
const MenuIcon = styled.div`
cursor: pointer;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
width: 33px;
height: 33px;
const SearchIcon = styled.div`
width: 32px;
height: 32px;
border-radius: 50%;
&:hover {
background-color: var(--color-background);
.anticon {
color: var(--color-text-1);
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
background-color: var(--color-background-soft);
margin-right: 2px;
&.back-icon {
cursor: pointer;
transition: background-color 0.2s;
&:hover {
background-color: var(--color-background-mute);
}
}
`
const SearchInput = styled(Input)`
border-radius: 30px;
width: 800px;
height: 36px;
`
export default TopicsPage

View File

@@ -1,7 +1,5 @@
import { ArrowRightOutlined } from '@ant-design/icons'
import { HStack } from '@renderer/components/Layout'
import { MessageEditingProvider } from '@renderer/context/MessageEditingContext'
import { useSettings } from '@renderer/hooks/useSettings'
import { getTopicById } from '@renderer/hooks/useTopic'
import { default as MessageItem } from '@renderer/pages/home/Messages/Message'
import { locateToMessage } from '@renderer/services/MessagesService'
@@ -10,6 +8,7 @@ import { Topic } from '@renderer/types'
import type { Message } from '@renderer/types/newMessage'
import { runAsyncFunction } from '@renderer/utils'
import { Button } from 'antd'
import { Forward } from 'lucide-react'
import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@@ -20,7 +19,6 @@ interface Props extends React.HTMLAttributes<HTMLDivElement> {
const SearchMessage: FC<Props> = ({ message, ...props }) => {
const navigate = NavigationService.navigate!
const { messageStyle } = useSettings()
const { t } = useTranslation()
const [topic, setTopic] = useState<Topic | null>(null)
@@ -43,18 +41,18 @@ const SearchMessage: FC<Props> = ({ message, ...props }) => {
return (
<MessageEditingProvider>
<MessagesContainer {...props} className={messageStyle}>
<ContainerWrapper style={{ paddingTop: 20, paddingBottom: 20, position: 'relative' }}>
<MessagesContainer {...props}>
<ContainerWrapper>
<MessageItem message={message} topic={topic} hideMenuBar={true} />
<Button
type="text"
size="middle"
style={{ color: 'var(--color-text-3)', position: 'absolute', right: 0, top: 10 }}
style={{ color: 'var(--color-text-3)', position: 'absolute', right: 16, top: 16 }}
onClick={() => locateToMessage(navigate, message)}
icon={<ArrowRightOutlined />}
icon={<Forward size={16} />}
/>
<HStack mt="10px" justifyContent="center">
<Button onClick={() => locateToMessage(navigate, message)} icon={<ArrowRightOutlined />}>
<Button onClick={() => locateToMessage(navigate, message)} icon={<Forward size={16} />}>
{t('history.locate.message')}
</Button>
</HStack>
@@ -74,12 +72,11 @@ const MessagesContainer = styled.div`
`
const ContainerWrapper = styled.div`
width: 800px;
width: 100%;
display: flex;
flex-direction: column;
.message {
padding: 0;
}
padding: 16px;
position: relative;
`
export default SearchMessage

View File

@@ -151,7 +151,8 @@ const Container = styled.div`
`
const ContainerWrapper = styled.div`
width: 800px;
width: 100%;
padding: 0 16px;
display: flex;
flex-direction: column;
`

View File

@@ -1,9 +1,8 @@
import { ArrowRightOutlined, MessageOutlined } from '@ant-design/icons'
import { MessageOutlined } from '@ant-design/icons'
import { HStack } from '@renderer/components/Layout'
import SearchPopup from '@renderer/components/Popups/SearchPopup'
import { MessageEditingProvider } from '@renderer/context/MessageEditingContext'
import useScrollPosition from '@renderer/hooks/useScrollPosition'
import { useSettings } from '@renderer/hooks/useSettings'
import { getAssistantById } from '@renderer/services/AssistantService'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { isGenerating, locateToMessage } from '@renderer/services/MessagesService'
@@ -13,6 +12,7 @@ import { loadTopicMessagesThunk } from '@renderer/store/thunk/messageThunk'
import { Topic } from '@renderer/types'
import { Button, Divider, Empty } from 'antd'
import { t } from 'i18next'
import { Forward } from 'lucide-react'
import { FC, useEffect } from 'react'
import styled from 'styled-components'
@@ -25,7 +25,6 @@ interface Props extends React.HTMLAttributes<HTMLDivElement> {
const TopicMessages: FC<Props> = ({ topic, ...props }) => {
const navigate = NavigationService.navigate!
const { handleScroll, containerRef } = useScrollPosition('TopicMessages')
const { messageStyle } = useSettings()
const dispatch = useAppDispatch()
useEffect(() => {
@@ -48,8 +47,8 @@ const TopicMessages: FC<Props> = ({ topic, ...props }) => {
return (
<MessageEditingProvider>
<MessagesContainer {...props} ref={containerRef} onScroll={handleScroll} className={messageStyle}>
<ContainerWrapper style={{ paddingTop: 30, paddingBottom: 30 }}>
<MessagesContainer {...props} ref={containerRef} onScroll={handleScroll}>
<ContainerWrapper>
{topic?.messages.map((message) => (
<div key={message.id} style={{ position: 'relative' }}>
<MessageItem message={message} topic={topic} hideMenuBar={true} />
@@ -58,7 +57,7 @@ const TopicMessages: FC<Props> = ({ topic, ...props }) => {
size="middle"
style={{ color: 'var(--color-text-3)', position: 'absolute', right: 0, top: 5 }}
onClick={() => locateToMessage(navigate, message)}
icon={<ArrowRightOutlined />}
icon={<Forward size={16} />}
/>
<Divider style={{ margin: '8px auto 15px' }} variant="dashed" />
</div>
@@ -86,12 +85,10 @@ const MessagesContainer = styled.div`
`
const ContainerWrapper = styled.div`
width: 800px;
width: 100%;
padding: 16px;
display: flex;
flex-direction: column;
.message {
padding: 0;
}
`
export default TopicMessages

View File

@@ -78,7 +78,8 @@ const TopicsHistory: React.FC<Props> = ({ keywords, onClick, onSearch, ...props
}
const ContainerWrapper = styled.div`
width: 800px;
width: 100%;
padding: 0 16px;
display: flex;
flex-direction: column;
`

View File

@@ -7,6 +7,7 @@ import { useSettings } from '@renderer/hooks/useSettings'
import { useShortcut } from '@renderer/hooks/useShortcuts'
import { useShowTopics } from '@renderer/hooks/useStore'
import { Assistant, Topic } from '@renderer/types'
import { classNames } from '@renderer/utils'
import { Flex } from 'antd'
import { debounce } from 'lodash'
import React, { FC, useMemo, useState } from 'react'
@@ -106,7 +107,7 @@ const Chat: FC<Props> = (props) => {
}
return (
<Container id="chat" className={messageStyle}>
<Container id="chat" className={classNames([messageStyle, { 'multi-select-mode': isMultiSelectMode }])}>
<Main ref={mainRef} id="chat-main" vertical flex={1} justify="space-between" style={{ maxWidth }}>
<ContentSearch
ref={contentSearchRef}

View File

@@ -77,7 +77,8 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
showInputEstimatedTokens,
autoTranslateWithSpace,
enableQuickPanelTriggers,
enableBackspaceDeleteModel
enableBackspaceDeleteModel,
enableSpellCheck
} = useSettings()
const [expended, setExpend] = useState(false)
const [estimateTokenCount, setEstimateTokenCount] = useState(0)
@@ -138,17 +139,21 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
_text = text
_files = files
const resizeTextArea = useCallback(() => {
const textArea = textareaRef.current?.resizableTextArea?.textArea
if (textArea) {
// 如果已经手动设置了高度,则不自动调整
if (textareaHeight) {
return
const resizeTextArea = useCallback(
(force: boolean = false) => {
const textArea = textareaRef.current?.resizableTextArea?.textArea
if (textArea) {
// 如果已经手动设置了高度,则不自动调整
if (textareaHeight && !force) {
return
}
if (textArea?.scrollHeight) {
textArea.style.height = Math.min(textArea.scrollHeight, 400) + 'px'
}
}
textArea.style.height = 'auto'
textArea.style.height = textArea?.scrollHeight > 400 ? '400px' : `${textArea?.scrollHeight}px`
}
}, [textareaHeight])
},
[textareaHeight]
)
const sendMessage = useCallback(async () => {
if (inputEmpty || loading) {
@@ -748,13 +753,13 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
}
return (
<Container
onDragOver={handleDragOver}
onDrop={handleDrop}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
className="inputbar">
<NarrowLayout style={{ width: '100%' }}>
<NarrowLayout style={{ width: '100%' }}>
<Container
onDragOver={handleDragOver}
onDrop={handleDrop}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
className="inputbar">
<QuickPanelView setInputText={setText} />
<InputBarContainer
id="inputbar"
@@ -780,14 +785,13 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
: t('chat.input.placeholder', { key: getSendMessageShortcutLabel(sendMessageShortcut) })
}
autoFocus
contextMenu="true"
variant="borderless"
spellCheck={false}
spellCheck={enableSpellCheck}
rows={2}
ref={textareaRef}
style={{
fontSize,
minHeight: textareaHeight ? `${textareaHeight}px` : undefined
minHeight: textareaHeight ? `${textareaHeight}px` : '30px'
}}
styles={{ textarea: TextareaStyle }}
onFocus={(e: React.FocusEvent<HTMLTextAreaElement>) => {
@@ -851,8 +855,8 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
</ToolbarMenu>
</Toolbar>
</InputBarContainer>
</NarrowLayout>
</Container>
</Container>
</NarrowLayout>
)
}
@@ -887,16 +891,15 @@ const Container = styled.div`
flex-direction: column;
position: relative;
z-index: 2;
padding: 0 16px 16px 16px;
`
const InputBarContainer = styled.div`
border: 0.5px solid var(--color-border);
transition: all 0.2s ease;
position: relative;
margin: 14px 20px;
margin-top: 0;
border-radius: 15px;
padding-top: 6px; // 为拖动手柄留出空间
padding-top: 8px; // 为拖动手柄留出空间
background-color: var(--color-background-opacity);
&.file-dragging {
@@ -919,7 +922,7 @@ const InputBarContainer = styled.div`
const TextareaStyle: CSSProperties = {
paddingLeft: 0,
padding: '6px 15px 8px' // 减小顶部padding
padding: '6px 15px 0px' // 减小顶部padding
}
const Textarea = styled(TextArea)`
@@ -934,16 +937,17 @@ const Textarea = styled(TextArea)`
&.ant-input {
line-height: 1.4;
}
&::-webkit-scrollbar {
width: 3px;
}
`
const Toolbar = styled.div`
display: flex;
flex-direction: row;
justify-content: space-between;
padding: 0 8px;
padding-bottom: 0;
margin-bottom: 4px;
height: 30px;
padding: 5px 8px;
height: 40px;
gap: 16px;
position: relative;
z-index: 2;

View File

@@ -45,7 +45,7 @@ const TokenCount: FC<Props> = ({ estimateTokenCount, inputTokenCount, contextCou
return (
<Container>
<Popover content={PopoverContent}>
<Popover content={PopoverContent} arrow={false}>
<MenuOutlined /> {contextCount.current} / {formatMaxCount(contextCount.max)}
<Divider type="vertical" style={{ marginTop: 0, marginLeft: 5, marginRight: 5 }} />
<ArrowUpOutlined />

View File

@@ -54,9 +54,10 @@ const CitationTooltip: React.FC<CitationTooltipProps> = ({ children, citation })
return (
<Tooltip
arrow={false}
overlay={tooltipContent}
placement="top"
color="var(--color-background-mute)"
color="var(--color-background)"
styles={{
body: {
border: '1px solid var(--color-border)',

View File

@@ -27,7 +27,7 @@ const CodeBlock: React.FC<Props> = ({ children, className, id, onSave }) => {
{children}
</CodeBlockView>
) : (
<code className={className} style={{ textWrap: 'wrap' }}>
<code className={className} style={{ textWrap: 'wrap', fontSize: '95%', padding: '2px 4px' }}>
{children}
</code>
)

View File

@@ -8,8 +8,8 @@ import { useSettings } from '@renderer/hooks/useSettings'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import type { MainTextMessageBlock, ThinkingMessageBlock, TranslationMessageBlock } from '@renderer/types/newMessage'
import { parseJSON } from '@renderer/utils'
import { escapeBrackets, removeSvgEmptyLines } from '@renderer/utils/formats'
import { findCitationInChildren, getCodeBlockId } from '@renderer/utils/markdown'
import { removeSvgEmptyLines } from '@renderer/utils/formats'
import { findCitationInChildren, getCodeBlockId, processLatexBrackets } from '@renderer/utils/markdown'
import { isEmpty } from 'lodash'
import { type FC, memo, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
@@ -24,6 +24,7 @@ import remarkMath from 'remark-math'
import CodeBlock from './CodeBlock'
import Link from './Link'
import remarkDisableConstructs from './plugins/remarkDisableConstructs'
import Table from './Table'
const ALLOWED_ELEMENTS =
@@ -40,7 +41,7 @@ const Markdown: FC<Props> = ({ block }) => {
const { mathEngine } = useSettings()
const remarkPlugins = useMemo(() => {
const plugins = [remarkGfm, remarkCjkFriendly]
const plugins = [remarkGfm, remarkCjkFriendly, remarkDisableConstructs(['codeIndented'])]
if (mathEngine !== 'none') {
plugins.push(remarkMath)
}
@@ -51,7 +52,7 @@ const Markdown: FC<Props> = ({ block }) => {
const empty = isEmpty(block.content)
const paused = block.status === 'paused'
const content = empty && paused ? t('message.chat.completion.paused') : block.content
return removeSvgEmptyLines(escapeBrackets(content))
return removeSvgEmptyLines(processLatexBrackets(content))
}, [block, t])
const rehypePlugins = useMemo(() => {
@@ -105,20 +106,21 @@ const Markdown: FC<Props> = ({ block }) => {
}, [])
return (
<ReactMarkdown
rehypePlugins={rehypePlugins}
remarkPlugins={remarkPlugins}
className="markdown"
components={components}
disallowedElements={DISALLOWED_ELEMENTS}
urlTransform={urlTransform}
remarkRehypeOptions={{
footnoteLabel: t('common.footnotes'),
footnoteLabelTagName: 'h4',
footnoteBackContent: ' '
}}>
{messageContent}
</ReactMarkdown>
<div className="markdown">
<ReactMarkdown
rehypePlugins={rehypePlugins}
remarkPlugins={remarkPlugins}
components={components}
disallowedElements={DISALLOWED_ELEMENTS}
urlTransform={urlTransform}
remarkRehypeOptions={{
footnoteLabel: t('common.footnotes'),
footnoteLabelTagName: 'h4',
footnoteBackContent: ' '
}}>
{messageContent}
</ReactMarkdown>
</div>
)
}

View File

@@ -93,7 +93,7 @@ describe('CitationTooltip', () => {
const tooltip = screen.getByTestId('tooltip-wrapper')
expect(tooltip).toHaveAttribute('data-placement', 'top')
expect(tooltip).toHaveAttribute('data-color', 'var(--color-background-mute)')
expect(tooltip).toHaveAttribute('data-color', 'var(--color-background)')
const styles = JSON.parse(tooltip.getAttribute('data-styles') || '{}')
expect(styles.body).toEqual({

View File

@@ -42,13 +42,13 @@ vi.mock('@renderer/utils', () => ({
}))
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')
getCodeBlockId: vi.fn(() => 'code-block-1'),
processLatexBrackets: vi.fn((str) => str)
}))
// Mock components with more realistic behavior
@@ -103,6 +103,12 @@ 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 custom plugins
vi.mock('../plugins/remarkDisableConstructs', () => ({
__esModule: true,
default: vi.fn()
}))
// Mock ReactMarkdown with realistic rendering
vi.mock('react-markdown', () => ({
__esModule: true,
@@ -162,12 +168,16 @@ describe('Markdown', () => {
describe('rendering', () => {
it('should render markdown content with correct structure', () => {
const block = createMainTextBlock({ content: 'Test content' })
render(<Markdown block={block} />)
const { container } = render(<Markdown block={block} />)
const markdown = screen.getByTestId('markdown-content')
expect(markdown).toBeInTheDocument()
expect(markdown).toHaveClass('markdown')
expect(markdown).toHaveTextContent('Test content')
// Check that the outer container has the markdown class
const markdownContainer = container.querySelector('.markdown')
expect(markdownContainer).toBeInTheDocument()
// Check that the markdown content is rendered inside
const markdownContent = screen.getByTestId('markdown-content')
expect(markdownContent).toBeInTheDocument()
expect(markdownContent).toHaveTextContent('Test content')
})
it('should handle empty content gracefully', () => {
@@ -202,16 +212,6 @@ describe('Markdown', () => {
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()

View File

@@ -47,7 +47,7 @@ exports[`CitationTooltip > basic rendering > should match snapshot 1`] = `
}
<div
data-color="var(--color-background-mute)"
data-color="var(--color-background)"
data-placement="top"
data-styles="{"body":{"border":"1px solid var(--color-border)","padding":"12px","borderRadius":"8px"}}"
data-testid="tooltip-wrapper"

View File

@@ -3,55 +3,58 @@
exports[`Markdown > rendering > should match snapshot 1`] = `
<div
class="markdown"
data-testid="markdown-content"
>
# Test Markdown
<div
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"
<span
data-testid="has-link-component"
>
<code>
test code
</code>
<button
type="button"
>
Save
</button>
</div>
</div>
<div
data-testid="has-table-component"
>
link
</span>
<div
data-block-id="test-block-1"
data-testid="table-component"
data-testid="has-code-component"
>
<table>
test table
</table>
<button
data-testid="copy-table-button"
type="button"
<div
data-id="code-block-1"
data-testid="code-block"
>
Copy Table
</button>
<code>
test code
</code>
<button
type="button"
>
Save
</button>
</div>
</div>
<div
data-testid="has-table-component"
>
<div
data-block-id="test-block-1"
data-testid="table-component"
>
<table>
test table
</table>
<button
data-testid="copy-table-button"
type="button"
>
Copy Table
</button>
</div>
</div>
<span
data-testid="has-img-component"
>
img
</span>
</div>
<span
data-testid="has-img-component"
>
img
</span>
</div>
`;

View File

@@ -0,0 +1,155 @@
import { render } from '@testing-library/react'
import ReactMarkdown from 'react-markdown'
import { describe, expect, it } from 'vitest'
import remarkDisableConstructs from '../remarkDisableConstructs'
describe('disableIndentedCode', () => {
const renderMarkdown = (markdown: string, constructs: string[] = ['codeIndented']) => {
return render(<ReactMarkdown remarkPlugins={[remarkDisableConstructs(constructs)]}>{markdown}</ReactMarkdown>)
}
describe('normal path', () => {
it('should disable indented code blocks while preserving other code types', () => {
const markdown = `
# Test Document
Regular paragraph.
This should be treated as a regular paragraph, not code
\`inline code\` should work
\`\`\`javascript
// This fenced code should work
console.log('hello')
\`\`\`
Another paragraph.
`
const { container } = renderMarkdown(markdown)
// Verify only fenced code (pre element)
expect(container.querySelectorAll('pre')).toHaveLength(1)
// Verify inline code
const inlineCode = container.querySelector('code:not(pre code)')
expect(inlineCode?.textContent).toBe('inline code')
// Verify fenced code
const fencedCode = container.querySelector('pre code')
expect(fencedCode?.textContent).toContain('console.log')
// Verify indented content becomes paragraph
const paragraphs = container.querySelectorAll('p')
const indentedParagraph = Array.from(paragraphs).find((p) =>
p.textContent?.includes('This should be treated as a regular paragraph')
)
expect(indentedParagraph).toBeTruthy()
})
it('should handle indented code in nested structures', () => {
const markdown = `
> Blockquote with \`inline code\`
>
> This indented code in blockquote should become text
1. List item
This indented code in list should become text
* Bullet list
* Nested item
More indented code to convert
`
const { container } = renderMarkdown(markdown)
// Verify no indented code blocks
expect(container.querySelectorAll('pre')).toHaveLength(0)
// Verify blockquote exists and contains converted text
const blockquote = container.querySelector('blockquote')
expect(blockquote?.textContent).toContain('This indented code in blockquote should become text')
// Verify lists exist
const lists = container.querySelectorAll('ul, ol')
expect(lists.length).toBeGreaterThan(0)
})
it('should preserve other markdown elements when disabling constructs', () => {
const markdown = `
# Heading
Paragraph text.
Indented code to disable
[Link text](https://example.com)
\`\`\`
Fenced code to keep
\`\`\`
`
const { container } = renderMarkdown(markdown)
// Verify heading
expect(container.querySelector('h1')?.textContent).toBe('Heading')
// Verify link
const link = container.querySelector('a')
expect(link?.textContent).toBe('Link text')
expect(link?.getAttribute('href')).toBe('https://example.com')
// Verify only fenced code
expect(container.querySelectorAll('pre')).toHaveLength(1)
})
})
describe('edge cases', () => {
it('should not affect markdown when no constructs are disabled', () => {
const markdown = `
This is indented code
\`inline code\`
\`\`\`javascript
console.log('fenced')
\`\`\`
`
const { container } = renderMarkdown(markdown, [])
// Should have indented code and fenced code
expect(container.querySelectorAll('pre')).toHaveLength(2)
})
it('should handle markdown with only inline and fenced code', () => {
const markdown = `
Regular paragraph with \`inline code\`.
\`\`\`typescript
function test(): string {
return "hello";
}
\`\`\`
`
const { container } = renderMarkdown(markdown)
// Should have only fenced code
expect(container.querySelectorAll('pre')).toHaveLength(1)
// Verify fenced code content
const fencedCode = container.querySelector('pre code')
expect(fencedCode?.textContent).toContain('function test()')
// Verify inline code
const inlineCode = container.querySelector('code:not(pre code)')
expect(inlineCode?.textContent).toBe('inline code')
})
})
})

View File

@@ -0,0 +1,107 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import remarkDisableConstructs from '../remarkDisableConstructs'
describe('remarkDisableConstructs', () => {
let mockData: any
let mockThis: any
beforeEach(() => {
mockData = {}
mockThis = {
data: vi.fn().mockReturnValue(mockData)
}
})
describe('plugin creation', () => {
it('should return a function when called', () => {
const plugin = remarkDisableConstructs(['codeIndented'])
expect(typeof plugin).toBe('function')
})
})
describe('normal path', () => {
it('should add micromarkExtensions for single construct', () => {
const plugin = remarkDisableConstructs(['codeIndented'])
plugin.call(mockThis as any)
expect(mockData).toHaveProperty('micromarkExtensions')
expect(Array.isArray(mockData.micromarkExtensions)).toBe(true)
expect(mockData.micromarkExtensions).toHaveLength(1)
expect(mockData.micromarkExtensions[0]).toEqual({
disable: {
null: ['codeIndented']
}
})
})
it('should handle multiple constructs', () => {
const constructs = ['codeIndented', 'autolink', 'htmlFlow']
const plugin = remarkDisableConstructs(constructs)
plugin.call(mockThis as any)
expect(mockData.micromarkExtensions[0]).toEqual({
disable: {
null: constructs
}
})
})
})
describe('edge cases', () => {
it('should not add extensions when empty array is provided', () => {
const plugin = remarkDisableConstructs([])
plugin.call(mockThis as any)
expect(mockData).not.toHaveProperty('micromarkExtensions')
})
it('should not add extensions when undefined is passed', () => {
const plugin = remarkDisableConstructs()
plugin.call(mockThis as any)
expect(mockData).not.toHaveProperty('micromarkExtensions')
})
it('should handle empty construct names', () => {
const plugin = remarkDisableConstructs(['', ' '])
plugin.call(mockThis as any)
expect(mockData.micromarkExtensions[0]).toEqual({
disable: {
null: ['', ' ']
}
})
})
it('should handle mixed valid and empty construct names', () => {
const plugin = remarkDisableConstructs(['codeIndented', '', 'autolink'])
plugin.call(mockThis as any)
expect(mockData.micromarkExtensions[0]).toEqual({
disable: {
null: ['codeIndented', '', 'autolink']
}
})
})
})
describe('interaction with existing data', () => {
it('should append to existing micromarkExtensions', () => {
const existingExtension = { some: 'extension' }
mockData.micromarkExtensions = [existingExtension]
const plugin = remarkDisableConstructs(['codeIndented'])
plugin.call(mockThis as any)
expect(mockData.micromarkExtensions).toHaveLength(2)
expect(mockData.micromarkExtensions[0]).toBe(existingExtension)
expect(mockData.micromarkExtensions[1]).toEqual({
disable: {
null: ['codeIndented']
}
})
})
})
})

View File

@@ -0,0 +1,53 @@
import type { Plugin } from 'unified'
/**
* Custom remark plugin to disable specific markdown constructs
*
* This plugin allows you to disable specific markdown constructs by passing
* them as micromark extensions to the underlying parser.
*
* @see https://github.com/micromark/micromark
*
* @example
* ```typescript
* // Disable indented code blocks
* remarkDisableConstructs(['codeIndented'])
*
* // Disable multiple constructs
* remarkDisableConstructs(['codeIndented', 'autolink', 'htmlFlow'])
* ```
*/
/**
* Helper function to add values to plugin data
* @param data - The plugin data object
* @param field - The field name to add to
* @param value - The value to add
*/
function add(data: any, field: string, value: unknown): void {
const list = data[field] ? data[field] : (data[field] = [])
list.push(value)
}
/**
* Remark plugin to disable specific markdown constructs
* @param constructs - Array of construct names to disable (e.g., ['codeIndented', 'autolink'])
* @returns A remark plugin function
*/
function remarkDisableConstructs(constructs: string[] = []): Plugin<[], any, any> {
return function () {
const data = this.data()
if (constructs.length > 0) {
const disableExtension = {
disable: {
null: constructs
}
}
add(data, 'micromarkExtensions', disableExtension)
}
}
}
export default remarkDisableConstructs

View File

@@ -31,7 +31,7 @@ const MessageErrorInfo: React.FC<{ block: ErrorMessageBlock }> = ({ block }) =>
}
const Alert = styled(AntdAlert)`
margin: 0.5rem 0;
margin: 0.5rem 0 !important;
padding: 10px;
font-size: 12px;
`

View File

@@ -18,12 +18,12 @@ const ImageBlock: React.FC<Props> = ({ block }) => {
? [`file://${block?.file?.path}`]
: []
return (
<Container style={{ marginBottom: 8 }}>
<Container>
{images.map((src, index) => (
<ImageViewer
src={src}
key={`image-${index}`}
style={{ maxWidth: 500, maxHeight: 500, padding: 5, borderRadius: 8 }}
style={{ maxWidth: 500, maxHeight: 500, padding: 0, borderRadius: 8 }}
/>
))}
</Container>
@@ -34,6 +34,5 @@ const Container = styled.div`
display: flex;
flex-direction: row;
gap: 10px;
margin-top: 8px;
`
export default React.memo(ImageBlock)

View File

@@ -3,7 +3,7 @@ import { useSettings } from '@renderer/hooks/useSettings'
import { MessageBlockStatus, type ThinkingMessageBlock } from '@renderer/types/newMessage'
import { lightbulbVariants } from '@renderer/utils/motionVariants'
import { Collapse, message as antdMessage, Tooltip } from 'antd'
import { Lightbulb } from 'lucide-react'
import { ChevronRight, Lightbulb } from 'lucide-react'
import { motion } from 'motion/react'
import { memo, useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -57,6 +57,14 @@ const ThinkingBlock: React.FC<Props> = ({ block }) => {
size="small"
onChange={() => setActiveKey((key) => (key ? '' : 'thought'))}
className="message-thought-container"
expandIcon={({ isActive }) => (
<ChevronRight
color="var(--color-text-3)"
size={16}
strokeWidth={1.5}
style={{ transform: isActive ? 'rotate(90deg)' : 'rotate(0deg)' }}
/>
)}
expandIconPosition="end"
items={[
{

View File

@@ -164,17 +164,7 @@ export default React.memo(MessageBlockRenderer)
const ImageBlockGroup = styled.div`
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
grid-template-columns: repeat(3, minmax(200px, 1fr));
gap: 8px;
max-width: 960px;
/* > * {
min-width: 200px;
} */
@media (min-width: 1536px) {
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
max-width: 1280px;
> * {
min-width: 250px;
}
}
`

View File

@@ -1,10 +1,9 @@
import ContextMenu from '@renderer/components/ContextMenu'
import Favicon from '@renderer/components/Icons/FallbackFavicon'
import { HStack } from '@renderer/components/Layout'
import { fetchWebContent } from '@renderer/utils/fetch'
import { cleanMarkdownContent } from '@renderer/utils/formats'
import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query'
import { Button, Drawer, message, Skeleton } from 'antd'
import { Button, message, Popover, Skeleton } from 'antd'
import { Check, Copy, FileSearch } from 'lucide-react'
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -48,16 +47,53 @@ const truncateText = (text: string, maxLength = 100) => {
const CitationsList: React.FC<CitationsListProps> = ({ citations }) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const previewItems = citations.slice(0, 3)
const count = citations.length
if (!count) return null
const popoverContent = (
<div>
{citations.map((citation) => (
<PopoverContentItem key={citation.url || citation.number}>
{citation.type === 'websearch' ? (
<PopoverContent>
<WebSearchCitation citation={citation} />
</PopoverContent>
) : (
<KnowledgePopoverContent>
<KnowledgeCitation citation={citation} />
</KnowledgePopoverContent>
)}
</PopoverContentItem>
))}
</div>
)
return (
<QueryClientProvider client={queryClient}>
<>
<OpenButton type="text" onClick={() => setOpen(true)}>
<Popover
arrow={false}
content={popoverContent}
title={
<div
style={{
padding: '8px 12px 8px',
marginBottom: -8,
fontWeight: 'bold',
borderBottom: '0.5px solid var(--color-border)'
}}>
{t('message.citations')}
</div>
}
placement="right"
trigger="hover"
styles={{
body: {
padding: '0 0 8px 0'
}
}}>
<OpenButton type="text">
<PreviewIcons>
{previewItems.map((c, i) => (
<PreviewIcon key={i} style={{ zIndex: previewItems.length - i }}>
@@ -71,27 +107,7 @@ const CitationsList: React.FC<CitationsListProps> = ({ citations }) => {
</PreviewIcons>
{t('message.citation', { count })}
</OpenButton>
<Drawer
title={t('message.citations')}
placement="right"
onClose={() => setOpen(false)}
open={open}
width={680}
styles={{ header: { border: 'none' }, body: { paddingTop: 0 } }}
destroyOnClose={false}>
{open &&
citations.map((citation) => (
<HStack key={citation.url || citation.number} style={{ alignItems: 'center', gap: 8, marginBottom: 12 }}>
{citation.type === 'websearch' ? (
<WebSearchCitation citation={citation} />
) : (
<KnowledgeCitation citation={citation} />
)}
</HStack>
))}
</Drawer>
</>
</Popover>
</QueryClientProvider>
)
}
@@ -136,16 +152,17 @@ const WebSearchCitation: React.FC<{ citation: Citation }> = ({ citation }) => {
})
return (
<WebSearchCard>
<ContextMenu>
<ContextMenu>
<WebSearchCard>
<WebSearchCardHeader>
<CitationIndex>{citation.number}</CitationIndex>
{citation.showFavicon && citation.url && (
<Favicon hostname={new URL(citation.url).hostname} alt={citation.title || citation.hostname || ''} />
)}
<CitationLink className="text-nowrap" href={citation.url} onClick={(e) => handleLinkClick(citation.url, e)}>
{citation.title || <span className="hostname">{citation.hostname}</span>}
</CitationLink>
<CitationIndex>{citation.number}</CitationIndex>
{fetchedContent && <CopyButton content={fetchedContent} />}
</WebSearchCardHeader>
{isLoading ? (
@@ -153,28 +170,27 @@ const WebSearchCitation: React.FC<{ citation: Citation }> = ({ citation }) => {
) : (
<WebSearchCardContent className="selectable-text">{fetchedContent}</WebSearchCardContent>
)}
</ContextMenu>
</WebSearchCard>
</WebSearchCard>
</ContextMenu>
)
}
const KnowledgeCitation: React.FC<{ citation: Citation }> = ({ citation }) => {
return (
<WebSearchCard>
<ContextMenu>
<ContextMenu>
<WebSearchCard>
<WebSearchCardHeader>
<CitationIndex>{citation.number}</CitationIndex>
{citation.showFavicon && <FileSearch width={16} />}
<CitationLink className="text-nowrap" href={citation.url} onClick={(e) => handleLinkClick(citation.url, e)}>
{citation.title}
</CitationLink>
<CitationIndex>{citation.number}</CitationIndex>
{citation.content && <CopyButton content={citation.content} />}
</WebSearchCardHeader>
<WebSearchCardContent className="selectable-text">
{citation.content && truncateText(citation.content, 100)}
</WebSearchCardContent>
</ContextMenu>
</WebSearchCard>
<WebSearchCardContent className="selectable-text">{citation.content && citation.content}</WebSearchCardContent>
</WebSearchCard>
</ContextMenu>
)
}
@@ -182,7 +198,7 @@ const OpenButton = styled(Button)`
display: flex;
align-items: center;
padding: 3px 8px;
margin-bottom: 8px;
margin: 8px 0;
align-self: flex-start;
font-size: 12px;
background-color: var(--color-background-soft);
@@ -213,10 +229,19 @@ const PreviewIcon = styled.div`
`
const CitationIndex = styled.div`
font-size: 14px;
width: 14px;
height: 14px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background-color: var(--color-reference);
font-size: 10px;
line-height: 1.6;
color: var(--color-text-2);
margin-right: 8px;
color: var(--color-reference-text);
flex-shrink: 0;
opacity: 1;
transition: opacity 0.3s ease;
`
const CitationLink = styled.a`
@@ -224,7 +249,7 @@ const CitationLink = styled.a`
line-height: 1.6;
color: var(--color-text-1);
text-decoration: none;
flex: 1;
.hostname {
color: var(--color-link);
}
@@ -236,10 +261,14 @@ const CopyIconWrapper = styled.div`
align-items: center;
justify-content: center;
color: var(--color-text-2);
opacity: 0.6;
margin-left: auto;
opacity: 0;
padding: 4px;
border-radius: 4px;
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
transition: opacity 0.3s ease;
&:hover {
opacity: 1;
@@ -251,11 +280,17 @@ const WebSearchCard = styled.div`
display: flex;
flex-direction: column;
width: 100%;
padding: 12px;
border-radius: var(--list-item-border-radius);
background-color: var(--color-background);
padding: 12px 0;
transition: all 0.3s ease;
position: relative;
&:hover {
${CopyIconWrapper} {
opacity: 1;
}
${CitationIndex} {
opacity: 0;
}
}
`
const WebSearchCardHeader = styled.div`
@@ -265,6 +300,7 @@ const WebSearchCardHeader = styled.div`
gap: 8px;
margin-bottom: 6px;
width: 100%;
position: relative;
`
const WebSearchCardContent = styled.div`
@@ -273,6 +309,7 @@ const WebSearchCardContent = styled.div`
color: var(--color-text-2);
user-select: text;
cursor: text;
word-break: break-all;
&.selectable-text {
-webkit-user-select: text;
@@ -282,4 +319,21 @@ const WebSearchCardContent = styled.div`
}
`
const PopoverContent = styled.div`
max-width: min(400px, 60vw);
max-height: 60vh;
padding: 0 12px;
`
const KnowledgePopoverContent = styled(PopoverContent)`
max-width: 800px;
`
const PopoverContentItem = styled.div`
border-bottom: 0.5px solid var(--color-border);
&:last-child {
border-bottom: none;
}
`
export default CitationsList

View File

@@ -1,9 +1,8 @@
import ContextMenu from '@renderer/components/ContextMenu'
import { useMessageEditing } from '@renderer/context/MessageEditingContext'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { useMessageOperations } from '@renderer/hooks/useMessageOperations'
import { useModel } from '@renderer/hooks/useModel'
import { useMessageStyle, useSettings } from '@renderer/hooks/useSettings'
import { 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'
@@ -42,14 +41,12 @@ const MessageItem: FC<Props> = ({
index,
hideMenuBar = false,
isGrouped,
isStreaming = false,
style
isStreaming = false
}) => {
const { t } = useTranslation()
const { assistant, setModel } = useAssistant(message.assistantId)
const model = useModel(getMessageModelId(message), message.model?.provider) || message.model
const { isBubbleStyle } = useMessageStyle()
const { showMessageDivider, messageFont, fontSize, narrowMode, messageStyle } = useSettings()
const { messageFont, fontSize } = useSettings()
const { editMessageBlocks, resendUserMessageWithEdit, editMessage } = useMessageOperations(topic)
const messageContainerRef = useRef<HTMLDivElement>(null)
const { editingMessageId, stopEditing } = useMessageEditing()
@@ -101,9 +98,6 @@ const MessageItem: FC<Props> = ({
const isAssistantMessage = message.role === 'assistant'
const showMenubar = !hideMenuBar && !isStreaming && !message.status.includes('ing') && !isEditing
const messageBorder = !isBubbleStyle && showMessageDivider ? '1px dotted var(--color-border)' : 'none'
const messageBackground = getMessageBackground(isBubbleStyle, isAssistantMessage)
const messageHighlightHandler = useCallback((highlight: boolean = true) => {
if (messageContainerRef.current) {
messageContainerRef.current.scrollIntoView({ behavior: 'smooth' })
@@ -140,101 +134,38 @@ const MessageItem: FC<Props> = ({
'message-assistant': isAssistantMessage,
'message-user': !isAssistantMessage
})}
ref={messageContainerRef}
style={{
...style,
justifyContent: isBubbleStyle ? (isAssistantMessage ? 'flex-start' : 'flex-end') : undefined,
flex: isBubbleStyle ? undefined : 1
}}>
ref={messageContainerRef}>
<MessageHeader
message={message}
assistant={assistant}
model={model}
key={getModelUniqId(model)}
index={index}
topic={topic}
/>
{isEditing && (
<ContextMenu
style={{
display: 'flex',
flexDirection: 'column',
alignSelf: isAssistantMessage ? 'flex-start' : 'flex-end',
width: isBubbleStyle ? '70%' : '100%'
}}>
<MessageHeader
message={message}
assistant={assistant}
model={model}
key={getModelUniqId(model)}
index={index}
/>
<div style={{ paddingLeft: messageStyle === 'plain' ? 46 : undefined }}>
<MessageEditor
message={message}
onSave={handleEditSave}
onResend={handleEditResend}
onCancel={handleEditCancel}
/>
</div>
</ContextMenu>
<MessageEditor
message={message}
onSave={handleEditSave}
onResend={handleEditResend}
onCancel={handleEditCancel}
/>
)}
{!isEditing && (
<ContextMenu
style={{
display: 'flex',
flexDirection: 'column',
alignSelf: isAssistantMessage ? 'flex-start' : 'flex-end',
flex: 1,
maxWidth: '100%'
}}>
<MessageHeader
message={message}
assistant={assistant}
model={model}
key={getModelUniqId(model)}
index={index}
/>
<>
<MessageContentContainer
className={
message.role === 'user'
? 'message-content-container message-content-container-user'
: message.role === 'assistant'
? 'message-content-container message-content-container-assistant'
: 'message-content-container'
}
className="message-content-container"
style={{
fontFamily: messageFont === 'serif' ? 'var(--font-family-serif)' : 'var(--font-family)',
fontSize,
background: messageBackground,
overflowY: 'visible',
maxWidth: narrowMode ? 760 : undefined,
alignSelf: isBubbleStyle ? (isAssistantMessage ? 'start' : 'end') : undefined
overflowY: 'visible'
}}>
<MessageErrorBoundary>
<MessageContent message={message} />
</MessageErrorBoundary>
{showMenubar && !isBubbleStyle && (
<MessageFooter
className="MessageFooter"
style={{
borderTop: messageBorder,
flexDirection: !isLastMessage ? 'row-reverse' : undefined
}}>
<MessageMenubar
message={message}
assistant={assistant}
model={model}
index={index}
topic={topic}
isLastMessage={isLastMessage}
isAssistantMessage={isAssistantMessage}
isGrouped={isGrouped}
messageContainerRef={messageContainerRef as React.RefObject<HTMLDivElement>}
setModel={setModel}
/>
</MessageFooter>
)}
</MessageContentContainer>
{showMenubar && isBubbleStyle && (
<MessageFooter
className="MessageFooter"
style={{
borderTop: messageBorder,
flexDirection: !isAssistantMessage ? 'row-reverse' : undefined
}}>
{showMenubar && (
<MessageFooter className="MessageFooter">
<MessageMenubar
message={message}
assistant={assistant}
@@ -249,28 +180,22 @@ const MessageItem: FC<Props> = ({
/>
</MessageFooter>
)}
</ContextMenu>
</>
)}
</MessageContainer>
)
}
const getMessageBackground = (isBubbleStyle: boolean, isAssistantMessage: boolean) => {
return isBubbleStyle
? isAssistantMessage
? 'var(--chat-background-assistant)'
: 'var(--chat-background-user)'
: undefined
}
const MessageContainer = styled.div`
display: flex;
flex-direction: column;
width: 100%;
position: relative;
transition: background-color 0.3s ease;
padding: 0 20px;
transform: translateZ(0);
will-change: transform;
padding: 10px 10px 0 10px;
border-radius: 10px;
&.message-highlight {
background-color: var(--color-primary-mute);
}
@@ -292,11 +217,7 @@ const MessageContainer = styled.div`
const MessageContentContainer = styled.div`
max-width: 100%;
display: flex;
flex: 1;
flex-direction: column;
justify-content: space-between;
margin-left: 46px;
padding-left: 46px;
margin-top: 5px;
overflow-y: auto;
`
@@ -306,9 +227,8 @@ const MessageFooter = styled.div`
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 2px 0;
margin-top: 2px;
gap: 20px;
margin-left: 46px;
`
const NewContextMessage = styled.div`

View File

@@ -184,7 +184,7 @@ const MessageAnchorLine: FC<MessageLineProps> = ({ messages }) => {
else messageItemsRef.current.delete('bottom-anchor')
}}
style={{
opacity: mouseY ? 0.5 + calculateValueByDistance('bottom-anchor', 1) : 0.6
opacity: mouseY ? 0.5 : Math.max(0, 0.6 - (0.3 * Math.abs(0 - messages.length / 2)) / 5)
}}
onClick={scrollToBottom}>
<CircleChevronDown
@@ -194,7 +194,7 @@ const MessageAnchorLine: FC<MessageLineProps> = ({ messages }) => {
</MessageItem>
{messages.map((message, index) => {
const opacity = 0.5 + calculateValueByDistance(message.id, 1)
const scale = 1 + calculateValueByDistance(message.id, 1)
const scale = 1 + calculateValueByDistance(message.id, 1.2)
const size = 10 + calculateValueByDistance(message.id, 20)
const avatarSource = getAvatarSource(isLocalAi, getMessageModelId(message))
const username = removeLeadingEmoji(getUserName(message))
@@ -219,15 +219,14 @@ const MessageAnchorLine: FC<MessageLineProps> = ({ messages }) => {
</MessageItemContainer>
{message.role === 'assistant' ? (
<Avatar
<MessageItemAvatar
src={avatarSource}
size={size}
style={{
border: isLocalAi ? '1px solid var(--color-border-soft)' : 'none',
filter: theme === 'dark' ? 'invert(0.05)' : undefined
}}>
A
</Avatar>
}}
/>
) : (
<>
{isEmoji(avatar) ? (
@@ -241,7 +240,7 @@ const MessageAnchorLine: FC<MessageLineProps> = ({ messages }) => {
{avatar}
</EmojiAvatar>
) : (
<Avatar src={avatar} size={size} />
<MessageItemAvatar src={avatar} size={size} />
)}
</>
)}
@@ -260,17 +259,28 @@ const MessageItemContainer = styled.div`
align-items: flex-end;
justify-content: space-between;
text-align: right;
gap: 4px;
gap: 3px;
opacity: 0;
transform-origin: right center;
transition: transform cubic-bezier(0.25, 1, 0.5, 1) 150ms;
will-change: transform;
`
const MessageItemAvatar = styled(Avatar)`
transition:
width,
height,
cubic-bezier(0.25, 1, 0.5, 1) 150ms;
will-change: width, height;
`
const MessageLineContainer = styled.div<{ $height: number | null }>`
width: 14px;
position: fixed;
top: ${(props) => (props.$height ? `calc(${props.$height / 2}px + var(--status-bar-height))` : '50%')};
top: calc(50% - var(--status-bar-height) - 10px);
right: 13px;
max-height: ${(props) => (props.$height ? `${props.$height}px` : 'calc(100% - var(--status-bar-height) * 2)')};
max-height: ${(props) =>
props.$height ? `${props.$height - 20}px` : 'calc(100% - var(--status-bar-height) * 2 - 20px)'};
transform: translateY(-50%);
z-index: 0;
user-select: none;
@@ -280,7 +290,7 @@ const MessageLineContainer = styled.div<{ $height: number | null }>`
font-size: 5px;
overflow: hidden;
&:hover {
width: 440px;
width: 500px;
overflow-x: visible;
overflow-y: hidden;
${MessageItemContainer} {

View File

@@ -40,7 +40,7 @@ const MessageBlockEditor: FC<Props> = ({ message, onSave, onResend, onCancel })
const model = assistant.model || assistant.defaultModel
const isVision = useMemo(() => isVisionModel(model), [model])
const supportExts = useMemo(() => [...textExts, ...documentExts, ...(isVision ? imageExts : [])], [isVision])
const { pasteLongTextAsFile, pasteLongTextThreshold, fontSize, sendMessageShortcut } = useSettings()
const { pasteLongTextAsFile, pasteLongTextThreshold, fontSize, sendMessageShortcut, enableSpellCheck } = useSettings()
const { t } = useTranslation()
const textareaRef = useRef<TextAreaRef>(null)
const attachmentButtonRef = useRef<AttachmentButtonRef>(null)
@@ -222,13 +222,16 @@ const MessageBlockEditor: FC<Props> = ({ message, onSave, onResend, onCancel })
}}
onKeyDown={(e) => handleKeyDown(e, block.id)}
autoFocus
contextMenu="true"
spellCheck={false}
spellCheck={enableSpellCheck}
onPaste={(e) => onPaste(e.nativeEvent)}
onFocus={() => {
// 记录当前聚焦的组件
PasteService.setLastFocusedComponent('messageEditor')
}}
onContextMenu={(e) => {
// 阻止事件冒泡,避免触发全局的 Electron contextMenu
e.stopPropagation()
}}
style={{
fontSize,
padding: '0px 15px 8px 15px'
@@ -305,10 +308,11 @@ const MessageBlockEditor: FC<Props> = ({ message, onSave, onResend, onCancel })
const EditorContainer = styled.div`
padding: 8px 0;
border: 1px solid var(--color-border);
border: 0.5px solid var(--color-border);
transition: all 0.2s ease;
border-radius: 15px;
margin-top: 5px;
margin-bottom: 10px;
background-color: var(--color-background-opacity);
width: 100%;

View File

@@ -1,4 +1,3 @@
import Scrollbar from '@renderer/components/Scrollbar'
import { MessageEditingProvider } from '@renderer/context/MessageEditingContext'
import { useChatContext } from '@renderer/hooks/useChatContext'
import { useMessageOperations } from '@renderer/hooks/useMessageOperations'
@@ -10,11 +9,10 @@ import type { Message } from '@renderer/types/newMessage'
import { classNames } from '@renderer/utils'
import { Popover } from 'antd'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import styled, { css } from 'styled-components'
import styled from 'styled-components'
import MessageItem from './Message'
import MessageGroupMenuBar from './MessageGroupMenuBar'
import SelectableMessage from './MessageSelect'
interface Props {
messages: (Message & { index: number })[]
@@ -62,7 +60,6 @@ const MessageGroup = ({ messages, topic, registerMessageElement }: Props) => {
)
const isGrouped = isMultiSelectMode ? false : messageLength > 1 && messages.every((m) => m.role === 'assistant')
const isHorizontal = multiModelMessageStyle === 'horizontal'
const isGrid = multiModelMessageStyle === 'grid'
useEffect(() => {
@@ -166,25 +163,19 @@ const MessageGroup = ({ messages, topic, registerMessageElement }: Props) => {
isGrouped,
message,
topic,
index: message.index,
style: {
paddingTop: isGrouped && ['horizontal', 'grid'].includes(multiModelMessageStyle) ? 0 : 15
}
index: message.index
}
const messageContent = (
<MessageWrapper
id={`message-${message.id}`}
$layout={multiModelMessageStyle}
// $selected={index === selectedIndex}
$isGrouped={isGrouped}
key={message.id}
className={classNames({
// 加个卡片布局
'group-message-wrapper': message.role === 'assistant' && (isHorizontal || isGrid) && isGrouped,
[multiModelMessageStyle]: isGrouped,
selected: message.id === selectedMessageId
})}>
className={classNames([
{
[multiModelMessageStyle]: message.role === 'assistant',
selected: message.id === selectedMessageId
}
])}>
<MessageItem {...messageProps} />
</MessageWrapper>
)
@@ -193,47 +184,43 @@ const MessageGroup = ({ messages, topic, registerMessageElement }: Props) => {
return (
<Popover
key={message.id}
destroyTooltipOnHide
content={
<MessageWrapper
$layout={multiModelMessageStyle}
// $selected={index === selectedIndex}
$isGrouped={isGrouped}
$isInPopover={true}>
className={classNames([
'in-popover',
{
[multiModelMessageStyle]: message.role === 'assistant',
selected: message.id === selectedMessageId
}
])}>
<MessageItem {...messageProps} />
</MessageWrapper>
}
trigger={gridPopoverTrigger}
styles={{ root: { maxWidth: '60vw', minWidth: '550px', overflowY: 'auto', zIndex: 1000 } }}>
<div style={{ cursor: 'pointer' }}>{messageContent}</div>
styles={{
root: { maxWidth: '60vw', overflowY: 'auto', zIndex: 1000 },
body: { padding: 2 }
}}>
{messageContent}
</Popover>
)
}
return (
<SelectableMessage
key={`selectable-${message.id}`}
messageId={message.id}
topic={topic}
isClearMessage={message.type === 'clear'}>
{messageContent}
</SelectableMessage>
)
return messageContent
},
[isGrid, isGrouped, topic, multiModelMessageStyle, isHorizontal, selectedMessageId, gridPopoverTrigger]
[isGrid, isGrouped, topic, multiModelMessageStyle, selectedMessageId, gridPopoverTrigger]
)
return (
<MessageEditingProvider>
<GroupContainer
id={`message-group-${messages[0].askId}`}
$isGrouped={isGrouped}
$layout={multiModelMessageStyle}
className={classNames([isGrouped && 'group-container', isHorizontal && 'horizontal', isGrid && 'grid'])}>
id={messages[0].askId ? `message-group-${messages[0].askId}` : undefined}
className={classNames([multiModelMessageStyle, { 'multi-select-mode': isMultiSelectMode }])}>
<GridContainer
$count={messageLength}
$layout={multiModelMessageStyle}
$gridColumns={gridColumns}
className={classNames([isGrouped && 'group-grid-container', isHorizontal && 'horizontal', isGrid && 'grid'])}>
className={classNames([multiModelMessageStyle, { 'multi-select-mode': isMultiSelectMode }])}>
{messages.map(renderMessage)}
</GridContainer>
{isGrouped && (
@@ -256,73 +243,103 @@ const MessageGroup = ({ messages, topic, registerMessageElement }: Props) => {
)
}
const GroupContainer = styled.div<{ $isGrouped: boolean; $layout: MultiModelMessageStyle }>`
padding-top: ${({ $isGrouped, $layout }) => ($isGrouped && 'horizontal' === $layout ? '15px' : '0')};
&.group-container.horizontal,
&.group-container.grid {
padding: 0 20px;
.message {
padding: 0;
}
const GroupContainer = styled.div`
&.horizontal,
&.grid {
padding: 4px 10px;
.group-menu-bar {
margin-left: 0;
margin-right: 0;
}
}
&.multi-select-mode {
padding: 5px 10px;
}
`
const GridContainer = styled.div<{ $count: number; $layout: MultiModelMessageStyle; $gridColumns: number }>`
const GridContainer = styled.div<{ $count: number; $gridColumns: number }>`
width: 100%;
display: grid;
gap: ${({ $layout }) => ($layout === 'horizontal' ? '16px' : '0')};
grid-template-columns: repeat(
${({ $layout, $count }) => (['fold', 'vertical'].includes($layout) ? 1 : $count)},
minmax(480px, 1fr)
);
@media (max-width: 800px) {
grid-template-columns: repeat(
${({ $layout, $count }) => (['fold', 'vertical'].includes($layout) ? 1 : $count)},
minmax(400px, 1fr)
);
overflow-y: visible;
gap: 16px;
&.horizontal {
padding-bottom: 4px;
grid-template-columns: repeat(${({ $count }) => $count}, minmax(480px, 1fr));
overflow-x: auto;
}
&.fold,
&.vertical {
grid-template-columns: repeat(1, minmax(0, 1fr));
gap: 8px;
}
&.grid {
grid-template-columns: repeat(
${({ $count, $gridColumns }) => ($count > 1 ? $gridColumns || 2 : 1)},
minmax(0, 1fr)
);
grid-template-rows: auto;
}
&.multi-select-mode {
grid-template-columns: repeat(1, minmax(0, 1fr));
gap: 10px;
.message {
border: 0.5px solid var(--color-border);
border-radius: 10px;
padding: 10px;
.message-content-container {
max-height: 200px;
overflow-y: hidden !important;
}
.MessageFooter {
display: none;
}
}
}
${({ $layout }) =>
$layout === 'horizontal' &&
css`
margin-top: 15px;
`}
${({ $gridColumns, $layout, $count }) =>
$layout === 'grid' &&
css`
margin-top: 15px;
grid-template-columns: repeat(${$count > 1 ? $gridColumns || 2 : 1}, minmax(0, 1fr));
grid-template-rows: auto;
gap: 16px;
`}
${({ $layout }) => {
return $layout === 'horizontal'
? css`
overflow-y: auto;
`
: 'overflow-y: visible;'
}}
`
interface MessageWrapperProps {
$layout: 'fold' | 'horizontal' | 'vertical' | 'grid'
// $selected: boolean
$isGrouped: boolean
$isInPopover?: boolean
}
const MessageWrapper = styled(Scrollbar)<MessageWrapperProps>`
width: 100%;
display: flex;
const MessageWrapper = styled.div<MessageWrapperProps>`
&.horizontal {
display: inline-block;
overflow-y: auto;
.message {
border: 0.5px solid var(--color-border);
border-radius: 10px;
}
.message-content-container {
padding-left: 0;
max-height: calc(100vh - 350px);
overflow-y: auto !important;
margin-right: -10px;
}
.MessageFooter {
margin-left: 0;
margin-top: 2px;
margin-bottom: 2px;
}
}
&.grid {
display: inline-block;
height: 300px;
overflow-y: hidden;
border: 0.5px solid var(--color-border);
border-radius: 10px;
cursor: pointer;
}
&.in-popover {
height: auto;
border: none;
max-height: 50vh;
overflow-y: auto;
cursor: default;
.message-content-container {
padding-left: 0;
}
.MessageFooter {
margin-left: 0;
}
}
&.fold {
display: none;
@@ -330,38 +347,6 @@ const MessageWrapper = styled(Scrollbar)<MessageWrapperProps>`
display: inline-block;
}
}
${({ $layout, $isGrouped }) => {
if ($layout === 'horizontal' && $isGrouped) {
return css`
border: 0.5px solid var(--color-border);
padding: 10px;
border-radius: 6px;
max-height: 600px;
margin-bottom: 10px;
`
}
return ''
}}
${({ $layout, $isInPopover, $isGrouped }) => {
// 如果布局是grid并且是组消息则设置最大高度和溢出行为卡片不可滚动点击展开后可滚动
// 如果布局是horizontal则设置溢出行为卡片可滚动
// 如果布局是fold、vertical高度不限制与正常消息流布局一致则设置卡片不可滚动visible
return $layout === 'grid' && $isGrouped
? css`
max-height: ${$isInPopover ? '50vh' : '300px'};
overflow-y: ${$isInPopover ? 'auto' : 'hidden'};
border: 0.5px solid ${$isInPopover ? 'transparent' : 'var(--color-border)'};
padding: 10px;
border-radius: 6px;
background-color: var(--color-background);
`
: css`
overflow-y: ${$layout === 'horizontal' ? 'auto' : 'visible'};
border-radius: 6px;
`
}}
`
export default memo(MessageGroup)

View File

@@ -59,6 +59,7 @@ const MessageGroupMenuBar: FC<Props> = ({
<LayoutContainer>
{['fold', 'vertical', 'horizontal', 'grid'].map((layout) => (
<Tooltip
mouseEnterDelay={0.5}
key={layout}
title={t(`message.message.multi_model_style`) + ': ' + t(`message.message.multi_model_style.${layout}`)}>
<LayoutOption
@@ -101,15 +102,13 @@ const GroupMenuBar = styled.div<{ $layout: MultiModelMessageStyle }>`
flex-direction: row;
align-items: center;
gap: 10px;
margin: 0 20px;
padding: 6px 10px;
border-radius: 6px;
margin-top: 10px;
padding: 8px;
border-radius: 10px;
margin: 8px 10px 16px;
justify-content: space-between;
overflow: hidden;
border: 0.5px solid var(--color-border);
height: 40px;
background-color: var(--color-background);
`
const LayoutContainer = styled.div`

View File

@@ -1,10 +1,11 @@
import { SettingOutlined } from '@ant-design/icons'
import Selector from '@renderer/components/Selector'
import { useSettings } from '@renderer/hooks/useSettings'
import { SettingDivider } from '@renderer/pages/settings'
import { SettingRow } from '@renderer/pages/settings'
import { useAppDispatch } from '@renderer/store'
import { setGridColumns, setGridPopoverTrigger } from '@renderer/store/settings'
import { Col, Row, Select, Slider } from 'antd'
import { Col, Row, Slider } from 'antd'
import { Popover } from 'antd'
import { FC, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -18,19 +19,21 @@ const MessageGroupSettings: FC = () => {
return (
<Popover
arrow={false}
trigger={undefined}
showArrow
content={
<div style={{ padding: 10 }}>
<div style={{ padding: 8 }}>
<SettingRow>
<div style={{ marginRight: 10 }}>{t('settings.messages.grid_popover_trigger')}</div>
<Select
<Selector
size={14}
value={gridPopoverTrigger || 'hover'}
onChange={(value) => dispatch(setGridPopoverTrigger(value as 'hover' | 'click'))}
size="small">
<Select.Option value="hover">{t('settings.messages.grid_popover_trigger.hover')}</Select.Option>
<Select.Option value="click">{t('settings.messages.grid_popover_trigger.click')}</Select.Option>
</Select>
options={[
{ label: t('settings.messages.grid_popover_trigger.hover'), value: 'hover' },
{ label: t('settings.messages.grid_popover_trigger.click'), value: 'click' }
]}
/>
</SettingRow>
<SettingDivider />
<SettingRow>

View File

@@ -4,16 +4,17 @@ import { APP_NAME, AppLogo, isLocalAi } from '@renderer/config/env'
import { getModelLogo } from '@renderer/config/models'
import { useTheme } from '@renderer/context/ThemeProvider'
import useAvatar from '@renderer/hooks/useAvatar'
import { useChatContext } from '@renderer/hooks/useChatContext'
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
import { useMessageStyle, useSettings } from '@renderer/hooks/useSettings'
import { getMessageModelId } from '@renderer/services/MessagesService'
import { getModelName } from '@renderer/services/ModelService'
import type { Assistant, Model } from '@renderer/types'
import type { Assistant, Model, Topic } from '@renderer/types'
import type { Message } from '@renderer/types/newMessage'
import { firstLetter, isEmoji, removeLeadingEmoji } from '@renderer/utils'
import { Avatar } from 'antd'
import { Avatar, Checkbox } from 'antd'
import dayjs from 'dayjs'
import { CSSProperties, FC, memo, useCallback, useMemo } from 'react'
import { FC, memo, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@@ -24,6 +25,7 @@ interface Props {
assistant: Assistant
model?: Model
index: number | undefined
topic: Topic
}
const getAvatarSource = (isLocalAi: boolean, modelId: string | undefined) => {
@@ -31,7 +33,7 @@ const getAvatarSource = (isLocalAi: boolean, modelId: string | undefined) => {
return modelId ? getModelLogo(modelId) : undefined
}
const MessageHeader: FC<Props> = memo(({ assistant, model, message, index }) => {
const MessageHeader: FC<Props> = memo(({ assistant, model, message, index, topic }) => {
const avatar = useAvatar()
const { theme } = useTheme()
const { userName, sidebarIcons } = useSettings()
@@ -39,6 +41,10 @@ const MessageHeader: FC<Props> = memo(({ assistant, model, message, index }) =>
const { isBubbleStyle } = useMessageStyle()
const { openMinappById } = useMinappPopup()
const { isMultiSelectMode, selectedMessageIds, handleSelectMessage } = useChatContext(topic)
const isSelected = selectedMessageIds?.includes(message.id)
const avatarSource = useMemo(() => getAvatarSource(isLocalAi, getMessageModelId(message)), [message])
const getUserName = useCallback(() => {
@@ -67,65 +73,54 @@ const MessageHeader: FC<Props> = memo(({ assistant, model, message, index }) =>
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [model?.provider, showMinappIcon])
const avatarStyle: CSSProperties | undefined = isBubbleStyle
? {
flexDirection: isAssistantMessage ? 'row' : 'row-reverse',
textAlign: isAssistantMessage ? 'left' : 'right'
}
: undefined
const containerStyle = isBubbleStyle
? {
justifyContent: isAssistantMessage ? 'flex-start' : 'flex-end'
}
: undefined
return (
<Container className="message-header" style={containerStyle}>
<AvatarWrapper style={avatarStyle}>
{isAssistantMessage ? (
<Avatar
src={avatarSource}
size={35}
style={{
borderRadius: '25%',
cursor: showMinappIcon ? 'pointer' : 'default',
border: isLocalAi ? '1px solid var(--color-border-soft)' : 'none',
filter: theme === 'dark' ? 'invert(0.05)' : undefined
}}
onClick={showMiniApp}>
{avatarName}
</Avatar>
) : (
<>
{isEmoji(avatar) ? (
<EmojiAvatar onClick={() => UserPopup.show()} size={35} fontSize={20}>
{avatar}
</EmojiAvatar>
) : (
<Avatar
src={avatar}
size={35}
style={{ borderRadius: '25%', cursor: 'pointer' }}
onClick={() => UserPopup.show()}
/>
)}
</>
)}
<UserWrap>
<UserName isBubbleStyle={isBubbleStyle} theme={theme}>
{username}
</UserName>
<InfoWrap
style={{
flexDirection: !isAssistantMessage && isBubbleStyle ? 'row-reverse' : undefined
}}>
<MessageTime>{dayjs(message?.updatedAt ?? message.createdAt).format('MM/DD HH:mm')}</MessageTime>
{showTokens && <DividerContainer style={{ color: 'var(--color-text-3)' }}> | </DividerContainer>}
<MessageTokens message={message} isLastMessage={isLastMessage} />
</InfoWrap>
</UserWrap>
</AvatarWrapper>
<Container className="message-header">
{isAssistantMessage ? (
<Avatar
src={avatarSource}
size={35}
style={{
borderRadius: '25%',
cursor: showMinappIcon ? 'pointer' : 'default',
border: isLocalAi ? '1px solid var(--color-border-soft)' : 'none',
filter: theme === 'dark' ? 'invert(0.05)' : undefined
}}
onClick={showMiniApp}>
{avatarName}
</Avatar>
) : (
<>
{isEmoji(avatar) ? (
<EmojiAvatar onClick={() => UserPopup.show()} size={35} fontSize={20}>
{avatar}
</EmojiAvatar>
) : (
<Avatar
src={avatar}
size={35}
style={{ borderRadius: '25%', cursor: 'pointer' }}
onClick={() => UserPopup.show()}
/>
)}
</>
)}
<UserWrap>
<UserName isBubbleStyle={isBubbleStyle} theme={theme}>
{username}
</UserName>
<InfoWrap className="message-header-info-wrap">
<MessageTime>{dayjs(message?.updatedAt ?? message.createdAt).format('MM/DD HH:mm')}</MessageTime>
{showTokens && <DividerContainer style={{ color: 'var(--color-text-3)' }}> | </DividerContainer>}
<MessageTokens message={message} isLastMessage={isLastMessage} />
</InfoWrap>
</UserWrap>
{isMultiSelectMode && (
<Checkbox
checked={isSelected}
onChange={(e) => handleSelectMessage(message.id, e.target.checked)}
style={{ position: 'absolute', right: 0, top: 0 }}
/>
)}
</Container>
)
})
@@ -133,23 +128,18 @@ const MessageHeader: FC<Props> = memo(({ assistant, model, message, index }) =>
MessageHeader.displayName = 'MessageHeader'
const Container = styled.div`
display: flex;
flex-direction: row;
align-items: center;
padding-bottom: 4px;
`
const AvatarWrapper = styled.div`
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
position: relative;
`
const UserWrap = styled.div`
display: flex;
flex-direction: column;
justify-content: space-between;
flex: 1;
`
const InfoWrap = styled.div`

View File

@@ -507,8 +507,7 @@ const MessageMenubar: FC<Props> = (props) => {
<Dropdown
menu={{ items: dropdownItems, onClick: (e) => e.domEvent.stopPropagation() }}
trigger={['click']}
placement="topRight"
arrow>
placement="topRight">
<ActionButton
className="message-action-button"
onClick={(e) => e.stopPropagation()}

View File

@@ -1,3 +1,4 @@
import ContextMenu from '@renderer/components/ContextMenu'
import SvgSpinners180Ring from '@renderer/components/Icons/SvgSpinners180Ring'
import Scrollbar from '@renderer/components/Scrollbar'
import { LOAD_MORE_COUNT } from '@renderer/config/constant'
@@ -16,9 +17,9 @@ import { estimateHistoryTokens } from '@renderer/services/TokenService'
import store, { useAppDispatch } from '@renderer/store'
import { messageBlocksSelectors, updateOneBlock } from '@renderer/store/messageBlock'
import { newMessagesActions } from '@renderer/store/newMessage'
import { saveMessageAndBlocksToDB } from '@renderer/store/thunk/messageThunk'
import { saveMessageAndBlocksToDB, updateMessageAndBlocksThunk } from '@renderer/store/thunk/messageThunk'
import type { Assistant, Topic } from '@renderer/types'
import { type Message, MessageBlockType } from '@renderer/types/newMessage'
import { type Message, MessageBlock, MessageBlockType } from '@renderer/types/newMessage'
import {
captureScrollableDivAsBlob,
captureScrollableDivAsDataURL,
@@ -210,7 +211,15 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic, o
if (msgBlock && isTextLikeBlock(msgBlock) && msgBlock.type !== MessageBlockType.ERROR) {
try {
const updatedRaw = updateCodeBlock(msgBlock.content, codeBlockId, newContent)
const updatedBlock: MessageBlock = {
...msgBlock,
content: updatedRaw,
updatedAt: new Date().toISOString()
}
dispatch(updateOneBlock({ id: msgBlockId, changes: { content: updatedRaw } }))
await dispatch(updateMessageAndBlocksThunk(topic.id, null, [updatedBlock]))
window.message.success({ content: t('code_block.edit.save.success'), key: 'save-code' })
} catch (error) {
console.error(`Failed to save code block ${codeBlockId} content to message block ${msgBlockId}:`, error)
@@ -271,7 +280,6 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic, o
id="messages"
className="messages-container"
ref={scrollContainerRef}
style={{ position: 'relative', paddingTop: showPrompt ? 10 : 0 }}
key={assistant.id}
onScroll={handleScrollPosition}>
<NarrowLayout style={{ display: 'flex', flexDirection: 'column-reverse' }}>
@@ -283,22 +291,25 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic, o
scrollableTarget="messages"
inverse
style={{ overflow: 'visible' }}>
<ScrollContainer>
{groupedMessages.map(([key, groupMessages]) => (
<MessageGroup
key={key}
messages={groupMessages}
topic={topic}
registerMessageElement={registerMessageElement}
/>
))}
{isLoadingMore && (
<LoaderContainer>
<SvgSpinners180Ring color="var(--color-text-2)" />
</LoaderContainer>
)}
</ScrollContainer>
<ContextMenu>
<ScrollContainer>
{groupedMessages.map(([key, groupMessages]) => (
<MessageGroup
key={key}
messages={groupMessages}
topic={topic}
registerMessageElement={registerMessageElement}
/>
))}
{isLoadingMore && (
<LoaderContainer>
<SvgSpinners180Ring color="var(--color-text-2)" />
</LoaderContainer>
)}
</ScrollContainer>
</ContextMenu>
</InfiniteScroll>
{showPrompt && <Prompt assistant={assistant} key={assistant.prompt} topic={topic} />}
</NarrowLayout>
{messageNavigation === 'anchor' && <MessageAnchorLine messages={displayMessages} />}
@@ -361,6 +372,10 @@ const LoaderContainer = styled.div`
const ScrollContainer = styled.div`
display: flex;
flex-direction: column-reverse;
padding: 20px 10px 20px 16px;
.multi-select-mode & {
padding-bottom: 60px;
}
`
interface ContainerProps {
@@ -370,11 +385,9 @@ interface ContainerProps {
const MessagesContainer = styled(Scrollbar)<ContainerProps>`
display: flex;
flex-direction: column-reverse;
padding: 10px 0 20px;
overflow-x: hidden;
background-color: var(--color-background);
z-index: 1;
margin-right: 2px;
position: relative;
`
export default Messages

View File

@@ -10,7 +10,11 @@ const NarrowLayout: FC<Props> = ({ children, ...props }) => {
const { narrowMode } = useSettings()
if (narrowMode) {
return <Container {...props}>{children}</Container>
return (
<Container className="narrow-mode" {...props}>
{children}
</Container>
)
}
return children

View File

@@ -30,11 +30,11 @@ const Prompt: FC<Props> = ({ assistant, topic }) => {
}
const Container = styled.div<{ $isDark: boolean }>`
padding: 10px 20px;
margin: 5px 20px 0 20px;
padding: 10px 16px;
border-radius: 10px;
cursor: pointer;
border: 0.5px solid var(--color-border);
margin: 10px 10px 0 10px;
`
const Text = styled.div`

View File

@@ -27,10 +27,9 @@ const Assistants: FC<AssistantsTabProps> = ({
}) => {
const { assistants, removeAssistant, addAssistant, updateAssistants } = useAssistants()
const [dragging, setDragging] = useState(false)
const [collapsedTags, setCollapsedTags] = useState<Record<string, boolean>>({})
const { addAgent } = useAgents()
const { t } = useTranslation()
const { getGroupedAssistants } = useTags()
const { getGroupedAssistants, collapsedTags, toggleTagCollapse } = useTags()
const { assistantsTabSortType = 'list', setAssistantsTabSortType } = useAssistantsTabSortType()
const containerRef = useRef<HTMLDivElement>(null)
@@ -46,13 +45,6 @@ const Assistants: FC<AssistantsTabProps> = ({
[activeAssistant, assistants, removeAssistant, setActiveAssistant, onCreateDefaultAssistant]
)
const toggleTagCollapse = useCallback((tag: string) => {
setCollapsedTags((prev) => ({
...prev,
[tag]: !prev[tag]
}))
}, [])
const handleSortByChange = useCallback(
(sortType: AssistantsSortType) => {
setAssistantsTabSortType(sortType)
@@ -103,7 +95,6 @@ const Assistants: FC<AssistantsTabProps> = ({
<DragableList
list={group.assistants}
onUpdate={(newList) => handleGroupReorder(group.tag, newList)}
style={{ paddingBottom: dragging ? '34px' : 0 }}
onDragStart={() => setDragging(true)}
onDragEnd={() => setDragging(false)}>
{(assistant) => (
@@ -141,7 +132,6 @@ const Assistants: FC<AssistantsTabProps> = ({
<DragableList
list={assistants}
onUpdate={updateAssistants}
style={{ paddingBottom: dragging ? '34px' : 0 }}
onDragStart={() => setDragging(true)}
onDragEnd={() => setDragging(false)}>
{(assistant) => (

View File

@@ -1,6 +1,7 @@
import { CheckOutlined } from '@ant-design/icons'
import EditableNumber from '@renderer/components/EditableNumber'
import { HStack } from '@renderer/components/Layout'
import Scrollbar from '@renderer/components/Scrollbar'
import Selector from '@renderer/components/Selector'
import { DEFAULT_CONTEXTCOUNT, DEFAULT_MAX_TOKENS, DEFAULT_TEMPERATURE } from '@renderer/config/constant'
import {
isOpenAIModel,
@@ -38,7 +39,6 @@ import {
setPasteLongTextThreshold,
setRenderInputMessageAsMarkdown,
setShowInputEstimatedTokens,
setShowMessageDivider,
setShowPrompt,
setShowTokens,
setShowTranslateConfirm,
@@ -54,7 +54,7 @@ import {
} from '@renderer/types'
import { modalConfirm } from '@renderer/utils'
import { getSendMessageShortcutLabel } from '@renderer/utils/input'
import { Button, Col, InputNumber, Row, Select, Slider, Switch, Tooltip } from 'antd'
import { Button, Col, InputNumber, Row, Slider, Switch, Tooltip } from 'antd'
import { CircleHelp, Settings2 } from 'lucide-react'
import { FC, useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -86,7 +86,6 @@ const SettingsTab: FC<Props> = (props) => {
const {
showPrompt,
showMessageDivider,
messageFont,
showInputEstimatedTokens,
sendMessageShortcut,
@@ -312,20 +311,6 @@ const SettingsTab: FC<Props> = (props) => {
<Switch size="small" checked={showTokens} onChange={(checked) => dispatch(setShowTokens(checked))} />
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>
{t('settings.messages.divider')}
<Tooltip title={t('settings.messages.divider.tooltip')}>
<CircleHelp size={14} style={{ marginLeft: 4 }} color="var(--color-text-2)" />
</Tooltip>
</SettingRowTitleSmall>
<Switch
size="small"
checked={showMessageDivider}
onChange={(checked) => dispatch(setShowMessageDivider(checked))}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>{t('settings.messages.use_serif_font')}</SettingRowTitleSmall>
<Switch
@@ -351,56 +336,56 @@ const SettingsTab: FC<Props> = (props) => {
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>{t('message.message.style')}</SettingRowTitleSmall>
<StyledSelect
<Selector
value={messageStyle}
onChange={(value) => dispatch(setMessageStyle(value as 'plain' | 'bubble'))}
style={{ width: 135 }}
size="small">
<Select.Option value="plain">{t('message.message.style.plain')}</Select.Option>
<Select.Option value="bubble">{t('message.message.style.bubble')}</Select.Option>
</StyledSelect>
options={[
{ value: 'plain', label: t('message.message.style.plain') },
{ value: 'bubble', label: t('message.message.style.bubble') }
]}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>{t('message.message.multi_model_style')}</SettingRowTitleSmall>
<StyledSelect
size="small"
<Selector
value={multiModelMessageStyle}
onChange={(value) =>
dispatch(setMultiModelMessageStyle(value as 'fold' | 'vertical' | 'horizontal' | 'grid'))
}
style={{ width: 135 }}>
<Select.Option value="fold">{t('message.message.multi_model_style.fold')}</Select.Option>
<Select.Option value="vertical">{t('message.message.multi_model_style.vertical')}</Select.Option>
<Select.Option value="horizontal">{t('message.message.multi_model_style.horizontal')}</Select.Option>
<Select.Option value="grid">{t('message.message.multi_model_style.grid')}</Select.Option>
</StyledSelect>
options={[
{ value: 'fold', label: t('message.message.multi_model_style.fold') },
{ value: 'vertical', label: t('message.message.multi_model_style.vertical') },
{ value: 'horizontal', label: t('message.message.multi_model_style.horizontal') },
{ value: 'grid', label: t('message.message.multi_model_style.grid') }
]}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>{t('settings.messages.navigation')}</SettingRowTitleSmall>
<StyledSelect
size="small"
<Selector
value={messageNavigation}
onChange={(value) => dispatch(setMessageNavigation(value as 'none' | 'buttons' | 'anchor'))}
style={{ width: 135 }}>
<Select.Option value="none">{t('settings.messages.navigation.none')}</Select.Option>
<Select.Option value="buttons">{t('settings.messages.navigation.buttons')}</Select.Option>
<Select.Option value="anchor">{t('settings.messages.navigation.anchor')}</Select.Option>
</StyledSelect>
options={[
{ value: 'none', label: t('settings.messages.navigation.none') },
{ value: 'buttons', label: t('settings.messages.navigation.buttons') },
{ value: 'anchor', label: t('settings.messages.navigation.anchor') }
]}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>{t('settings.messages.math_engine')}</SettingRowTitleSmall>
<StyledSelect
<Selector
value={mathEngine}
onChange={(value) => dispatch(setMathEngine(value as MathEngine))}
style={{ width: 135 }}
size="small">
<Select.Option value="KaTeX">KaTeX</Select.Option>
<Select.Option value="MathJax">MathJax</Select.Option>
<Select.Option value="none">{t('settings.messages.math_engine.none')}</Select.Option>
</StyledSelect>
options={[
{ value: 'KaTeX', label: 'KaTeX' },
{ value: 'MathJax', label: 'MathJax' },
{ value: 'none', label: t('settings.messages.math_engine.none') }
]}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
@@ -430,17 +415,14 @@ const SettingsTab: FC<Props> = (props) => {
<SettingGroup>
<SettingRow>
<SettingRowTitleSmall>{t('message.message.code_style')}</SettingRowTitleSmall>
<StyledSelect
<Selector
value={codeStyle}
onChange={(value) => onCodeStyleChange(value as CodeStyleVarious)}
style={{ width: 135 }}
size="small">
{themeNames.map((theme) => (
<Select.Option key={theme} value={theme}>
{theme}
</Select.Option>
))}
</StyledSelect>
options={themeNames.map((theme) => ({
value: theme,
label: theme
}))}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
@@ -466,7 +448,7 @@ const SettingsTab: FC<Props> = (props) => {
<CircleHelp size={14} style={{ marginLeft: 4 }} color="var(--color-text-2)" />
</Tooltip>
</SettingRowTitleSmall>
<InputNumber
<EditableNumber
size="small"
min={1}
max={60}
@@ -577,7 +559,7 @@ const SettingsTab: FC<Props> = (props) => {
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>{t('settings.messages.input.paste_long_text_threshold')}</SettingRowTitleSmall>
<InputNumber
<EditableNumber
size="small"
min={500}
max={10000}
@@ -641,11 +623,9 @@ const SettingsTab: FC<Props> = (props) => {
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>{t('settings.input.target_language')}</SettingRowTitleSmall>
<StyledSelect
defaultValue={'english' as TranslateLanguageVarious}
size="small"
<Selector
value={targetLanguage}
menuItemSelectedIcon={<CheckOutlined />}
onChange={(value) => setTargetLanguage(value as TranslateLanguageVarious)}
options={[
{ value: 'chinese', label: t('settings.input.target_language.chinese') },
{ value: 'chinese-traditional', label: t('settings.input.target_language.chinese-traditional') },
@@ -653,17 +633,14 @@ const SettingsTab: FC<Props> = (props) => {
{ value: 'japanese', label: t('settings.input.target_language.japanese') },
{ value: 'russian', label: t('settings.input.target_language.russian') }
]}
onChange={(value) => setTargetLanguage(value as TranslateLanguageVarious)}
style={{ width: 135 }}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>{t('settings.messages.input.send_shortcuts')}</SettingRowTitleSmall>
<StyledSelect
size="small"
<Selector
value={sendMessageShortcut}
menuItemSelectedIcon={<CheckOutlined />}
onChange={(value) => setSendMessageShortcut(value as SendMessageShortcut)}
options={[
{ value: 'Enter', label: getSendMessageShortcutLabel('Enter') },
{ value: 'Ctrl+Enter', label: getSendMessageShortcutLabel('Ctrl+Enter') },
@@ -671,8 +648,6 @@ const SettingsTab: FC<Props> = (props) => {
{ value: 'Command+Enter', label: getSendMessageShortcutLabel('Command+Enter') },
{ value: 'Shift+Enter', label: getSendMessageShortcutLabel('Shift+Enter') }
]}
onChange={(value) => setSendMessageShortcut(value as SendMessageShortcut)}
style={{ width: 135 }}
/>
</SettingRow>
</SettingGroup>
@@ -704,12 +679,4 @@ const SettingGroup = styled.div<{ theme?: ThemeMode }>`
margin-bottom: 10px;
`
const StyledSelect = styled(Select)`
.ant-select-selector {
border-radius: 15px !important;
padding: 4px 10px !important;
height: 26px !important;
}
`
export default SettingsTab

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