Compare commits

...

106 Commits

Author SHA1 Message Date
kangfenmao
d7f8eec59e feat: fix siliconflow reset api url 2024-09-22 09:24:55 +08:00
kangfenmao
f98879a1e5 chore(version): 0.7.6 2024-09-22 00:44:47 +08:00
kangfenmao
ef40e9db5f style: centered positioning and alignment added to modal and userpopup components
- Added centered positioning to Modal component in PromptPopup.
- Added centered alignment to UserPopup component.
2024-09-22 00:19:45 +08:00
kangfenmao
eb799879ff feat: export topic message as image #103 2024-09-22 00:16:36 +08:00
kangfenmao
13fddc8e7f feat: add loading spinner #86
close #86
2024-09-21 21:07:50 +08:00
kangfenmao
fa3d7f7f4a refactor: message component 2024-09-21 13:20:16 +08:00
kangfenmao
6845ee1664 feat: improved formula rendering with new escaping functions
- Improved formula rendering by removing unnecessary escaping of dollar numbers.
- Implemented two new string escaping functions to prevent incorrect LaTeX formula rendering.

#101
2024-09-21 10:27:32 +08:00
kangfenmao
c8b98681ef fix: After stopping content generation, messages cannot be cleared #66
close #66
2024-09-21 00:25:17 +08:00
kangfenmao
ae4542ce68 feat: improved input bar functionality and added text insertion feature
- Improved functionality for handling text input and file uploads in the input bar.
- Added functionality to insert text at cursor position in a text area.
2024-09-21 00:04:53 +08:00
kangfenmao
0140ff5f6e fix: anthropic api url #97 2024-09-20 23:10:25 +08:00
kangfenmao
a22a47c16a chore(version): 0.7.5 2024-09-20 17:01:52 +08:00
kangfenmao
6bb7b2ca5d feat: improved ui effects and rendering for components
- Added smooth all property transition effect to Icon component.
- Added hover effect and conditional rendering for Switch Topic Sidebar button on current assistant.
- Updated the existing conditional options array to consistently include both topic and settings options.
- Improved hover effects on topic list items.
2024-09-20 16:48:24 +08:00
kangfenmao
1ec7df9a7e feat: add new add topic button 2024-09-20 15:11:50 +08:00
kangfenmao
83925832be fix: attachment open handler 2024-09-20 11:38:30 +08:00
kangfenmao
4dadf98909 fix: improved api call validation
- Improved API call validation to account for additional usage properties.
2024-09-20 11:12:15 +08:00
kangfenmao
375c07e442 style: removed unnecessary import and optimized sidebar styling
- Removed unnecessary import and optimized sidebar styling for improved performance.
2024-09-20 10:49:50 +08:00
kangfenmao
9374541993 chore(version): 0.7.4 2024-09-20 00:15:24 +08:00
kangfenmao
372224469d feat: added minapp event handling and sidebar menu interactions #50
- Added functionality for handling MinApp window closure and provided a default close event handler.
- Added event listeners to Sidebar menus to interact with MinApp.
2024-09-19 23:28:06 +08:00
kangfenmao
60e87e8a22 fix: Disable topic switching and movement during rendering.
- Added functionality to disable topic switching and movement when rendering is in progress.
2024-09-19 23:01:21 +08:00
kangfenmao
353e497642 feat: Improved layout and added file content filtering.
- Added a margin bottom to the Upload component in the MessageAttachments page for improved layout.
- Added support for not displaying file contents for specific providers.
2024-09-19 22:58:12 +08:00
kangfenmao
0ee72a9ef8 chore(version): 0.7.3 2024-09-19 18:20:52 +08:00
kangfenmao
d9873b4261 fix: attachment select extension for windows 2024-09-19 17:40:45 +08:00
kangfenmao
934ab1a374 chore(version): 0.7.2 2024-09-19 16:56:58 +08:00
kangfenmao
33ac0937df feat: Added translations, new column, and UI improvements.
- Added translations for a new field.
- Added new column for file count in the FilesPage view.
- Improved handling of message tokens in the UI.
- Added functionality to display message tokens for messages with specific roles.
- Added window style selection and styling adjustments to the General Settings page.
- Added support for vision models in OpenAIProvider.
2024-09-19 16:56:44 +08:00
kangfenmao
f1c8922752 fix: openai sdk request error 2024-09-19 15:21:24 +08:00
kangfenmao
03bdbdb412 fix: support \(...\) and \[...\] style math formula #78 2024-09-19 15:21:06 +08:00
kangfenmao
cf9d4c5370 feat: add click assistant switch to topics settings 2024-09-19 13:55:44 +08:00
kangfenmao
bfa6bfa196 feat: enhanced user experience with layout adjustments.
- This commit addresses key feature enhancements and minor optimizations for improved user experience and functionality.
- Adjusted margin top for upload container to a positive value.
- Adjusted the max-height of the container to improve rendering on smaller screens.
2024-09-19 12:04:06 +08:00
kangfenmao
af8144d45e feat: Improved file management and added new features.
- Updated file manager to use FileManager class instead of File class.
- Improved file management functionality with features for finding duplicate files, file uploading, and storage management.
- Added styles to wrap and truncate text in a no-drag area.
- Added explicit file extensions to imageExts constant.
- Added the 'paste long text as file' input setting.
- Added image file display and UI improvements for file names and overflow.
- Improved file paste and long text handling functionality.
- awaited onSendMessage function call and added message to chat completion.
- Implemented new option to paste long text as file in the Settings page.
- Updated content display logic to include file origin name along with the file content for text files.
- Improved functionality for handling image and text file contents in the Gemini chat provider.
- Updated file content formatting logic for text files with origin name and content prefix.
- Added a new setting "pasteLongTextAsFile" and its corresponding action to the application settings.
2024-09-19 10:51:30 +08:00
kangfenmao
29605fbcdb feat: copy and paste files or images 2024-09-18 21:18:42 +08:00
kangfenmao
6e7e5cb1f1 feat: add file attachment 2024-09-18 18:00:49 +08:00
kangfenmao
6f5dccd595 feat: estimate completion usage calculation added to chat.
- Estimated usage calculation has been added to chat completion fetching to track message usage.
- Added functionality to estimate completion usage tokens based on input and prompt data.
2024-09-17 14:56:10 +08:00
kangfenmao
0af35b9f10 feat: Added functionality to move topics between assistants.
- Added functionality to move topics between assistants.
- Updated i18n translations to improve user interface clarity and accessibility.
- Improved code organization and functionality to support moving topics between assistants.
2024-09-17 14:37:42 +08:00
kangfenmao
8350ac037e fix: dexie data upgrade 2024-09-16 18:04:46 +08:00
kangfenmao
74b80b474e chore(version): 0.7.1 2024-09-16 16:56:38 +08:00
kangfenmao
be4bf5b510 fix: clear database and restore specific data from backup
- Updated restore function now clears database and restores specific data from backup.
- Removed unused imports and refactored logic for item transformation in the '24' migration step.
2024-09-16 16:44:41 +08:00
kangfenmao
fdb610736d fix: backup and restore 2024-09-16 14:59:42 +08:00
kangfenmao
82e9baf211 fix: Improved user experience by adding timeout to text area resize.
- Added timeout before resizing text area to improve user experience.
- Removed import of the unused `useProviderByAssistant` hook.
2024-09-16 13:03:29 +08:00
kangfenmao
e34d4be6f2 feat: new message branch 2024-09-16 12:56:00 +08:00
kangfenmao
e7f7f8509e feat: add copy button on message footer 2024-09-16 11:51:20 +08:00
kangfenmao
fa1f00f4f5 refactor: add topics and settings table
dexie
2024-09-16 10:19:06 +08:00
kangfenmao
cee373bb6f chore: Update package manager to yarn 4.5.0 and re-add notarize dependency.
- Updated the package manager to yarn version 4.5.0.
- Removed and re-added "electron/notarize" dependency with a specific patch version.
2024-09-15 14:20:58 +08:00
kangfenmao
01acdeb777 feat: added vite_main_bundle_id config and improved code cleanliness 2024-09-15 10:35:02 +08:00
kangfenmao
a654ccc25e chore(version): 0.7.0 2024-09-14 21:28:39 +08:00
kangfenmao
71a35ccd44 fix: removed dev tools, updated sidebar links, fixed file deletion.
- Removed ability to open developer tools in main window.
- Added and removed a link to the "/files" route in the Sidebar component.
- Fixed file deletion logic to correctly delete files from both the database and the file system.
2024-09-14 21:28:39 +08:00
kangfenmao
29826ff091 fix: removed 'trigger' attribute from popover component 2024-09-14 17:22:03 +08:00
kangfenmao
8566476d91 feat: add id to miniapp 2024-09-14 17:02:47 +08:00
kangfenmao
a173a87f29 style: improved formatting in add agent popup.
- Improved formatting of prompt and fetched generated text in Add Agent Popup.
2024-09-14 16:53:22 +08:00
Aimer
cb068d71ca Modified the prompt part Modified the minapp data part 2024-09-14 16:23:58 +08:00
kangfenmao
66210d1d2e fix: remove trailing double spaces from markdown strings 2024-09-14 16:17:35 +08:00
kangfenmao
aa427c9911 refactor: update file management to use filetype instead of filemetadata 2024-09-14 16:08:43 +08:00
kangfenmao
9ae9fdf392 refactor: remove sqlite3 use dexie 2024-09-14 15:25:56 +08:00
kangfenmao
0ddef31ed8 chore: update build process and database configuration.
- Updated configuration to exclude additional directories from electron-builder's build process.
- Dropped the creation of the "files" table in the database schema.
- Improved code organization and extracted the data path into a reusable function.
- Updated database migration configuration to use a new migration manager.
- Added database migration to create a table for file management.
- A migration to remove the "files" table has been applied.
2024-09-13 17:03:26 +08:00
kangfenmao
617af8b12a feat: implemented vision model support and ui enhancements.
- Updated color palette settings have been implemented.
- Added VisionIcon component utilizing Ant Design icons and styled components for visual customization.
- Updated vision model regex to include additional models.
- Added support for multiple file columns in i18n resources.
- Added translations to column titles.
- Added support for vision models in the Select Model Button component.
- Added functionality to display a vision model icon next to the model name on dropdown items.
- Implemented changes to add vision model support to the Edit Models Popup.
- Added icon to display vision models in provider settings.
2024-09-13 15:46:48 +08:00
kangfenmao
71876e6a70 feat: added attachment preview and upload/removal capabilities.
- Added functionality to display attachment preview with upload and removal capabilities.
- Added support for file attachments to the input bar.
2024-09-13 14:47:05 +08:00
kangfenmao
4f250cdcb1 refactor: use sequelize replace better-sqlite3 2024-09-13 13:26:22 +08:00
kangfenmao
9268ab845e fix: Corrected image mime type in IPC message.
- Corrected image mime type in IPC message.
2024-09-13 13:26:22 +08:00
kangfenmao
0337c6649b feat: Added tracking column to files table and updated FileMetadata interface.
- Added a "count" column with default value 1 to the "files" table for tracking purposes.
- Improved file duplication and deletion handling.
- Updated regular expression for vision models to include additional providers.
- Improved removal of topics for assistants from local storage.
- Added support for human-readable date formats in file metadata.
- Improved handling of messages with image attachments to include base64 encoded images in the response.
- Added new 'count' property to the FileMetadata interface.
2024-09-13 13:26:22 +08:00
kangfenmao
8781388760 feat: Improved IPC image handling and added vision model support.
- Improved IPC image handling to return mime type and base64 encoded data alongside the image data.
- Updated type definition for `base64` method in image object to return an object with mime, base64, and data properties.
- Added support for vision models using new function and regex.
- Table cell size has been reduced on the FilesPage component.
- Added support for vision model attachments.
- Added model dependency to AttachmentButton component.
- Implemented new functionality to handle image messages in the GeminiProvider class.
- Update image base64 encoding to directly use API response data.
2024-09-13 13:26:22 +08:00
kangfenmao
2016ba7062 feat: add attachment files 2024-09-13 13:26:22 +08:00
kangfenmao
a03d619e2f feat: add sqlite database manager 2024-09-13 13:26:22 +08:00
kangfenmao
76d1f0bb1e feat: added file management functionality and API operations
- Improved functionality for file management has been added.
- Added file system management functionality through IPC.
- Added functionality to interact with files including selection, upload, deletion, and batch operations.
- Added new file operations to the custom API, including file select, upload, delete, batch upload, and batch delete functions.
- Implemented feature to select and upload files via API.
2024-09-13 13:26:22 +08:00
kangfenmao
2bad5a1184 feat: add file class 2024-09-13 13:26:22 +08:00
kangfenmao
94ba3aee05 feat: add files sidebar menu 2024-09-13 13:26:22 +08:00
kangfenmao
563758f69f refactor: renamed generate method to generateText for clarity and consistency 2024-09-13 10:03:30 +08:00
kangfenmao
56af85cc3e feat: add generate to ai provider api 2024-09-13 09:57:27 +08:00
kangfenmao
6a1a861ecc chore(version): 0.6.14 2024-09-11 20:58:46 +08:00
kangfenmao
ceab574a22 feat: Add new image file and Poe app support.
- Added a new image file.
- Added Poe app to the list of supported apps.
- Removed unused provider configuration.
2024-09-11 20:58:29 +08:00
kangfenmao
98704fdb28 docs: Update translations and UI for better readability.
- Updated English translations in internationalization resources to simplify search assistant placeholder.
- Removed unused import, improved text search UI and adjusted font sizes for better readability.
2024-09-11 19:39:27 +08:00
kangfenmao
fd5cba5219 chore(version): 0.6.13 2024-09-11 19:22:34 +08:00
kangfenmao
be5aaa2b66 feat: Add Cohere model support and binary asset.
- Added new binary asset 'cohere.webp'.
- Added Cohere model support to the application.
2024-09-11 19:19:09 +08:00
kangfenmao
7e8687decd feat: Added GitHub provider support and models.
- Added a new SVG logo for the GitHub provider.
- Added a new social media platform provider to the SYSTEM_MODELS configuration.
- Added support for Github provider in the application configuration.
- Added two new translation keys: 'github' with 'GitHub Models' and updated the existing key 'graphrag-kylin-mountain'.
- Added width parameter to EditModelsPopup configuration.
- Added GitHub-specific model handling to OpenAIProvider class.
- Incremented the application version to 25.
- Added support for a new LLM model type.
- Added a new migration step to configure and enable a GitHub LLM provider.
2024-09-11 19:08:40 +08:00
kangfenmao
4c96324ef7 docs: Update release notes for Electron application.
- Updated release notes for Electron application now include additional features and fixes.
2024-09-11 17:36:37 +08:00
kangfenmao
dd3c81ec5f feat: Enhanced search functionality with user interaction and command shortcuts.
- Improved functionality to search Assistants with enhanced user interaction and command shortcuts.
- Implemented search functionality with runtime state management.
- Added functionality to return default assistant settings and updated conversion of agents to assistants to include default settings.
- Added a new 'searching' boolean field and corresponding state update action to the runtime store.
2024-09-11 17:29:46 +08:00
kangfenmao
42f0b5f8fc feat: Update temperature slider maximum value to 2 #62
- Increased the maximum temperature value in the settings slider.
- Increased the temperature slider maximum value from 1.2 to 2.
2024-09-11 16:24:07 +08:00
kangfenmao
11b2cd88b7 feat: Added configurable Droppable component props to DragableList, updated translations and implemented search functionality.
- Added support for configurable Droppable component props to the DragableList component.
- Updated translations for multiple components and languages.
- Implemented search functionality in the Assistants page.
2024-09-11 16:14:06 +08:00
kangfenmao
6bf98f6db3 fix: Corrected deletions and added API host reset for editable providers.
- Corrected deletions of the 'editable' property for multiple providers.
- Added ability to reset API host for editable providers when not empty.
2024-09-11 15:25:44 +08:00
kangfenmao
10b4e3c634 feat: enable Math support in Markdown rendering.
- Enabled Math support in Markdown rendering without single dollar text math.
2024-09-10 15:31:32 +08:00
kangfenmao
a3f5223b4c fix: disable math formula conversion in Markdown.
- Disabled math formula conversion in Markdown rendering.
2024-09-10 15:25:18 +08:00
kangfenmao
2855575b36 style: Refine UI styles and layout.
- Adjusted various font and layout styles to refine the user interface.
- Updated the minimum width of the NavbarRightContainer to match the var(--topic-list-width) setting.
- Added logic to synchronize local _activeTopic with activeTopic state.
- Improve logic for dynamically updating tab state in RightSidebar component based on position and topic settings.
- Removed unneeded console statement from font size slider's onChangeComplete event.
- Adjusted the width of the SettingMenus component to utilize the --settings-width variable.
2024-09-10 15:20:59 +08:00
kangfenmao
1f0ba20523 feat: Added platform-specific functionality to GeneralSettings page.
- Added platform-specific functionality to GeneralSettings page.
2024-09-10 13:52:50 +08:00
kangfenmao
2f53416e09 docs: Update agent-related translations to use 'assistant' term.
- "All agent-related translations have been updated to use the term 'assistant' instead of 'agent'."
2024-09-10 13:51:47 +08:00
kangfenmao
ddbf266a3f style: Updated component styles and layouts.
- Added new styles for the business smart assistant icon.
- Adjusted the sizes and positions of the ArrowRightButton components.
- Removed conditional style for NavbarLeft component.
- Implemented logic to resolve tab initialization based on component position.
2024-09-10 13:50:20 +08:00
kangfenmao
d815415f36 style: Adjusted layout and styling of right sidebar.
- Modified color border variable to a lighter grayish white.
- Adjusted the layout and styling of the right sidebar.
2024-09-10 13:28:34 +08:00
kangfenmao
cdacc56fd7 chore(version): 0.6.12 2024-09-09 17:34:30 +08:00
kangfenmao
455d909c74 style: Centered buttons and modals.
- Added the centered property to the OK button on the AgentsPage.
- Added centered option to modal confirmation dialog.
- Centred the delete button in the ProvidersList component.
- Added centered confirmation to reset modal.
2024-09-09 17:16:14 +08:00
kangfenmao
52d84afed6 feat: Update release notes with new features and bug fixes. 2024-09-09 17:01:02 +08:00
kangfenmao
f06d1d4d9a style: Centered layout updates across components.
- Centered the 'Add Assistant' popup in the chat modal.
- Added centered alignment to the AssistantSettingPopup component.
- The text area prompt input field now has a larger height.
- Updated the positioning of the Manage Agents popup to be centered.
- Added a centered attribute to the AddModelPopup modal footer.
- Added centered positioning to ProviderSettings AddProviderPopup.
- Centered layout has been added to the SearchContainer.
2024-09-09 16:57:20 +08:00
kangfenmao
805a65bbaa Revert "refactor: Migrate DeepSeek models to v2 naming convention"
This reverts commit 9ff65441ef.
2024-09-09 16:33:29 +08:00
kangfenmao
f217950b13 style: Adjusted dropdown menu maxHeight to 55vh. #52
- Adjusted the maxHeight property of the dropdown menu to 55vh from 80vh.
2024-09-09 13:03:51 +08:00
kangfenmao
9ff65441ef refactor: Migrate DeepSeek models to v2 naming convention
- Updated DeepSeek models to use version 2 naming convention.
2024-09-09 11:58:18 +08:00
kangfenmao
2b20282a41 feat: Add Zhihu app support and image asset.
- A new image file 'zhihu.png' has been added.
- Added support for Zhihu app in the minapp configuration.
2024-09-09 11:20:02 +08:00
kangfenmao
96ad2de896 chore(version): 0.6.11 2024-09-08 22:59:12 +08:00
kangfenmao
e1ea875c21 feat: Add list styling and optimize DragableList component
- Added list styling functionality to the DragableList component.
- Removed unused imports and updated container height to accommodate additional content.
2024-09-08 22:55:58 +08:00
kangfenmao
500e91977c feat: Show all topics on drag start
- Enforce the drag and drop functionality to show all topics on drag start.
2024-09-08 22:35:34 +08:00
kangfenmao
bd194ff955 refactor: Simplify import and topic deletion logic
- Updated import statement to remove unused type reference.
- Improved handling of deleting a topic.
2024-09-08 22:25:56 +08:00
kangfenmao
828bd71f22 feat: Remove activeAssistant dependency, add assistant dependency
- Updated the `onEditAssistant` function to remove dependency on `activeAssistant` variable and add `assistant` as a dependency.
2024-09-08 20:57:49 +08:00
kangfenmao
5991f692b2 feat: Edit assistant settings with real-time sync.
- Added support for editing an assistant's settings with real-time synchronization to the agent.
2024-09-08 16:09:17 +08:00
kangfenmao
200d78a140 feat: Enhanced UI/UX with design updates, i18n, and feature enhancements.
- Updated design styles for segmented tabs and size adjustments for assistive elements.
- Added internationalization translations for English and Chinese.
- Removed unused import and functionality for switching topics sidebar.
- Added functionality to hide or show the right sidebar in the Chat page.
- Renamed Assistants component to RightSidebar.
- Improved functionality for showing and toggling topics and settings in the input bar.
- Removed unused imports and refactored Navbar component layout.
- Updated existing right sidebar functionality to allow for custom position and show topic settings.
- Removed inline styles for width from Settings component Container styles.
- Added new features for managing topics in the home page, including drag and drop functionality, a "show all" button for viewing more topics, and improved handling of large topic lists.
2024-09-08 15:56:16 +08:00
kangfenmao
9a502b5e47 refactor: Improve code reusability and model service logic
- Improved code reusability in ModelSettings component by utilizing the hasModel function and Memoization.
- Refactored model service to include logic for checking if a model exists and retrieving its unique ID.
2024-09-08 10:13:15 +08:00
kangfenmao
97ef3772ea chore(version): 0.6.10 2024-09-07 18:21:30 +08:00
kangfenmao
eb18be200e feat: Improved UI components and added new features
- Replaced 'CopyOutlined' icon with custom 'CopyIcon'.
- Replaced Topics component with RightSidebar component to match topicPosition settings.
- Removed unused imports and updated UI components in the Inputbar.
- Implemented a new Token Count component for displaying context and estimated token information in the input bar.
- Adjusted the height of code block header.
- Added functionality to toggle theme opacity.
- Added functionality to dynamically change the sidebar border style based on stored settings.
- Updated CSS styles for dynamic topic list width and padding adjustments.
- Removed unused import and styles to improve code efficiency and reduce clutter.
2024-09-07 18:11:27 +08:00
kangfenmao
467e97ff4b feat: Improved model selection and unique id generation
- Improved dropdown menu selection logic for models.
- Changes improve ModelSettings component to use getModelUniqId function for model identifiers.
- Added modeling service functionality to generate unique model identifiers.
2024-09-07 18:11:13 +08:00
kangfenmao
27b802d3c2 chore(version): 0.6.9 2024-09-06 18:04:11 +08:00
kangfenmao
37b0a175f7 feat: Add theme switching to Navbar
- Added a new theme switching functionality to the Navbar.
2024-09-06 18:03:06 +08:00
kangfenmao
b2b79f12a2 feat: Enhanced code block styling in Markdown editor
- Added styles for code blocks in markdown to match the application's design.
- Improved the rendering of code blocks in the Markdown editor by adding a border and changing the default display in dark mode.
2024-09-06 17:58:15 +08:00
110 changed files with 4546 additions and 2296 deletions

View File

@@ -15,6 +15,7 @@ module.exports = {
'@typescript-eslint/no-non-null-asserted-optional-chain': 'off', '@typescript-eslint/no-non-null-asserted-optional-chain': 'off',
'react/prop-types': 'off', 'react/prop-types': 'off',
'simple-import-sort/imports': 'error', 'simple-import-sort/imports': 'error',
'simple-import-sort/exports': 'error' 'simple-import-sort/exports': 'error',
'react/no-is-mounted': 'off'
} }
} }

View File

@@ -29,5 +29,6 @@
}, },
"[markdown]": { "[markdown]": {
"files.trimTrailingWhitespace": false "files.trimTrailingWhitespace": false
} },
"i18n-ally.localesPaths": ["src/renderer/src/i18n"]
} }

View File

@@ -8,8 +8,10 @@ files:
- '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}' - '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}' - '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}' - '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
- '!src/*' - '!src'
- '!local' - '!local'
- '!scripts'
- '!resources'
asarUnpack: asarUnpack:
- resources/** - resources/**
win: win:
@@ -40,7 +42,10 @@ dmg:
artifactName: ${productName}-${version}-${arch}.${ext} artifactName: ${productName}-${version}-${arch}.${ext}
linux: linux:
target: target:
- AppImage - target: AppImage
arch:
- arm64
- x64
# - snap # - snap
# - deb # - deb
maintainer: electronjs.org maintainer: electronjs.org
@@ -57,6 +62,14 @@ electronDownload:
afterSign: scripts/notarize.js afterSign: scripts/notarize.js
releaseInfo: releaseInfo:
releaseNotes: | releaseNotes: |
智能助理和消息列表合并 本次更新:
优化输入框样式 支持话题导出为图片
提升小程序稳定性 启动界面增加LOGO显示避免空白
修复输入框光标位置粘贴文字问题
修复暂停生成导致消息显示错乱问题
修复公式渲染异常情况
修复 Anthropic API 地址错误问题
近期更新:
增加了30多种文本文档格式选择
支持粘贴图片和文件到聊天输入框
支持将对话移动到其他智能体了

View File

@@ -7,7 +7,8 @@ export default defineConfig({
plugins: [externalizeDepsPlugin()], plugins: [externalizeDepsPlugin()],
resolve: { resolve: {
alias: { alias: {
ollama: resolve('ollama/src') '@types': resolve('src/renderer/src/types'),
'@main': resolve('src/main')
} }
} }
}, },

View File

@@ -1,6 +1,6 @@
{ {
"name": "CherryStudio", "name": "CherryStudio",
"version": "0.6.8", "version": "0.7.6",
"private": true, "private": true,
"description": "A powerful AI assistant for producer.", "description": "A powerful AI assistant for producer.",
"main": "./out/main/index.js", "main": "./out/main/index.js",
@@ -34,7 +34,8 @@
"electron-log": "^5.1.5", "electron-log": "^5.1.5",
"electron-store": "^8.2.0", "electron-store": "^8.2.0",
"electron-updater": "^6.1.7", "electron-updater": "^6.1.7",
"electron-window-state": "^5.0.3" "electron-window-state": "^5.0.3",
"html2canvas": "^1.4.1"
}, },
"devDependencies": { "devDependencies": {
"@anthropic-ai/sdk": "^0.24.3", "@anthropic-ai/sdk": "^0.24.3",
@@ -54,9 +55,12 @@
"axios": "^1.7.3", "axios": "^1.7.3",
"browser-image-compression": "^2.0.2", "browser-image-compression": "^2.0.2",
"dayjs": "^1.11.11", "dayjs": "^1.11.11",
"dexie": "^4.0.8",
"dexie-react-hooks": "^1.1.7",
"dotenv-cli": "^7.4.2", "dotenv-cli": "^7.4.2",
"electron": "^28.3.3", "electron": "^28.3.3",
"electron-builder": "^24.9.1", "electron-builder": "^24.9.1",
"electron-devtools-installer": "^3.2.0",
"electron-vite": "^2.0.0", "electron-vite": "^2.0.0",
"emittery": "^1.0.3", "emittery": "^1.0.3",
"emoji-picker-element": "^1.22.1", "emoji-picker-element": "^1.22.1",
@@ -65,10 +69,11 @@
"eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-unused-imports": "^4.0.0", "eslint-plugin-unused-imports": "^4.0.0",
"gpt-tokens": "^1.3.6", "gpt-tokens": "^1.3.10",
"i18next": "^23.11.5", "i18next": "^23.11.5",
"localforage": "^1.10.0", "localforage": "^1.10.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"mime": "^4.0.4",
"openai": "^4.52.1", "openai": "^4.52.1",
"prettier": "^3.2.4", "prettier": "^3.2.4",
"react": "^18.2.0", "react": "^18.2.0",
@@ -87,7 +92,7 @@
"remark-math": "^6.0.0", "remark-math": "^6.0.0",
"sass": "^1.77.2", "sass": "^1.77.2",
"styled-components": "^6.1.11", "styled-components": "^6.1.11",
"typescript": "^5.3.3", "typescript": "^5.6.2",
"uuid": "^10.0.0", "uuid": "^10.0.0",
"vite": "^5.0.12" "vite": "^5.0.12"
}, },
@@ -96,8 +101,7 @@
"react-dom": "^17.0.0 || ^18.0.0" "react-dom": "^17.0.0 || ^18.0.0"
}, },
"resolutions": { "resolutions": {
"@electron/notarize": "2.3.2",
"@electron/notarize@npm:2.2.1": "patch:@electron/notarize@npm%3A2.3.2#~/.yarn/patches/@electron-notarize-npm-2.3.2-535908a4bd.patch" "@electron/notarize@npm:2.2.1": "patch:@electron/notarize@npm%3A2.3.2#~/.yarn/patches/@electron-notarize-npm-2.3.2-535908a4bd.patch"
}, },
"packageManager": "yarn@4.3.1" "packageManager": "yarn@4.5.0"
} }

View File

@@ -1,4 +1,22 @@
import fs from 'node:fs'
import { app } from 'electron'
import Store from 'electron-store' import Store from 'electron-store'
import path from 'path'
const isDev = process.env.NODE_ENV === 'development'
isDev && app.setPath('userData', app.getPath('userData') + 'Dev')
const getDataPath = () => {
const dataPath = path.join(app.getPath('userData'), 'Data')
if (!fs.existsSync(dataPath)) {
fs.mkdirSync(dataPath, { recursive: true })
}
return dataPath
}
export const DATA_PATH = getDataPath()
export const appConfig = new Store() export const appConfig = new Store()

9
src/main/env.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
VITE_MAIN_BUNDLE_ID: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

View File

@@ -1,5 +1,6 @@
import { electronApp, optimizer } from '@electron-toolkit/utils' import { electronApp, optimizer } from '@electron-toolkit/utils'
import { app, BrowserWindow } from 'electron' import { app, BrowserWindow } from 'electron'
import installExtension, { REDUX_DEVTOOLS } from 'electron-devtools-installer'
import { registerIpc } from './ipc' import { registerIpc } from './ipc'
import { updateUserDataPath } from './utils/upgrade' import { updateUserDataPath } from './utils/upgrade'
@@ -12,7 +13,7 @@ app.whenReady().then(async () => {
await updateUserDataPath() await updateUserDataPath()
// Set app user model id for windows // Set app user model id for windows
electronApp.setAppUserModelId('com.kangfenmao.CherryStudio') electronApp.setAppUserModelId(import.meta.env.VITE_MAIN_BUNDLE_ID || 'com.kangfenmao.CherryStudio')
// Default open or close DevTools by F12 in development // Default open or close DevTools by F12 in development
// and ignore CommandOrControl + R in production. // and ignore CommandOrControl + R in production.
@@ -30,6 +31,12 @@ app.whenReady().then(async () => {
const mainWindow = createMainWindow() const mainWindow = createMainWindow()
registerIpc(mainWindow, app) registerIpc(mainWindow, app)
if (process.env.NODE_ENV === 'development') {
installExtension(REDUX_DEVTOOLS)
.then((name) => console.log(`Added Extension: ${name}`))
.catch((err) => console.log('An error occurred: ', err))
}
}) })
// Quit when all windows are closed, except on macOS. There, it's common // Quit when all windows are closed, except on macOS. There, it's common

View File

@@ -1,11 +1,14 @@
import { BrowserWindow, ipcMain, session, shell } from 'electron' import { FileType } from '@types'
import { BrowserWindow, ipcMain, OpenDialogOptions, session, shell } from 'electron'
import { appConfig, titleBarOverlayDark, titleBarOverlayLight } from './config' import { appConfig, titleBarOverlayDark, titleBarOverlayLight } from './config'
import AppUpdater from './updater' import AppUpdater from './services/AppUpdater'
import { openFile, saveFile } from './utils/file' import FileManager from './services/FileManager'
import { compress, decompress } from './utils/zip' import { compress, decompress } from './utils/zip'
import { createMinappWindow } from './window' import { createMinappWindow } from './window'
const fileManager = new FileManager()
export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
const { autoUpdater } = new AppUpdater(mainWindow) const { autoUpdater } = new AppUpdater(mainWindow)
@@ -24,13 +27,27 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
session.defaultSession.setProxy(proxy ? { proxyRules: proxy } : {}) session.defaultSession.setProxy(proxy ? { proxyRules: proxy } : {})
}) })
ipcMain.handle('save-file', saveFile)
ipcMain.handle('open-file', openFile)
ipcMain.handle('reload', () => mainWindow.reload()) ipcMain.handle('reload', () => mainWindow.reload())
ipcMain.handle('zip:compress', (_, text: string) => compress(text)) ipcMain.handle('zip:compress', (_, text: string) => compress(text))
ipcMain.handle('zip:decompress', (_, text: Buffer) => decompress(text)) ipcMain.handle('zip:decompress', (_, text: Buffer) => decompress(text))
ipcMain.handle('file:open', fileManager.open)
ipcMain.handle('file:save', fileManager.save)
ipcMain.handle('file:saveImage', fileManager.saveImage)
ipcMain.handle('file:base64Image', async (_, id) => await fileManager.base64Image(id))
ipcMain.handle('file:select', async (_, options?: OpenDialogOptions) => await fileManager.selectFile(options))
ipcMain.handle('file:upload', async (_, file: FileType) => await fileManager.uploadFile(file))
ipcMain.handle('file:clear', async () => await fileManager.clear())
ipcMain.handle('file:read', async (_, id: string) => await fileManager.readFile(id))
ipcMain.handle('file:delete', async (_, id: string) => await fileManager.deleteFile(id))
ipcMain.handle('file:get', async (_, filePath: string) => await fileManager.getFile(filePath))
ipcMain.handle('file:create', async (_, fileName: string) => await fileManager.createTempFile(fileName))
ipcMain.handle(
'file:write',
async (_, filePath: string, data: Uint8Array | string) => await fileManager.writeFile(filePath, data)
)
ipcMain.handle('minapp', (_, args) => { ipcMain.handle('minapp', (_, args) => {
createMinappWindow({ createMinappWindow({
url: args.url, url: args.url,

View File

@@ -0,0 +1,271 @@
import { getFileType } from '@main/utils/file'
import { FileType } from '@types'
import * as crypto from 'crypto'
import {
app,
dialog,
OpenDialogOptions,
OpenDialogReturnValue,
SaveDialogOptions,
SaveDialogReturnValue
} from 'electron'
import logger from 'electron-log'
import * as fs from 'fs'
import { writeFileSync } from 'fs'
import { readFile } from 'fs/promises'
import * as path from 'path'
import { v4 as uuidv4 } from 'uuid'
class FileManager {
private storageDir: string
constructor() {
this.storageDir = path.join(app.getPath('userData'), 'Data', 'Files')
this.initStorageDir()
}
private initStorageDir(): void {
if (!fs.existsSync(this.storageDir)) {
fs.mkdirSync(this.storageDir, { recursive: true })
}
}
private async getFileHash(filePath: string): Promise<string> {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('md5')
const stream = fs.createReadStream(filePath)
stream.on('data', (data) => hash.update(data))
stream.on('end', () => resolve(hash.digest('hex')))
stream.on('error', reject)
})
}
async findDuplicateFile(filePath: string): Promise<FileType | null> {
const stats = fs.statSync(filePath)
const fileSize = stats.size
const files = await fs.promises.readdir(this.storageDir)
for (const file of files) {
const storedFilePath = path.join(this.storageDir, file)
const storedStats = fs.statSync(storedFilePath)
if (storedStats.size === fileSize) {
const [originalHash, storedHash] = await Promise.all([
this.getFileHash(filePath),
this.getFileHash(storedFilePath)
])
if (originalHash === storedHash) {
const ext = path.extname(file)
const id = path.basename(file, ext)
return {
id,
origin_name: file,
name: file + ext,
path: storedFilePath,
created_at: storedStats.birthtime,
size: storedStats.size,
ext,
type: getFileType(ext),
count: 2
}
}
}
}
return null
}
async selectFile(options?: OpenDialogOptions): Promise<FileType[] | null> {
const defaultOptions: OpenDialogOptions = {
properties: ['openFile']
}
const dialogOptions = { ...defaultOptions, ...options }
const result = await dialog.showOpenDialog(dialogOptions)
if (result.canceled || result.filePaths.length === 0) {
return null
}
const fileMetadataPromises = result.filePaths.map(async (filePath) => {
const stats = fs.statSync(filePath)
const ext = path.extname(filePath)
const fileType = getFileType(ext)
return {
id: uuidv4(),
origin_name: path.basename(filePath),
name: path.basename(filePath),
path: filePath,
created_at: stats.birthtime,
size: stats.size,
ext: ext,
type: fileType,
count: 1
}
})
return Promise.all(fileMetadataPromises)
}
async uploadFile(file: FileType): Promise<FileType> {
const duplicateFile = await this.findDuplicateFile(file.path)
if (duplicateFile) {
return duplicateFile
}
const uuid = uuidv4()
const origin_name = path.basename(file.path)
const ext = path.extname(origin_name)
const destPath = path.join(this.storageDir, uuid + ext)
await fs.promises.copyFile(file.path, destPath)
const stats = await fs.promises.stat(destPath)
const fileType = getFileType(ext)
const fileMetadata: FileType = {
id: uuid,
origin_name,
name: uuid + ext,
path: destPath,
created_at: stats.birthtime,
size: stats.size,
ext: ext,
type: fileType,
count: 1
}
return fileMetadata
}
async getFile(filePath: string): Promise<FileType | null> {
if (!fs.existsSync(filePath)) {
return null
}
const stats = fs.statSync(filePath)
const ext = path.extname(filePath)
const fileType = getFileType(ext)
const fileInfo: FileType = {
id: uuidv4(),
origin_name: path.basename(filePath),
name: path.basename(filePath),
path: filePath,
created_at: stats.birthtime,
size: stats.size,
ext: ext,
type: fileType,
count: 1
}
return fileInfo
}
async deleteFile(id: string): Promise<void> {
await fs.promises.unlink(path.join(this.storageDir, id))
}
async readFile(id: string): Promise<string> {
const filePath = path.join(this.storageDir, id)
return fs.readFileSync(filePath, 'utf8')
}
async createTempFile(fileName: string): Promise<string> {
const tempDir = path.join(app.getPath('temp'), 'CherryStudio')
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true })
}
const tempFilePath = path.join(tempDir, `temp_file_${uuidv4()}_${fileName}`)
return tempFilePath
}
async writeFile(filePath: string, data: Uint8Array | string): Promise<void> {
await fs.promises.writeFile(filePath, data)
}
async base64Image(id: string): Promise<{ mime: string; base64: string; data: string }> {
const filePath = path.join(this.storageDir, id)
const data = await fs.promises.readFile(filePath)
const base64 = data.toString('base64')
const mime = `image/${path.extname(filePath).slice(1)}`
return {
mime,
base64,
data: `data:${mime};base64,${base64}`
}
}
async clear(): Promise<void> {
await fs.promises.rmdir(this.storageDir, { recursive: true })
await this.initStorageDir()
}
async open(
_: Electron.IpcMainInvokeEvent,
options: OpenDialogOptions
): Promise<{ fileName: string; content: Buffer } | null> {
try {
const result: OpenDialogReturnValue = await dialog.showOpenDialog({
title: '打开文件',
properties: ['openFile'],
filters: [{ name: '所有文件', extensions: ['*'] }],
...options
})
if (!result.canceled && result.filePaths.length > 0) {
const filePath = result.filePaths[0]
const fileName = filePath.split('/').pop() || ''
const content = await readFile(filePath)
return { fileName, content }
}
return null
} catch (err) {
logger.error('[IPC - Error]', 'An error occurred opening the file:', err)
return null
}
}
async save(
_: Electron.IpcMainInvokeEvent,
fileName: string,
content: string,
options?: SaveDialogOptions
): Promise<void> {
try {
const result: SaveDialogReturnValue = await dialog.showSaveDialog({
title: '保存文件',
defaultPath: fileName,
...options
})
if (!result.canceled && result.filePath) {
await writeFileSync(result.filePath, content, { encoding: 'utf-8' })
}
} catch (err) {
logger.error('[IPC - Error]', 'An error occurred saving the file:', err)
}
}
async saveImage(_: Electron.IpcMainInvokeEvent, name: string, data: string): Promise<void> {
try {
const filePath = dialog.showSaveDialogSync({
defaultPath: `${name}.png`,
filters: [{ name: 'PNG Image', extensions: ['png'] }]
})
if (filePath) {
const base64Data = data.replace(/^data:image\/png;base64,/, '')
fs.writeFileSync(filePath, base64Data, 'base64')
}
} catch (error) {
logger.error('[IPC - Error]', 'An error occurred saving the image:', error)
}
}
}
export default FileManager

View File

@@ -1,55 +1,106 @@
import { dialog, OpenDialogOptions, OpenDialogReturnValue, SaveDialogOptions, SaveDialogReturnValue } from 'electron' import { FileTypes } from '../../renderer/src/types'
import logger from 'electron-log'
import { writeFile } from 'fs'
import { readFile } from 'fs/promises'
export async function saveFile( export function getFileType(ext: string): FileTypes {
_: Electron.IpcMainInvokeEvent, const imageExts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp']
fileName: string, const videoExts = ['.mp4', '.avi', '.mov', '.wmv', '.flv', '.mkv']
content: string, const audioExts = ['.mp3', '.wav', '.ogg', '.flac', '.aac']
options?: SaveDialogOptions const documentExts = ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx']
): Promise<void> { const textExts = [
try { '.txt', // 普通文本文件
const result: SaveDialogReturnValue = await dialog.showSaveDialog({ '.md', // Markdown 文件
title: '保存文件', '.mdx', // Markdown 文件
defaultPath: fileName, '.html', // HTML 文件
...options '.htm', // HTML 文件的另一种扩展名
}) '.xml', // XML 文件
'.json', // JSON 文件
'.yaml', // YAML 文件
'.yml', // YAML 文件的另一种扩展名
'.csv', // 逗号分隔值文件
'.tsv', // 制表符分隔值文件
'.ini', // 配置文件
'.log', // 日志文件
'.rtf', // 富文本格式文件
'.tex', // LaTeX 文件
'.srt', // 字幕文件
'.xhtml', // XHTML 文件
'.nfo', // 信息文件(主要用于场景发布)
'.conf', // 配置文件
'.config', // 配置文件
'.env', // 环境变量文件
'.properties', // 配置属性文件
'.latex', // LaTeX 文档文件
'.rst', // reStructuredText 文件
'.php', // PHP 脚本文件,包含嵌入的 HTML
'.js', // JavaScript 文件(部分是文本,部分可能包含代码)
'.ts', // TypeScript 文件
'.jsp', // JavaServer Pages 文件
'.aspx', // ASP.NET 文件
'.bat', // Windows 批处理文件
'.sh', // Unix/Linux Shell 脚本文件
'.py', // Python 脚本文件
'.rb', // Ruby 脚本文件
'.pl', // Perl 脚本文件
'.sql', // SQL 脚本文件
'.css', // Cascading Style Sheets 文件
'.less', // Less CSS 预处理器文件
'.scss', // Sass CSS 预处理器文件
'.sass', // Sass 文件
'.styl', // Stylus CSS 预处理器文件
'.coffee', // CoffeeScript 文件
'.ino', // Arduino 代码文件
'.ino', // Arduino 代码文件
'.asm', // Assembly 语言文件
'.go', // Go 语言文件
'.scala', // Scala 语言文件
'.swift', // Swift 语言文件
'.kt', // Kotlin 语言文件
'.rs', // Rust 语言文件
'.lua', // Lua 语言文件
'.groovy', // Groovy 语言文件
'.dart', // Dart 语言文件
'.hs', // Haskell 语言文件
'.clj', // Clojure 语言文件
'.cljs', // ClojureScript 语言文件
'.elm', // Elm 语言文件
'.erl', // Erlang 语言文件
'.ex', // Elixir 语言文件
'.exs', // Elixir 脚本文件
'.pug', // Pug (formerly Jade) 模板文件
'.haml', // Haml 模板文件
'.slim', // Slim 模板文件
'.tpl', // 模板文件(通用)
'.ejs', // Embedded JavaScript 模板文件
'.hbs', // Handlebars 模板文件
'.mustache', // Mustache 模板文件
'.jade', // Jade 模板文件 (已重命名为 Pug)
'.twig', // Twig 模板文件
'.blade', // Blade 模板文件 (Laravel)
'.vue', // Vue.js 单文件组件
'.jsx', // React JSX 文件
'.tsx', // React TSX 文件
'.graphql', // GraphQL 查询语言文件
'.gql', // GraphQL 查询语言文件
'.proto', // Protocol Buffers 文件
'.thrift', // Thrift 文件
'.toml', // TOML 配置文件
'.edn', // Clojure 数据表示文件
'.cake', // CakePHP 配置文件
'.ctp', // CakePHP 视图文件
'.cfm', // ColdFusion 标记语言文件
'.cfc', // ColdFusion 组件文件
'.m', // Objective-C 源文件
'.mm', // Objective-C++ 源文件
'.gradle', // Gradle 构建文件
'.groovy', // Gradle 构建文件
'.gradle', // Gradle 构建文件
'.kts' // Kotlin Script 文件
]
if (!result.canceled && result.filePath) { ext = ext.toLowerCase()
writeFile(result.filePath, content, { encoding: 'utf-8' }, (err) => { if (imageExts.includes(ext)) return FileTypes.IMAGE
if (err) { if (videoExts.includes(ext)) return FileTypes.VIDEO
logger.error('[IPC - Error]', 'An error occurred saving the file:', err) if (audioExts.includes(ext)) return FileTypes.AUDIO
} if (textExts.includes(ext)) return FileTypes.TEXT
}) if (documentExts.includes(ext)) return FileTypes.DOCUMENT
} return FileTypes.OTHER
} catch (err) {
logger.error('[IPC - Error]', 'An error occurred saving the file:', err)
}
}
export async function openFile(
_: Electron.IpcMainInvokeEvent,
options: OpenDialogOptions
): Promise<{ fileName: string; content: Buffer } | null> {
try {
const result: OpenDialogReturnValue = await dialog.showOpenDialog({
title: '打开文件',
properties: ['openFile'],
filters: [{ name: '所有文件', extensions: ['*'] }],
...options
})
if (!result.canceled && result.filePaths.length > 0) {
const filePath = result.filePaths[0]
const fileName = filePath.split('/').pop() || ''
const content = await readFile(filePath)
return { fileName, content }
}
return null
} catch (err) {
logger.error('[IPC - Error]', 'An error occurred opening the file:', err)
return null
}
} }

7
src/main/utils/index.ts Normal file
View File

@@ -0,0 +1,7 @@
import path from 'node:path'
import { app } from 'electron'
export function getResourcePath() {
return path.join(app.getAppPath(), 'resources')
}

View File

@@ -1,4 +1,5 @@
import { ElectronAPI } from '@electron-toolkit/preload' import { ElectronAPI } from '@electron-toolkit/preload'
import { FileType } from '@renderer/types'
import type { OpenDialogOptions } from 'electron' import type { OpenDialogOptions } from 'electron'
declare global { declare global {
@@ -13,13 +14,25 @@ declare global {
checkForUpdate: () => void checkForUpdate: () => void
openWebsite: (url: string) => void openWebsite: (url: string) => void
setProxy: (proxy: string | undefined) => void setProxy: (proxy: string | undefined) => void
saveFile: (path: string, content: string | NodeJS.ArrayBufferView, options?: SaveDialogOptions) => void
openFile: (options?: OpenDialogOptions) => Promise<{ fileName: string; content: Buffer } | null>
setTheme: (theme: 'light' | 'dark') => void setTheme: (theme: 'light' | 'dark') => void
minApp: (options: { url: string; windowOptions?: Electron.BrowserWindowConstructorOptions }) => void minApp: (options: { url: string; windowOptions?: Electron.BrowserWindowConstructorOptions }) => void
reload: () => void reload: () => void
compress: (text: string) => Promise<Buffer> compress: (text: string) => Promise<Buffer>
decompress: (text: Buffer) => Promise<string> decompress: (text: Buffer) => Promise<string>
file: {
select: (options?: OpenDialogOptions) => Promise<FileType[] | null>
upload: (file: FileType) => Promise<FileType>
delete: (fileId: string) => Promise<void>
read: (fileId: string) => Promise<string>
base64Image: (fileId: string) => Promise<{ mime: string; base64: string; data: string }>
clear: () => Promise<void>
get: (filePath: string) => Promise<FileType | null>
create: (fileName: string) => Promise<string>
write: (filePath: string, data: Uint8Array | string) => Promise<void>
open: (options?: OpenDialogOptions) => Promise<{ fileName: string; content: Buffer } | null>
save: (path: string, content: string | NodeJS.ArrayBufferView, options?: SaveDialogOptions) => void
saveImage: (name: string, data: string) => void
}
} }
} }
} }

View File

@@ -1,5 +1,5 @@
import { electronAPI } from '@electron-toolkit/preload' import { electronAPI } from '@electron-toolkit/preload'
import { contextBridge, ipcRenderer } from 'electron' import { contextBridge, ipcRenderer, OpenDialogOptions } from 'electron'
// Custom APIs for renderer // Custom APIs for renderer
const api = { const api = {
@@ -9,13 +9,25 @@ const api = {
setProxy: (proxy: string) => ipcRenderer.invoke('set-proxy', proxy), setProxy: (proxy: string) => ipcRenderer.invoke('set-proxy', proxy),
setTheme: (theme: 'light' | 'dark') => ipcRenderer.invoke('set-theme', theme), setTheme: (theme: 'light' | 'dark') => ipcRenderer.invoke('set-theme', theme),
minApp: (url: string) => ipcRenderer.invoke('minapp', url), minApp: (url: string) => ipcRenderer.invoke('minapp', url),
openFile: (options?: { decompress: boolean }) => ipcRenderer.invoke('open-file', options),
reload: () => ipcRenderer.invoke('reload'), reload: () => ipcRenderer.invoke('reload'),
saveFile: (path: string, content: string, options?: { compress: boolean }) => {
ipcRenderer.invoke('save-file', path, content, options)
},
compress: (text: string) => ipcRenderer.invoke('zip:compress', text), compress: (text: string) => ipcRenderer.invoke('zip:compress', text),
decompress: (text: Buffer) => ipcRenderer.invoke('zip:decompress', text) decompress: (text: Buffer) => ipcRenderer.invoke('zip:decompress', text),
file: {
select: (options?: OpenDialogOptions) => ipcRenderer.invoke('file:select', options),
upload: (filePath: string) => ipcRenderer.invoke('file:upload', filePath),
delete: (fileId: string) => ipcRenderer.invoke('file:delete', fileId),
read: (fileId: string) => ipcRenderer.invoke('file:read', fileId),
base64Image: (fileId: string) => ipcRenderer.invoke('file:base64Image', fileId),
clear: () => ipcRenderer.invoke('file:clear'),
get: (filePath: string) => ipcRenderer.invoke('file:get', filePath),
create: (fileName: string) => ipcRenderer.invoke('file:create', fileName),
write: (filePath: string, data: Uint8Array | string) => ipcRenderer.invoke('file:write', filePath, data),
open: (options?: { decompress: boolean }) => ipcRenderer.invoke('file:open', options),
save: (path: string, content: string, options?: { compress: boolean }) => {
return ipcRenderer.invoke('file:save', path, content, options)
},
saveImage: (name: string, data: string) => ipcRenderer.invoke('file:saveImage', name, data)
}
} }
// Use `contextBridge` APIs to expose Electron APIs to // Use `contextBridge` APIs to expose Electron APIs to

View File

@@ -5,10 +5,26 @@
<meta name="viewport" content="initial-scale=1, width=device-width" /> <meta name="viewport" content="initial-scale=1, width=device-width" />
<meta <meta
http-equiv="Content-Security-Policy" http-equiv="Content-Security-Policy"
content="default-src 'self'; connect-src *; script-src 'self' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: *; frame-src * file:" /> content="default-src 'self'; connect-src *; script-src 'self' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: *; frame-src * file:" />
<style>
#spinner {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
#spinner img {
width: 100px;
height: 100px;
border-radius: 50%;
}
</style>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<div id="spinner">
<img src="/src/assets/images/logo.png" />
</div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>

View File

@@ -1,3 +1,5 @@
import '@renderer/databases'
import store, { persistor } from '@renderer/store' import store, { persistor } from '@renderer/store'
import { Provider } from 'react-redux' import { Provider } from 'react-redux'
import { HashRouter, Route, Routes } from 'react-router-dom' import { HashRouter, Route, Routes } from 'react-router-dom'
@@ -9,6 +11,7 @@ import AntdProvider from './context/AntdProvider'
import { ThemeProvider } from './context/ThemeProvider' import { ThemeProvider } from './context/ThemeProvider'
import AgentsPage from './pages/agents/AgentsPage' import AgentsPage from './pages/agents/AgentsPage'
import AppsPage from './pages/apps/AppsPage' import AppsPage from './pages/apps/AppsPage'
import FilesPage from './pages/files/FilesPage'
import HomePage from './pages/home/HomePage' import HomePage from './pages/home/HomePage'
import SettingsPage from './pages/settings/SettingsPage' import SettingsPage from './pages/settings/SettingsPage'
import TranslatePage from './pages/translate/TranslatePage' import TranslatePage from './pages/translate/TranslatePage'
@@ -24,6 +27,7 @@ function App(): JSX.Element {
<Sidebar /> <Sidebar />
<Routes> <Routes>
<Route path="/" element={<HomePage />} /> <Route path="/" element={<HomePage />} />
<Route path="/files" element={<FilesPage />} />
<Route path="/agents" element={<AgentsPage />} /> <Route path="/agents" element={<AgentsPage />} />
<Route path="/translate" element={<TranslatePage />} /> <Route path="/translate" element={<TranslatePage />} />
<Route path="/apps" element={<AppsPage />} /> <Route path="/apps" element={<AppsPage />} />

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,3 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M16 0C7.16 0 0 7.16 0 16C0 23.08 4.58 29.06 10.94 31.18C11.74 31.32 12.04 30.84 12.04 30.42C12.04 30.04 12.02 28.78 12.02 27.44C8 28.18 6.96 26.46 6.64 25.56C6.46 25.1 5.68 23.68 5 23.3C4.44 23 3.64 22.26 4.98 22.24C6.24 22.22 7.14 23.4 7.44 23.88C8.88 26.3 11.18 25.62 12.1 25.2C12.24 24.16 12.66 23.46 13.12 23.06C9.56 22.66 5.84 21.28 5.84 15.16C5.84 13.42 6.46 11.98 7.48 10.86C7.32 10.46 6.76 8.82 7.64 6.62C7.64 6.62 8.98 6.2 12.04 8.26C13.32 7.9 14.68 7.72 16.04 7.72C17.4 7.72 18.76 7.9 20.04 8.26C23.1 6.18 24.44 6.62 24.44 6.62C25.32 8.82 24.76 10.46 24.6 10.86C25.62 11.98 26.24 13.4 26.24 15.16C26.24 21.3 22.5 22.66 18.94 23.06C19.52 23.56 20.02 24.52 20.02 26.02C20.02 28.16 20 29.88 20 30.42C20 30.84 20.3 31.34 21.1 31.18C27.42 29.06 32 23.06 32 16C32 7.16 24.84 0 16 0V0Z" fill="#24292E"/>
</svg>

After

Width:  |  Height:  |  Size: 959 B

View File

@@ -22,16 +22,16 @@
--color-background: #181818; --color-background: #181818;
--color-background-soft: var(--color-black-soft); --color-background-soft: var(--color-black-soft);
--color-background-mute: var(--color-black-mute); --color-background-mute: var(--color-black-soft);
--color-primary: #135200; --color-primary: #00b96b;
--color-primary-soft: #13520099; --color-primary-soft: #00b96b99;
--color-primary-mute: #13520033; --color-primary-mute: #00b96b33;
--color-text: var(--color-text-1); --color-text: var(--color-text-1);
--color-icon: #ffffff99; --color-icon: #ffffff99;
--color-icon-white: #ffffff; --color-icon-white: #ffffff;
--color-border: #000; --color-border: #ffffff20;
--color-border-soft: #ffffff20; --color-border-soft: #ffffff20;
--color-error: #f44336; --color-error: #f44336;
--color-link: #1677ff; --color-link: #1677ff;
@@ -48,9 +48,9 @@
--status-bar-height: 40px; --status-bar-height: 40px;
--input-bar-height: 85px; --input-bar-height: 85px;
--assistants-width: 240px; --assistants-width: 280px;
--topic-list-width: 270px; --topic-list-width: 280px;
--settings-width: var(--assistants-width); --settings-width: 260px;
} }
body[theme-mode='light'] { body[theme-mode='light'] {
@@ -182,6 +182,12 @@ body,
-webkit-app-region: no-drag; -webkit-app-region: no-drag;
} }
.text-nowrap {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.minapp-drawer { .minapp-drawer {
.ant-drawer-content-wrapper { .ant-drawer-content-wrapper {
box-shadow: none; box-shadow: none;
@@ -208,3 +214,26 @@ body,
.ant-drawer-header { .ant-drawer-header {
-webkit-app-region: no-drag; -webkit-app-region: no-drag;
} }
.segmented-tab {
.ant-segmented-item-label {
align-items: center;
display: flex;
flex-direction: row;
justify-content: center;
font-size: 13px;
}
.iconfont {
font-size: 13px;
margin-left: -2px;
}
.anticon-setting {
font-size: 12px;
}
.icon-business-smart-assistant {
margin-right: -2px;
}
.ant-segmented-item-icon + * {
margin-left: 4px;
}
}

View File

@@ -101,7 +101,8 @@
font-family: 'Courier New', Courier, monospace; font-family: 'Courier New', Courier, monospace;
} }
p code { p code,
li code {
background: var(--color-background-mute); background: var(--color-background-mute);
padding: 3px 5px; padding: 3px 5px;
border-radius: 5px; border-radius: 5px;
@@ -112,15 +113,26 @@
border-radius: 5px; border-radius: 5px;
overflow-x: auto; overflow-x: auto;
font-family: 'Fira Code', 'Courier New', Courier, monospace; font-family: 'Fira Code', 'Courier New', Courier, monospace;
background-color: var(--color-background-mute);
&:not(pre pre) {
> code:not(pre pre > code) {
padding: 15px;
display: block;
}
}
pre { pre {
margin: 0 !important; margin: 0 !important;
}
code { code {
background: none; background: none;
padding: 0; padding: 0;
border-radius: 0; border-radius: 0;
} }
} }
}
pre + pre {
margin-top: 10px;
}
blockquote { blockquote {
margin: 1em 0; margin: 1em 0;

View File

@@ -2,6 +2,7 @@ import {
DragDropContext, DragDropContext,
Draggable, Draggable,
Droppable, Droppable,
DroppableProps,
DropResult, DropResult,
OnDragEndResponder, OnDragEndResponder,
OnDragStartResponder, OnDragStartResponder,
@@ -13,13 +14,24 @@ import { FC } from 'react'
interface Props<T> { interface Props<T> {
list: T[] list: T[]
style?: React.CSSProperties style?: React.CSSProperties
listStyle?: React.CSSProperties
children: (item: T, index: number) => React.ReactNode children: (item: T, index: number) => React.ReactNode
onUpdate: (list: T[]) => void onUpdate: (list: T[]) => void
onDragStart?: OnDragStartResponder onDragStart?: OnDragStartResponder
onDragEnd?: OnDragEndResponder onDragEnd?: OnDragEndResponder
droppableProps?: Partial<DroppableProps>
} }
const DragableList: FC<Props<any>> = ({ children, list, style, onDragStart, onUpdate, onDragEnd }) => { const DragableList: FC<Props<any>> = ({
children,
list,
style,
listStyle,
droppableProps,
onDragStart,
onUpdate,
onDragEnd
}) => {
const _onDragEnd = (result: DropResult, provided: ResponderProvided) => { const _onDragEnd = (result: DropResult, provided: ResponderProvided) => {
onDragEnd?.(result, provided) onDragEnd?.(result, provided)
if (result.destination) { if (result.destination) {
@@ -32,17 +44,17 @@ const DragableList: FC<Props<any>> = ({ children, list, style, onDragStart, onUp
return ( return (
<DragDropContext onDragStart={onDragStart} onDragEnd={_onDragEnd}> <DragDropContext onDragStart={onDragStart} onDragEnd={_onDragEnd}>
<Droppable droppableId="droppable"> <Droppable droppableId="droppable" {...droppableProps}>
{(provided) => ( {(provided) => (
<div {...provided.droppableProps} ref={provided.innerRef}> <div {...provided.droppableProps} ref={provided.innerRef} style={{ ...style }}>
{list.map((item, index) => ( {list.map((item, index) => (
<Draggable key={`draggable_${item.id}_${index}`} draggableId={item.id} index={index}> <Draggable key={`draggable_${item.id}_${index}`} draggableId={item.id} index={index} {...droppableProps}>
{(provided) => ( {(provided) => (
<div <div
ref={provided.innerRef} ref={provided.innerRef}
{...provided.draggableProps} {...provided.draggableProps}
{...provided.dragHandleProps} {...provided.dragHandleProps}
style={{ ...provided.draggableProps.style, marginBottom: 8, ...style }}> style={{ ...provided.draggableProps.style, marginBottom: 8, ...listStyle }}>
{children(item, index)} {children(item, index)}
</div> </div>
)} )}

View File

@@ -0,0 +1,15 @@
import { EyeOutlined } from '@ant-design/icons'
import React, { FC } from 'react'
import styled from 'styled-components'
const VisionIcon: FC<React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>> = (props) => {
return <Icon {...(props as any)} />
}
const Icon = styled(EyeOutlined)`
color: var(--color-primary);
font-size: 14px;
margin-left: 4px;
`
export default VisionIcon

View File

@@ -30,6 +30,8 @@ const PopupContainer: React.FC<Props> = ({ app, resolve }) => {
setTimeout(() => resolve({}), 300) setTimeout(() => resolve({}), 300)
} }
MinApp.onClose = onClose
const onReload = () => { const onReload = () => {
if (webviewRef.current) { if (webviewRef.current) {
webviewRef.current.src = app.url webviewRef.current.src = app.url
@@ -163,6 +165,7 @@ const Button = styled.div`
export default class MinApp { export default class MinApp {
static topviewId = 0 static topviewId = 0
static onClose = () => {}
static close() { static close() {
TopView.hide('MinApp') TopView.hide('MinApp')
store.dispatch(setMinappShow(false)) store.dispatch(setMinappShow(false))

View File

@@ -1,14 +1,18 @@
import { SearchOutlined } from '@ant-design/icons'
import { TopView } from '@renderer/components/TopView' import { TopView } from '@renderer/components/TopView'
import systemAgents from '@renderer/config/agents.json' import systemAgents from '@renderer/config/agents.json'
import { useAgents } from '@renderer/hooks/useAgents' import { useAgents } from '@renderer/hooks/useAgents'
import { useAssistants, useDefaultAssistant } from '@renderer/hooks/useAssistant' import { useAssistants, useDefaultAssistant } from '@renderer/hooks/useAssistant'
import { covertAgentToAssistant } from '@renderer/services/assistant' import { covertAgentToAssistant } from '@renderer/services/assistant'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/event'
import { Agent, Assistant } from '@renderer/types' import { Agent, Assistant } from '@renderer/types'
import { Input, Modal, Tag } from 'antd' import { Divider, Input, InputRef, Modal, Tag } from 'antd'
import { useMemo, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
import { HStack } from '../Layout'
interface Props { interface Props {
resolve: (value: Assistant | undefined) => void resolve: (value: Assistant | undefined) => void
} }
@@ -20,6 +24,7 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
const [searchText, setSearchText] = useState('') const [searchText, setSearchText] = useState('')
const { defaultAssistant } = useDefaultAssistant() const { defaultAssistant } = useDefaultAssistant()
const { assistants, addAssistant } = useAssistants() const { assistants, addAssistant } = useAssistants()
const inputRef = useRef<InputRef>(null)
const defaultAgent: Agent = useMemo( const defaultAgent: Agent = useMemo(
() => ({ () => ({
@@ -50,6 +55,7 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
const assistant = covertAgentToAssistant(agent) const assistant = covertAgentToAssistant(agent)
addAssistant(assistant) addAssistant(assistant)
setTimeout(() => EventEmitter.emit(EVENT_NAMES.SHOW_ASSISTANTS), 0)
resolve(assistant) resolve(assistant)
setOpen(false) setOpen(false)
} }
@@ -63,30 +69,51 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
AddAssistantPopup.hide() AddAssistantPopup.hide()
} }
useEffect(() => {
open && setTimeout(() => inputRef.current?.focus(), 0)
}, [open])
return ( return (
<Modal <Modal
style={{ marginTop: '5vh' }} centered
title={t('chat.add.assistant.title')}
open={open} open={open}
onCancel={onCancel} onCancel={onCancel}
afterClose={onClose} afterClose={onClose}
transitionName="ant-move-down" transitionName="ant-move-down"
maskTransitionName="ant-fade" maskTransitionName="ant-fade"
styles={{ content: { borderRadius: 20, padding: 0, overflow: 'hidden', paddingBottom: 20 } }}
closeIcon={null}
footer={null}> footer={null}>
<HStack style={{ padding: '0 12px', marginTop: 5 }}>
<Input <Input
placeholder={t('common.search')} prefix={
<SearchIcon>
<SearchOutlined />
</SearchIcon>
}
ref={inputRef}
placeholder={t('assistants.search')}
value={searchText} value={searchText}
onChange={(e) => setSearchText(e.target.value)} onChange={(e) => setSearchText(e.target.value)}
allowClear allowClear
autoFocus autoFocus
style={{ marginBottom: 16 }} style={{ paddingLeft: 0 }}
bordered={false}
size="large"
/> />
</HStack>
<Divider style={{ margin: 0 }} />
<Container> <Container>
{agents.map((agent) => ( {agents.map((agent) => (
<AgentItem key={agent.id} onClick={() => onCreateAssistant(agent)}> <AgentItem
key={agent.id}
onClick={() => onCreateAssistant(agent)}
className={agent.id === 'default' ? 'default' : ''}>
<HStack alignItems="center" gap={5}>
{agent.emoji} {agent.name} {agent.emoji} {agent.name}
{agent.group === 'system' && <Tag color="orange">{t('agents.tag.system')}</Tag>} </HStack>
{agent.group === 'user' && <Tag color="green">{t('agents.tag.user')}</Tag>} {agent.group === 'system' && <Tag color="green">{t('agents.tag.system')}</Tag>}
{agent.group === 'user' && <Tag color="orange">{t('agents.tag.user')}</Tag>}
</AgentItem> </AgentItem>
))} ))}
</Container> </Container>
@@ -95,7 +122,9 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
} }
const Container = styled.div` const Container = styled.div`
padding: 0 12px;
height: 50vh; height: 50vh;
margin-top: 10px;
overflow-y: auto; overflow-y: auto;
&::-webkit-scrollbar { &::-webkit-scrollbar {
display: none; display: none;
@@ -107,12 +136,14 @@ const AgentItem = styled.div`
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 8px; padding: 10px 15px;
border-radius: 8px; border-radius: 8px;
user-select: none; user-select: none;
background-color: var(--color-background-soft);
margin-bottom: 8px; margin-bottom: 8px;
cursor: pointer; cursor: pointer;
&.default {
background-color: var(--color-background-soft);
}
.anticon { .anticon {
font-size: 16px; font-size: 16px;
color: var(--color-icon); color: var(--color-icon);
@@ -122,6 +153,18 @@ const AgentItem = styled.div`
} }
` `
const SearchIcon = styled.div`
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
background-color: var(--color-background-soft);
margin-right: 6px;
`
export default class AddAssistantPopup { export default class AddAssistantPopup {
static topviewId = 0 static topviewId = 0
static hide() { static hide() {

View File

@@ -41,7 +41,8 @@ const AssistantSettingPopupContainer: React.FC<Props> = ({ assistant, resolve })
onCancel={handleCancel} onCancel={handleCancel}
afterClose={onClose} afterClose={onClose}
transitionName="ant-move-down" transitionName="ant-move-down"
maskTransitionName="ant-fade"> maskTransitionName="ant-fade"
centered>
<Box mb={8}>{t('common.name')}</Box> <Box mb={8}>{t('common.name')}</Box>
<Input <Input
placeholder={t('common.assistant') + t('common.name')} placeholder={t('common.assistant') + t('common.name')}

View File

@@ -40,7 +40,7 @@ const PromptPopupContainer: React.FC<Props> = ({
} }
return ( return (
<Modal title={title} open={open} onOk={onOk} onCancel={handleCancel} afterClose={onClose}> <Modal title={title} open={open} onOk={onOk} onCancel={handleCancel} afterClose={onClose} centered>
<Box mb={8}>{message}</Box> <Box mb={8}>{message}</Box>
<Input <Input
placeholder={inputPlaceholder} placeholder={inputPlaceholder}

View File

@@ -1,6 +1,6 @@
import useAvatar from '@renderer/hooks/useAvatar' import useAvatar from '@renderer/hooks/useAvatar'
import { useSettings } from '@renderer/hooks/useSettings' import { useSettings } from '@renderer/hooks/useSettings'
import LocalStorage from '@renderer/services/storage' import ImageStorage from '@renderer/services/storage'
import { useAppDispatch } from '@renderer/store' import { useAppDispatch } from '@renderer/store'
import { setAvatar } from '@renderer/store/runtime' import { setAvatar } from '@renderer/store/runtime'
import { setUserName } from '@renderer/store/settings' import { setUserName } from '@renderer/store/settings'
@@ -44,7 +44,8 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
onOk={onOk} onOk={onOk}
onCancel={onCancel} onCancel={onCancel}
afterClose={onClose} afterClose={onClose}
transitionName="ant-move-down"> transitionName="ant-move-down"
centered>
<Center mt="30px"> <Center mt="30px">
<Upload <Upload
customRequest={() => {}} customRequest={() => {}}
@@ -55,8 +56,8 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
try { try {
const _file = file.originFileObj as File const _file = file.originFileObj as File
const compressedFile = await compressImage(_file) const compressedFile = await compressImage(_file)
await LocalStorage.storeImage('avatar', compressedFile) await ImageStorage.set('avatar', compressedFile)
dispatch(setAvatar(await LocalStorage.getImage('avatar'))) dispatch(setAvatar(await ImageStorage.get('avatar')))
} catch (error: any) { } catch (error: any) {
window.message.error(error.message) window.message.error(error.message)
} }

View File

@@ -66,7 +66,7 @@ const NavbarCenterContainer = styled.div`
` `
const NavbarRightContainer = styled.div` const NavbarRightContainer = styled.div`
min-width: var(--settings-width); min-width: var(--topic-list-width);
display: flex; display: flex;
align-items: center; align-items: center;
padding: 0 12px; padding: 0 12px;

View File

@@ -1,22 +1,22 @@
import { TranslationOutlined } from '@ant-design/icons' import { FolderOutlined, TranslationOutlined } from '@ant-design/icons'
import { isMac } from '@renderer/config/constant' import { isMac } from '@renderer/config/constant'
import { isLocalAi, UserAvatar } from '@renderer/config/env' import { isLocalAi, UserAvatar } from '@renderer/config/env'
import useAvatar from '@renderer/hooks/useAvatar' import useAvatar from '@renderer/hooks/useAvatar'
import { useSettings } from '@renderer/hooks/useSettings' import { useSettings } from '@renderer/hooks/useSettings'
import { useRuntime, useShowAssistants } from '@renderer/hooks/useStore' import { useRuntime } from '@renderer/hooks/useStore'
import { Avatar } from 'antd' import { Avatar } from 'antd'
import { FC } from 'react' import { FC } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useLocation, useNavigate } from 'react-router-dom' import { useLocation, useNavigate } from 'react-router-dom'
import styled from 'styled-components' import styled from 'styled-components'
import MinApp from '../MinApp'
import UserPopup from '../Popups/UserPopup' import UserPopup from '../Popups/UserPopup'
const Sidebar: FC = () => { const Sidebar: FC = () => {
const { pathname } = useLocation() const { pathname } = useLocation()
const avatar = useAvatar() const avatar = useAvatar()
const { minappShow } = useRuntime() const { minappShow } = useRuntime()
const { toggleShowAssistants } = useShowAssistants()
const { generating } = useRuntime() const { generating } = useRuntime()
const { t } = useTranslation() const { t } = useTranslation()
const navigate = useNavigate() const navigate = useNavigate()
@@ -37,23 +37,23 @@ const Sidebar: FC = () => {
navigate(path) navigate(path)
} }
const onToggleShowAssistants = () => {
pathname === '/' ? toggleShowAssistants() : navigate('/')
}
return ( return (
<Container style={{ backgroundColor: minappShow ? 'var(--navbar-background)' : sidebarBgColor }}> <Container
style={{
backgroundColor: minappShow ? 'var(--navbar-background)' : sidebarBgColor,
zIndex: minappShow ? 10000 : 'initial'
}}>
<AvatarImg src={avatar || UserAvatar} draggable={false} className="nodrag" onClick={onEditUser} /> <AvatarImg src={avatar || UserAvatar} draggable={false} className="nodrag" onClick={onEditUser} />
<MainMenus> <MainMenus>
<Menus> <Menus onClick={MinApp.onClose}>
<StyledLink onClick={onToggleShowAssistants}> <StyledLink onClick={() => to('/')}>
<Icon className={isRoute('/')}> <Icon className={isRoute('/')}>
<i className="iconfont icon-chat"></i> <i className="iconfont icon-chat" />
</Icon> </Icon>
</StyledLink> </StyledLink>
<StyledLink onClick={() => to('/agents')}> <StyledLink onClick={() => to('/agents')}>
<Icon className={isRoute('/agents')}> <Icon className={isRoute('/agents')}>
<i className="iconfont icon-business-smart-assistant"></i> <i className="iconfont icon-business-smart-assistant" />
</Icon> </Icon>
</StyledLink> </StyledLink>
<StyledLink onClick={() => to('/translate')}> <StyledLink onClick={() => to('/translate')}>
@@ -63,15 +63,20 @@ const Sidebar: FC = () => {
</StyledLink> </StyledLink>
<StyledLink onClick={() => to('/apps')}> <StyledLink onClick={() => to('/apps')}>
<Icon className={isRoute('/apps')}> <Icon className={isRoute('/apps')}>
<i className="iconfont icon-appstore"></i> <i className="iconfont icon-appstore" />
</Icon>
</StyledLink>
<StyledLink onClick={() => to('/files')}>
<Icon className={isRoute('/files')}>
<FolderOutlined />
</Icon> </Icon>
</StyledLink> </StyledLink>
</Menus> </Menus>
</MainMenus> </MainMenus>
<Menus> <Menus onClick={MinApp.onClose}>
<StyledLink onClick={() => to(isLocalAi ? '/settings/assistant' : '/settings/provider')}> <StyledLink onClick={() => to(isLocalAi ? '/settings/assistant' : '/settings/provider')}>
<Icon className={pathname.startsWith('/settings') ? 'active' : ''}> <Icon className={pathname.startsWith('/settings') ? 'active' : ''}>
<i className="iconfont icon-setting"></i> <i className="iconfont icon-setting" />
</Icon> </Icon>
</StyledLink> </StyledLink>
</Menus> </Menus>
@@ -94,8 +99,8 @@ const Container = styled.div`
` `
const AvatarImg = styled(Avatar)` const AvatarImg = styled(Avatar)`
width: 28px; width: 32px;
height: 28px; height: 32px;
background-color: var(--color-background-soft); background-color: var(--color-background-soft);
margin-bottom: ${isMac ? '12px' : '12px'}; margin-bottom: ${isMac ? '12px' : '12px'};
margin-top: ${isMac ? '5px' : '2px'}; margin-top: ${isMac ? '5px' : '2px'};
@@ -114,15 +119,16 @@ const Menus = styled.div`
` `
const Icon = styled.div` const Icon = styled.div`
width: 34px; width: 35px;
height: 34px; height: 35px;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
border-radius: 6px; border-radius: 50%;
margin-bottom: 5px; margin-bottom: 5px;
transition: background-color 0.2s ease; transition: background-color 0.2s ease;
-webkit-app-region: none; -webkit-app-region: none;
transition: all 0.2s ease;
.iconfont, .iconfont,
.anticon { .anticon {
color: var(--color-icon); color: var(--color-icon);

View File

@@ -7,3 +7,95 @@ export const platform = window.electron?.process?.platform
export const isMac = platform === 'darwin' export const isMac = platform === 'darwin'
export const isWindows = platform === 'win32' || platform === 'win64' export const isWindows = platform === 'win32' || platform === 'win64'
export const isLinux = platform === 'linux' export const isLinux = platform === 'linux'
export const imageExts = ['.jpg', '.png', '.jpeg']
export const textExts = [
'.txt', // 普通文本文件
'.md', // Markdown 文件
'.mdx', // Markdown 文件
'.html', // HTML 文件
'.htm', // HTML 文件的另一种扩展名
'.xml', // XML 文件
'.json', // JSON 文件
'.yaml', // YAML 文件
'.yml', // YAML 文件的另一种扩展名
'.csv', // 逗号分隔值文件
'.tsv', // 制表符分隔值文件
'.ini', // 配置文件
'.log', // 日志文件
'.rtf', // 富文本格式文件
'.tex', // LaTeX 文件
'.srt', // 字幕文件
'.xhtml', // XHTML 文件
'.nfo', // 信息文件(主要用于场景发布)
'.conf', // 配置文件
'.config', // 配置文件
'.env', // 环境变量文件
'.properties', // 配置属性文件
'.latex', // LaTeX 文档文件
'.rst', // reStructuredText 文件
'.php', // PHP 脚本文件,包含嵌入的 HTML
'.js', // JavaScript 文件(部分是文本,部分可能包含代码)
'.ts', // TypeScript 文件
'.jsp', // JavaServer Pages 文件
'.aspx', // ASP.NET 文件
'.bat', // Windows 批处理文件
'.sh', // Unix/Linux Shell 脚本文件
'.py', // Python 脚本文件
'.rb', // Ruby 脚本文件
'.pl', // Perl 脚本文件
'.sql', // SQL 脚本文件
'.css', // Cascading Style Sheets 文件
'.less', // Less CSS 预处理器文件
'.scss', // Sass CSS 预处理器文件
'.sass', // Sass 文件
'.styl', // Stylus CSS 预处理器文件
'.coffee', // CoffeeScript 文件
'.ino', // Arduino 代码文件
'.ino', // Arduino 代码文件
'.asm', // Assembly 语言文件
'.go', // Go 语言文件
'.scala', // Scala 语言文件
'.swift', // Swift 语言文件
'.kt', // Kotlin 语言文件
'.rs', // Rust 语言文件
'.lua', // Lua 语言文件
'.groovy', // Groovy 语言文件
'.dart', // Dart 语言文件
'.hs', // Haskell 语言文件
'.clj', // Clojure 语言文件
'.cljs', // ClojureScript 语言文件
'.elm', // Elm 语言文件
'.erl', // Erlang 语言文件
'.ex', // Elixir 语言文件
'.exs', // Elixir 脚本文件
'.pug', // Pug (formerly Jade) 模板文件
'.haml', // Haml 模板文件
'.slim', // Slim 模板文件
'.tpl', // 模板文件(通用)
'.ejs', // Embedded JavaScript 模板文件
'.hbs', // Handlebars 模板文件
'.mustache', // Mustache 模板文件
'.jade', // Jade 模板文件 (已重命名为 Pug)
'.twig', // Twig 模板文件
'.blade', // Blade 模板文件 (Laravel)
'.vue', // Vue.js 单文件组件
'.jsx', // React JSX 文件
'.tsx', // React TSX 文件
'.graphql', // GraphQL 查询语言文件
'.gql', // GraphQL 查询语言文件
'.proto', // Protocol Buffers 文件
'.thrift', // Thrift 文件
'.toml', // TOML 配置文件
'.edn', // Clojure 数据表示文件
'.cake', // CakePHP 配置文件
'.ctp', // CakePHP 视图文件
'.cfm', // ColdFusion 标记语言文件
'.cfc', // ColdFusion 组件文件
'.m', // Objective-C 源文件
'.mm', // Objective-C++ 源文件
'.gradle', // Gradle 构建文件
'.groovy', // Gradle 构建文件
'.gradle', // Gradle 构建文件
'.kts' // Kotlin Script 文件
]

View File

@@ -2,5 +2,4 @@ export { default as UserAvatar } from '@renderer/assets/images/avatar.png'
export { default as AppLogo } from '@renderer/assets/images/logo.png' export { default as AppLogo } from '@renderer/assets/images/logo.png'
export const APP_NAME = 'Cherry Studio' export const APP_NAME = 'Cherry Studio'
export const isLocalAi = false export const isLocalAi = false

View File

@@ -4,64 +4,88 @@ import BaiduAiAppLogo from '@renderer/assets/images/apps/baidu-ai.png'
import DevvAppLogo from '@renderer/assets/images/apps/devv.png' import DevvAppLogo from '@renderer/assets/images/apps/devv.png'
import MetasoAppLogo from '@renderer/assets/images/apps/metaso.webp' import MetasoAppLogo from '@renderer/assets/images/apps/metaso.webp'
import PerplexityAppLogo from '@renderer/assets/images/apps/perplexity.webp' import PerplexityAppLogo from '@renderer/assets/images/apps/perplexity.webp'
import PoeAppLogo from '@renderer/assets/images/apps/poe.webp'
import SensetimeAppLogo from '@renderer/assets/images/apps/sensetime.png' import SensetimeAppLogo from '@renderer/assets/images/apps/sensetime.png'
import SparkDeskAppLogo from '@renderer/assets/images/apps/sparkdesk.png' import SparkDeskAppLogo from '@renderer/assets/images/apps/sparkdesk.png'
import TiangongAiLogo from '@renderer/assets/images/apps/tiangong.png' import TiangongAiLogo from '@renderer/assets/images/apps/tiangong.png'
import TencentYuanbaoAppLogo from '@renderer/assets/images/apps/yuanbao.png' import TencentYuanbaoAppLogo from '@renderer/assets/images/apps/yuanbao.png'
import ZhihuAppLogo from '@renderer/assets/images/apps/zhihu.png'
import MinApp from '@renderer/components/MinApp' import MinApp from '@renderer/components/MinApp'
import { PROVIDER_CONFIG } from '@renderer/config/provider' import { PROVIDER_CONFIG } from '@renderer/config/provider'
import { MinAppType } from '@renderer/types' import { MinAppType } from '@renderer/types'
const _apps: MinAppType[] = [ const _apps: MinAppType[] = [
{ {
name: 'AI 助手', id: '360-ai-so',
logo: AiAssistantAppLogo,
url: 'https://bot.360.com/'
},
{
name: '文心一言',
logo: BaiduAiAppLogo,
url: 'https://yiyan.baidu.com/'
},
{
name: 'SparkDesk',
logo: SparkDeskAppLogo,
url: 'https://xinghuo.xfyun.cn/desk'
},
{
name: '腾讯元宝',
logo: TencentYuanbaoAppLogo,
url: 'https://yuanbao.tencent.com/chat'
},
{
name: '商量',
logo: SensetimeAppLogo,
url: 'https://chat.sensetime.com/wb/chat'
},
{
name: '360AI搜索', name: '360AI搜索',
logo: AiSearchAppLogo, logo: AiSearchAppLogo,
url: 'https://so.360.com/' url: 'https://so.360.com/'
}, },
{ {
id: '360-ai-bot',
name: 'AI 助手',
logo: AiAssistantAppLogo,
url: 'https://bot.360.com/'
},
{
id: 'baidu-ai-chat',
name: '文心一言',
logo: BaiduAiAppLogo,
url: 'https://yiyan.baidu.com/'
},
{
id: 'tencent-yuanbao',
name: '腾讯元宝',
logo: TencentYuanbaoAppLogo,
url: 'https://yuanbao.tencent.com/chat'
},
{
id: 'sensetime-chat',
name: '商量',
logo: SensetimeAppLogo,
url: 'https://chat.sensetime.com/wb/chat'
},
{
id: 'spark-desk',
name: 'SparkDesk',
logo: SparkDeskAppLogo,
url: 'https://xinghuo.xfyun.cn/desk'
},
{
id: 'metaso',
name: '秘塔AI搜索', name: '秘塔AI搜索',
logo: MetasoAppLogo, logo: MetasoAppLogo,
url: 'https://metaso.cn/' url: 'https://metaso.cn/'
}, },
{ {
name: '天工AI', id: 'poe',
logo: TiangongAiLogo, name: 'Poe',
url: 'https://www.tiangong.cn/' logo: PoeAppLogo,
url: 'https://poe.com'
}, },
{ {
id: 'perplexity',
name: 'perplexity',
logo: PerplexityAppLogo,
url: 'https://www.perplexity.ai/'
},
{
id: 'devv',
name: 'DEVV_', name: 'DEVV_',
logo: DevvAppLogo, logo: DevvAppLogo,
url: 'https://devv.ai/' url: 'https://devv.ai/'
}, },
{ {
name: 'perplexity', id: 'tiangong-ai',
logo: PerplexityAppLogo, name: '天工AI',
url: 'https://www.perplexity.ai/' logo: TiangongAiLogo,
url: 'https://www.tiangong.cn/'
},
{
id: 'zhihu-zhiada',
name: '知乎直答',
logo: ZhihuAppLogo,
url: 'https://zhida.zhihu.com/'
} }
] ]

View File

@@ -1,6 +1,7 @@
import { Model } from '@renderer/types' import { Model } from '@renderer/types'
const TEXT_TO_IMAGE_REGEX = /flux|diffusion|stabilityai|sd-turbo|dall|cogview/i const TEXT_TO_IMAGE_REGEX = /flux|diffusion|stabilityai|sd-turbo|dall|cogview/i
const VISION_REGEX = /llava|moondream|minicpm|gemini-1.5|claude-3|vision|glm-4v|gpt-4|qwen-vl/i
const EMBEDDING_REGEX = /embedding/i const EMBEDDING_REGEX = /embedding/i
export const SYSTEM_MODELS: Record<string, Model[]> = { export const SYSTEM_MODELS: Record<string, Model[]> = {
@@ -117,6 +118,14 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
group: 'DeepSeek Coder' group: 'DeepSeek Coder'
} }
], ],
github: [
{
id: 'gpt-4o',
provider: 'github',
name: 'OpenAI GPT-4o',
group: 'OpenAI'
}
],
yi: [ yi: [
{ {
id: 'yi-large', id: 'yi-large',
@@ -387,3 +396,7 @@ export function isTextToImageModel(model: Model): boolean {
export function isEmbeddingModel(model: Model): boolean { export function isEmbeddingModel(model: Model): boolean {
return EMBEDDING_REGEX.test(model.id) return EMBEDDING_REGEX.test(model.id)
} }
export function isVisionModel(model: Model): boolean {
return VISION_REGEX.test(model.id)
}

View File

@@ -5,6 +5,7 @@ import BaichuanModelLogo from '@renderer/assets/images/models/baichuan.png'
import ChatGLMModelLogo from '@renderer/assets/images/models/chatglm.png' import ChatGLMModelLogo from '@renderer/assets/images/models/chatglm.png'
import ChatGPTModelLogo from '@renderer/assets/images/models/chatgpt.jpeg' import ChatGPTModelLogo from '@renderer/assets/images/models/chatgpt.jpeg'
import ClaudeModelLogo from '@renderer/assets/images/models/claude.png' import ClaudeModelLogo from '@renderer/assets/images/models/claude.png'
import CohereModelLogo from '@renderer/assets/images/models/cohere.webp'
import DeepSeekModelLogo from '@renderer/assets/images/models/deepseek.png' import DeepSeekModelLogo from '@renderer/assets/images/models/deepseek.png'
import DoubaoModelLogo from '@renderer/assets/images/models/doubao.png' import DoubaoModelLogo from '@renderer/assets/images/models/doubao.png'
import EmbeddingModelLogo from '@renderer/assets/images/models/embedding.png' import EmbeddingModelLogo from '@renderer/assets/images/models/embedding.png'
@@ -13,6 +14,7 @@ import GemmaModelLogo from '@renderer/assets/images/models/gemma.jpeg'
import HailuoModelLogo from '@renderer/assets/images/models/hailuo.png' import HailuoModelLogo from '@renderer/assets/images/models/hailuo.png'
import LlamaModelLogo from '@renderer/assets/images/models/llama.jpeg' import LlamaModelLogo from '@renderer/assets/images/models/llama.jpeg'
import MicrosoftModelLogo from '@renderer/assets/images/models/microsoft.png' import MicrosoftModelLogo from '@renderer/assets/images/models/microsoft.png'
import MinicpmModelLogo from '@renderer/assets/images/models/minicpm.webp'
import MixtralModelLogo from '@renderer/assets/images/models/mixtral.jpeg' import MixtralModelLogo from '@renderer/assets/images/models/mixtral.jpeg'
import PalmModelLogo from '@renderer/assets/images/models/palm.svg' import PalmModelLogo from '@renderer/assets/images/models/palm.svg'
import QwenModelLogo from '@renderer/assets/images/models/qwen.png' import QwenModelLogo from '@renderer/assets/images/models/qwen.png'
@@ -25,6 +27,7 @@ import DashScopeProviderLogo from '@renderer/assets/images/providers/dashscope.p
import DeepSeekProviderLogo from '@renderer/assets/images/providers/deepseek.png' import DeepSeekProviderLogo from '@renderer/assets/images/providers/deepseek.png'
import DoubaoProviderLogo from '@renderer/assets/images/providers/doubao.png' import DoubaoProviderLogo from '@renderer/assets/images/providers/doubao.png'
import GeminiProviderLogo from '@renderer/assets/images/providers/gemini.png' import GeminiProviderLogo from '@renderer/assets/images/providers/gemini.png'
import GithubProviderLogo from '@renderer/assets/images/providers/github.svg'
import GraphRagProviderLogo from '@renderer/assets/images/providers/graph-rag.png' import GraphRagProviderLogo from '@renderer/assets/images/providers/graph-rag.png'
import GroqProviderLogo from '@renderer/assets/images/providers/groq.png' import GroqProviderLogo from '@renderer/assets/images/providers/groq.png'
import MinimaxProviderLogo from '@renderer/assets/images/providers/minimax.png' import MinimaxProviderLogo from '@renderer/assets/images/providers/minimax.png'
@@ -76,6 +79,8 @@ export function getProviderLogo(providerId: string) {
return GraphRagProviderLogo return GraphRagProviderLogo
case 'minimax': case 'minimax':
return MinimaxProviderLogo return MinimaxProviderLogo
case 'github':
return GithubProviderLogo
default: default:
return undefined return undefined
} }
@@ -87,6 +92,7 @@ export function getModelLogo(modelId: string) {
} }
const logoMap = { const logoMap = {
o1: OpenAiProviderLogo,
gpt: ChatGPTModelLogo, gpt: ChatGPTModelLogo,
glm: ChatGLMModelLogo, glm: ChatGLMModelLogo,
deepseek: DeepSeekModelLogo, deepseek: DeepSeekModelLogo,
@@ -106,7 +112,10 @@ export function getModelLogo(modelId: string) {
palm: PalmModelLogo, palm: PalmModelLogo,
step: StepModelLogo, step: StepModelLogo,
abab: HailuoModelLogo, abab: HailuoModelLogo,
'ep-202': DoubaoModelLogo 'ep-202': DoubaoModelLogo,
cohere: CohereModelLogo,
command: CohereModelLogo,
minicpm: MinicpmModelLogo
} }
for (const key in logoMap) { for (const key in logoMap) {
@@ -121,8 +130,7 @@ export function getModelLogo(modelId: string) {
export const PROVIDER_CONFIG = { export const PROVIDER_CONFIG = {
openai: { openai: {
api: { api: {
url: 'https://api.openai.com', url: 'https://api.openai.com'
editable: true
}, },
websites: { websites: {
official: 'https://openai.com/', official: 'https://openai.com/',
@@ -131,6 +139,7 @@ export const PROVIDER_CONFIG = {
models: 'https://platform.openai.com/docs/models' models: 'https://platform.openai.com/docs/models'
}, },
app: { app: {
id: 'openai',
name: 'ChatGPT', name: 'ChatGPT',
url: 'https://chatgpt.com/', url: 'https://chatgpt.com/',
logo: OpenAiProviderLogo logo: OpenAiProviderLogo
@@ -138,8 +147,7 @@ export const PROVIDER_CONFIG = {
}, },
gemini: { gemini: {
api: { api: {
url: 'https://generativelanguage.googleapis.com', url: 'https://generativelanguage.googleapis.com'
editable: false
}, },
websites: { websites: {
official: 'https://gemini.google.com/', official: 'https://gemini.google.com/',
@@ -148,6 +156,7 @@ export const PROVIDER_CONFIG = {
models: 'https://ai.google.dev/gemini-api/docs/models/gemini' models: 'https://ai.google.dev/gemini-api/docs/models/gemini'
}, },
app: { app: {
id: 'gemini',
name: 'Gemini', name: 'Gemini',
url: 'https://gemini.google.com/', url: 'https://gemini.google.com/',
logo: GeminiProviderLogo logo: GeminiProviderLogo
@@ -155,8 +164,7 @@ export const PROVIDER_CONFIG = {
}, },
silicon: { silicon: {
api: { api: {
url: 'https://cloud.siliconflow.cn', url: 'https://api.siliconflow.cn'
editable: false
}, },
websites: { websites: {
official: 'https://www.siliconflow.cn/', official: 'https://www.siliconflow.cn/',
@@ -165,6 +173,7 @@ export const PROVIDER_CONFIG = {
models: 'https://docs.siliconflow.cn/docs/model-names' models: 'https://docs.siliconflow.cn/docs/model-names'
}, },
app: { app: {
id: 'silicon',
name: 'SiliconFlow', name: 'SiliconFlow',
url: 'https://cloud.siliconflow.cn/playground/chat', url: 'https://cloud.siliconflow.cn/playground/chat',
logo: SiliconFlowProviderLogo logo: SiliconFlowProviderLogo
@@ -172,8 +181,7 @@ export const PROVIDER_CONFIG = {
}, },
deepseek: { deepseek: {
api: { api: {
url: 'https://api.deepseek.com', url: 'https://api.deepseek.com'
editable: false
}, },
websites: { websites: {
official: 'https://deepseek.com/', official: 'https://deepseek.com/',
@@ -182,15 +190,26 @@ export const PROVIDER_CONFIG = {
models: 'https://platform.deepseek.com/api-docs/' models: 'https://platform.deepseek.com/api-docs/'
}, },
app: { app: {
id: 'deepseek',
name: 'DeepSeek', name: 'DeepSeek',
url: 'https://chat.deepseek.com/', url: 'https://chat.deepseek.com/',
logo: DeepSeekProviderLogo logo: DeepSeekProviderLogo
} }
}, },
github: {
api: {
url: 'https://models.inference.ai.azure.com/'
},
websites: {
official: 'https://github.com/marketplace/models',
apiKey: 'https://github.com/settings/tokens',
docs: 'https://docs.github.com/en/github-models',
models: 'https://github.com/marketplace/models'
}
},
yi: { yi: {
api: { api: {
url: 'https://api.lingyiwanwu.com', url: 'https://api.lingyiwanwu.com'
editable: false
}, },
websites: { websites: {
official: 'https://platform.lingyiwanwu.com/', official: 'https://platform.lingyiwanwu.com/',
@@ -199,6 +218,7 @@ export const PROVIDER_CONFIG = {
models: 'https://platform.lingyiwanwu.com/docs#%E6%A8%A1%E5%9E%8B' models: 'https://platform.lingyiwanwu.com/docs#%E6%A8%A1%E5%9E%8B'
}, },
app: { app: {
id: 'yi',
name: 'Yi', name: 'Yi',
url: 'https://www.wanzhi.com/', url: 'https://www.wanzhi.com/',
logo: YiProviderLogo logo: YiProviderLogo
@@ -206,8 +226,7 @@ export const PROVIDER_CONFIG = {
}, },
zhipu: { zhipu: {
api: { api: {
url: 'https://open.bigmodel.cn/api/paas/v4/', url: 'https://open.bigmodel.cn/api/paas/v4/'
editable: false
}, },
websites: { websites: {
official: 'https://open.bigmodel.cn/', official: 'https://open.bigmodel.cn/',
@@ -216,6 +235,7 @@ export const PROVIDER_CONFIG = {
models: 'https://open.bigmodel.cn/modelcenter/square' models: 'https://open.bigmodel.cn/modelcenter/square'
}, },
app: { app: {
id: 'zhipu',
name: '智谱', name: '智谱',
url: 'https://chatglm.cn/main/alltoolsdetail', url: 'https://chatglm.cn/main/alltoolsdetail',
logo: ZhipuProviderLogo logo: ZhipuProviderLogo
@@ -223,8 +243,7 @@ export const PROVIDER_CONFIG = {
}, },
moonshot: { moonshot: {
api: { api: {
url: 'https://api.moonshot.cn', url: 'https://api.moonshot.cn'
editable: false
}, },
websites: { websites: {
official: 'https://moonshot.ai/', official: 'https://moonshot.ai/',
@@ -233,6 +252,7 @@ export const PROVIDER_CONFIG = {
models: 'https://platform.moonshot.cn/docs/intro#%E6%A8%A1%E5%9E%8B%E5%88%97%E8%A1%A8' models: 'https://platform.moonshot.cn/docs/intro#%E6%A8%A1%E5%9E%8B%E5%88%97%E8%A1%A8'
}, },
app: { app: {
id: 'moonshot',
name: 'Kimi', name: 'Kimi',
url: 'https://kimi.moonshot.cn/', url: 'https://kimi.moonshot.cn/',
logo: KimiAppLogo logo: KimiAppLogo
@@ -240,8 +260,7 @@ export const PROVIDER_CONFIG = {
}, },
baichuan: { baichuan: {
api: { api: {
url: 'https://api.baichuan-ai.com', url: 'https://api.baichuan-ai.com'
editable: false
}, },
websites: { websites: {
official: 'https://www.baichuan-ai.com/', official: 'https://www.baichuan-ai.com/',
@@ -250,6 +269,7 @@ export const PROVIDER_CONFIG = {
models: 'https://platform.baichuan-ai.com/price' models: 'https://platform.baichuan-ai.com/price'
}, },
app: { app: {
id: 'baichuan',
name: '百小应', name: '百小应',
url: 'https://ying.baichuan-ai.com/chat', url: 'https://ying.baichuan-ai.com/chat',
logo: BaicuanAppLogo logo: BaicuanAppLogo
@@ -257,8 +277,7 @@ export const PROVIDER_CONFIG = {
}, },
dashscope: { dashscope: {
api: { api: {
url: 'https://dashscope.aliyuncs.com/compatible-mode/v1/', url: 'https://dashscope.aliyuncs.com/compatible-mode/v1/'
editable: false
}, },
websites: { websites: {
official: 'https://dashscope.aliyun.com/', official: 'https://dashscope.aliyun.com/',
@@ -267,6 +286,7 @@ export const PROVIDER_CONFIG = {
models: 'https://dashscope.console.aliyun.com/model' models: 'https://dashscope.console.aliyun.com/model'
}, },
app: { app: {
id: 'dashscope',
name: '通义千问', name: '通义千问',
url: 'https://tongyi.aliyun.com/qianwen/', url: 'https://tongyi.aliyun.com/qianwen/',
logo: QwenModelLogo logo: QwenModelLogo
@@ -274,8 +294,7 @@ export const PROVIDER_CONFIG = {
}, },
stepfun: { stepfun: {
api: { api: {
url: 'https://api.stepfun.com', url: 'https://api.stepfun.com'
editable: false
}, },
websites: { websites: {
official: 'https://platform.stepfun.com/', official: 'https://platform.stepfun.com/',
@@ -284,6 +303,7 @@ export const PROVIDER_CONFIG = {
models: 'https://platform.stepfun.com/docs/llm/text' models: 'https://platform.stepfun.com/docs/llm/text'
}, },
app: { app: {
id: 'stepfun',
name: '跃问', name: '跃问',
url: 'https://yuewen.cn/chats/new', url: 'https://yuewen.cn/chats/new',
logo: YuewenAppLogo logo: YuewenAppLogo
@@ -291,8 +311,7 @@ export const PROVIDER_CONFIG = {
}, },
doubao: { doubao: {
api: { api: {
url: 'https://ark.cn-beijing.volces.com/api/v3/', url: 'https://ark.cn-beijing.volces.com/api/v3/'
editable: true
}, },
websites: { websites: {
official: 'https://console.volcengine.com/ark/', official: 'https://console.volcengine.com/ark/',
@@ -301,6 +320,7 @@ export const PROVIDER_CONFIG = {
models: 'https://console.volcengine.com/ark/region:ark+cn-beijing/endpoint' models: 'https://console.volcengine.com/ark/region:ark+cn-beijing/endpoint'
}, },
app: { app: {
id: 'doubao',
name: '豆包', name: '豆包',
url: 'https://www.doubao.com/chat/', url: 'https://www.doubao.com/chat/',
logo: DoubaoProviderLogo logo: DoubaoProviderLogo
@@ -308,8 +328,7 @@ export const PROVIDER_CONFIG = {
}, },
minimax: { minimax: {
api: { api: {
url: 'https://api.minimax.chat/v1/', url: 'https://api.minimax.chat/v1/'
editable: true
}, },
websites: { websites: {
official: 'https://platform.minimaxi.com/', official: 'https://platform.minimaxi.com/',
@@ -318,6 +337,7 @@ export const PROVIDER_CONFIG = {
models: 'https://platform.minimaxi.com/document/Models' models: 'https://platform.minimaxi.com/document/Models'
}, },
app: { app: {
id: 'minimax',
name: '海螺', name: '海螺',
url: 'https://hailuoai.com/', url: 'https://hailuoai.com/',
logo: HailuoModelLogo logo: HailuoModelLogo
@@ -325,14 +345,12 @@ export const PROVIDER_CONFIG = {
}, },
'graphrag-kylin-mountain': { 'graphrag-kylin-mountain': {
api: { api: {
url: '', url: ''
editable: true
} }
}, },
openrouter: { openrouter: {
api: { api: {
url: 'https://openrouter.ai/api/v1/', url: 'https://openrouter.ai/api/v1/'
editable: false
}, },
websites: { websites: {
official: 'https://openrouter.ai/', official: 'https://openrouter.ai/',
@@ -343,8 +361,7 @@ export const PROVIDER_CONFIG = {
}, },
groq: { groq: {
api: { api: {
url: 'https://api.groq.com/openai', url: 'https://api.groq.com/openai'
editable: false
}, },
websites: { websites: {
official: 'https://groq.com/', official: 'https://groq.com/',
@@ -353,6 +370,7 @@ export const PROVIDER_CONFIG = {
models: 'https://console.groq.com/docs/models' models: 'https://console.groq.com/docs/models'
}, },
app: { app: {
id: 'groq',
name: 'Groq', name: 'Groq',
url: 'https://chat.groq.com/', url: 'https://chat.groq.com/',
logo: GroqProviderLogo logo: GroqProviderLogo
@@ -360,8 +378,7 @@ export const PROVIDER_CONFIG = {
}, },
ollama: { ollama: {
api: { api: {
url: 'http://localhost:11434/v1/', url: 'http://localhost:11434/v1/'
editable: true
}, },
websites: { websites: {
official: 'https://ollama.com/', official: 'https://ollama.com/',
@@ -371,8 +388,7 @@ export const PROVIDER_CONFIG = {
}, },
anthropic: { anthropic: {
api: { api: {
url: 'https://api.anthropic.com/', url: 'https://api.anthropic.com/'
editable: true
}, },
websites: { websites: {
official: 'https://anthropic.com/', official: 'https://anthropic.com/',
@@ -381,6 +397,7 @@ export const PROVIDER_CONFIG = {
models: 'https://docs.anthropic.com/en/docs/about-claude/models' models: 'https://docs.anthropic.com/en/docs/about-claude/models'
}, },
app: { app: {
id: 'anthropic',
name: 'Claude', name: 'Claude',
url: 'https://claude.ai/', url: 'https://claude.ai/',
logo: AnthropicProviderLogo logo: AnthropicProviderLogo
@@ -388,8 +405,7 @@ export const PROVIDER_CONFIG = {
}, },
aihubmix: { aihubmix: {
api: { api: {
url: 'https://aihubmix.com', url: 'https://aihubmix.com'
editable: false
}, },
websites: { websites: {
official: 'https://aihubmix.com/', official: 'https://aihubmix.com/',

View File

@@ -23,7 +23,8 @@ const AntdProvider: FC<PropsWithChildren> = ({ children }) => {
} }
}, },
token: { token: {
colorPrimary: '#00b96b' colorPrimary: '#00b96b',
borderRadius: 6
} }
}}> }}>
{children} {children}

View File

@@ -1,5 +1,5 @@
import { useSettings } from '@renderer/hooks/useSettings' import { useSettings } from '@renderer/hooks/useSettings'
import { ThemeMode } from '@renderer/store/settings' import { ThemeMode } from '@renderer/types'
import React, { createContext, PropsWithChildren, useContext, useEffect, useState } from 'react' import React, { createContext, PropsWithChildren, useContext, useEffect, useState } from 'react'
interface ThemeContextType { interface ThemeContextType {

View File

@@ -0,0 +1,27 @@
import { FileType, Topic } from '@renderer/types'
import { Dexie, type EntityTable } from 'dexie'
import { populateTopics } from './populate'
// Database declaration (move this to its own module also)
export const db = new Dexie('CherryStudio') as Dexie & {
files: EntityTable<FileType, 'id'>
topics: EntityTable<Pick<Topic, 'id' | 'messages'>, 'id'>
settings: EntityTable<{ id: string; value: any }, 'id'>
}
db.version(1).stores({
files: 'id, name, origin_name, path, size, ext, type, created_at, count'
})
db.version(2)
.stores({
files: 'id, name, origin_name, path, size, ext, type, created_at, count',
topics: '&id, messages',
settings: '&id, value'
})
.upgrade(populateTopics)
db.on('populate', populateTopics)
export default db

View File

@@ -0,0 +1,27 @@
import i18n from '@renderer/i18n'
import { Transaction } from 'dexie'
import localforage from 'localforage'
export async function populateTopics(trans: Transaction) {
const indexedKeys = await localforage.keys()
if (indexedKeys.length > 0) {
for (const key of indexedKeys) {
const value: any = await localforage.getItem(key)
if (key.startsWith('topic:')) {
await trans.db.table('topics').add({ id: value.id, messages: value.messages })
}
if (key === 'image://avatar') {
await trans.db.table('settings').add({ id: key, value: await localforage.getItem(key) })
}
}
window.modal.success({
title: i18n.t('message.upgrade.success.title'),
content: i18n.t('message.upgrade.success.content'),
okText: i18n.t('message.upgrade.success.button'),
centered: true,
onOk: () => window.api.reload()
})
}
}

View File

@@ -1,9 +1,10 @@
import { isLocalAi } from '@renderer/config/env' import { isLocalAi } from '@renderer/config/env'
import db from '@renderer/databases'
import i18n from '@renderer/i18n' import i18n from '@renderer/i18n'
import LocalStorage from '@renderer/services/storage'
import { useAppDispatch } from '@renderer/store' import { useAppDispatch } from '@renderer/store'
import { setAvatar } from '@renderer/store/runtime' import { setAvatar } from '@renderer/store/runtime'
import { runAsyncFunction } from '@renderer/utils' import { runAsyncFunction } from '@renderer/utils'
import { useLiveQuery } from 'dexie-react-hooks'
import { useEffect } from 'react' import { useEffect } from 'react'
import { useDefaultModel } from './useAssistant' import { useDefaultModel } from './useAssistant'
@@ -11,18 +12,16 @@ import { useSettings } from './useSettings'
export function useAppInit() { export function useAppInit() {
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const { proxyUrl } = useSettings() const { proxyUrl, language } = useSettings()
const { language } = useSettings()
const { setDefaultModel, setTopicNamingModel, setTranslateModel } = useDefaultModel() const { setDefaultModel, setTopicNamingModel, setTranslateModel } = useDefaultModel()
const avatar = useLiveQuery(() => db.settings.get('image://avatar'))
useEffect(() => { useEffect(() => {
runAsyncFunction(async () => { avatar?.value && dispatch(setAvatar(avatar.value))
const storedImage = await LocalStorage.getImage('avatar') }, [avatar, dispatch])
storedImage && dispatch(setAvatar(storedImage))
})
}, [dispatch])
useEffect(() => { useEffect(() => {
document.getElementById('spinner')?.remove()
runAsyncFunction(async () => { runAsyncFunction(async () => {
const { isPackaged } = await window.api.getAppInfo() const { isPackaged } = await window.api.getAppInfo()
isPackaged && setTimeout(window.api.checkForUpdate, 3000) isPackaged && setTimeout(window.api.checkForUpdate, 3000)

View File

@@ -16,7 +16,8 @@ import {
} from '@renderer/store/assistants' } from '@renderer/store/assistants'
import { setDefaultModel, setTopicNamingModel, setTranslateModel } from '@renderer/store/llm' import { setDefaultModel, setTopicNamingModel, setTranslateModel } from '@renderer/store/llm'
import { Assistant, AssistantSettings, Model, Topic } from '@renderer/types' import { Assistant, AssistantSettings, Model, Topic } from '@renderer/types'
import localforage from 'localforage'
import { TopicManager } from './useTopic'
export function useAssistants() { export function useAssistants() {
const { assistants } = useAppSelector((state) => state.assistants) const { assistants } = useAppSelector((state) => state.assistants)
@@ -29,9 +30,8 @@ export function useAssistants() {
removeAssistant: (id: string) => { removeAssistant: (id: string) => {
dispatch(removeAssistant({ id })) dispatch(removeAssistant({ id }))
const assistant = assistants.find((a) => a.id === id) const assistant = assistants.find((a) => a.id === id)
if (assistant) { const topics = assistant?.topics || []
assistant.topics.forEach((id) => localforage.removeItem(`topic:${id}`)) topics.forEach(({ id }) => TopicManager.removeTopic(id))
}
} }
} }
} }
@@ -45,7 +45,14 @@ export function useAssistant(id: string) {
assistant, assistant,
model: assistant?.model ?? defaultModel, model: assistant?.model ?? defaultModel,
addTopic: (topic: Topic) => dispatch(addTopic({ assistantId: assistant.id, topic })), addTopic: (topic: Topic) => dispatch(addTopic({ assistantId: assistant.id, topic })),
removeTopic: (topic: Topic) => dispatch(removeTopic({ assistantId: assistant.id, topic })), removeTopic: (topic: Topic) => {
TopicManager.removeTopic(topic.id)
dispatch(removeTopic({ assistantId: assistant.id, topic }))
},
moveTopic: (topic: Topic, toAssistant: Assistant) => {
dispatch(addTopic({ assistantId: toAssistant.id, topic: { ...topic } }))
dispatch(removeTopic({ assistantId: assistant.id, topic }))
},
updateTopic: (topic: Topic) => dispatch(updateTopic({ assistantId: assistant.id, topic })), updateTopic: (topic: Topic) => dispatch(updateTopic({ assistantId: assistant.id, topic })),
updateTopics: (topics: Topic[]) => dispatch(updateTopics({ assistantId: assistant.id, topics })), updateTopics: (topics: Topic[]) => dispatch(updateTopics({ assistantId: assistant.id, topics })),
removeAllTopics: () => dispatch(removeAllTopics({ assistantId: assistant.id })), removeAllTopics: () => dispatch(removeAllTopics({ assistantId: assistant.id })),

View File

@@ -4,9 +4,9 @@ import {
setSendMessageShortcut as _setSendMessageShortcut, setSendMessageShortcut as _setSendMessageShortcut,
setTheme, setTheme,
setTopicPosition, setTopicPosition,
setWindowStyle, setWindowStyle
ThemeMode
} from '@renderer/store/settings' } from '@renderer/store/settings'
import { ThemeMode } from '@renderer/types'
export function useSettings() { export function useSettings() {
const settings = useAppSelector((state) => state.settings) const settings = useAppSelector((state) => state.settings)

View File

@@ -1,3 +1,5 @@
import db from '@renderer/databases'
import { deleteMessageFiles } from '@renderer/services/messages'
import { Assistant, Topic } from '@renderer/types' import { Assistant, Topic } from '@renderer/types'
import { find } from 'lodash' import { find } from 'lodash'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
@@ -10,6 +12,8 @@ export function useActiveTopic(_assistant: Assistant) {
const { assistant } = useAssistant(_assistant.id) const { assistant } = useAssistant(_assistant.id)
const [activeTopic, setActiveTopic] = useState(_activeTopic || assistant?.topics[0]) const [activeTopic, setActiveTopic] = useState(_activeTopic || assistant?.topics[0])
_activeTopic = activeTopic
useEffect(() => { useEffect(() => {
// activeTopic not in assistant.topics // activeTopic not in assistant.topics
if (assistant && !find(assistant.topics, { id: activeTopic?.id })) { if (assistant && !find(assistant.topics, { id: activeTopic?.id })) {
@@ -23,3 +27,38 @@ export function useActiveTopic(_assistant: Assistant) {
export function getTopic(assistant: Assistant, topicId: string) { export function getTopic(assistant: Assistant, topicId: string) {
return assistant?.topics.find((topic) => topic.id === topicId) return assistant?.topics.find((topic) => topic.id === topicId)
} }
export class TopicManager {
static async getTopic(id: string) {
return await db.topics.get(id)
}
static async getTopicMessages(id: string) {
const topic = await this.getTopic(id)
return topic ? topic.messages : []
}
static async removeTopic(id: string) {
const messages = await this.getTopicMessages(id)
for (const message of messages) {
await deleteMessageFiles(message)
}
db.topics.delete(id)
}
static async clearTopicMessages(id: string) {
const topic = await this.getTopic(id)
if (topic) {
for (const message of topic?.messages ?? []) {
await deleteMessageFiles(message)
}
topic.messages = []
await db.topics.update(id, topic)
}
}
}

View File

@@ -37,7 +37,9 @@ const resources = {
add: 'Add', add: 'Add',
added: 'Added', added: 'Added',
manage: 'Manage', manage: 'Manage',
select_model: 'Select Model' select_model: 'Select Model',
'show.all': 'Show All',
collapse: 'Collapse'
}, },
message: { message: {
copied: 'Copied!', copied: 'Copied!',
@@ -53,25 +55,33 @@ const resources = {
'chat.completion.paused': 'Chat completion paused', 'chat.completion.paused': 'Chat completion paused',
'switch.disabled': 'Switching is disabled while the assistant is generating', 'switch.disabled': 'Switching is disabled while the assistant is generating',
'restore.success': 'Restored successfully', 'restore.success': 'Restored successfully',
'backup.success': 'Backup successful',
'reset.confirm.content': 'Are you sure you want to clear all data?', 'reset.confirm.content': 'Are you sure you want to clear all data?',
'reset.double.confirm.title': 'DATA LOST !!!', 'reset.double.confirm.title': 'DATA LOST !!!',
'reset.double.confirm.content': 'All data will be lost, do you want to continue?' 'reset.double.confirm.content': 'All data will be lost, do you want to continue?',
'upgrade.success.title': 'Upgrade successfully',
'upgrade.success.content': 'Please restart the application to complete the upgrade',
'upgrade.success.button': 'Restart',
'topic.added': 'New topic added'
}, },
chat: { chat: {
save: 'Save', save: 'Save',
'default.name': 'Default Assistant', 'default.name': '⭐️ Default Assistant',
'default.description': "Hello, I'm Default Assistant. You can start chatting with me right away", 'default.description': "Hello, I'm Default Assistant. You can start chatting with me right away",
'default.topic.name': 'Default Topic', 'default.topic.name': 'Default Topic',
'topics.title': 'Topics', 'topics.title': 'Topics',
'topics.auto_rename': 'Auto Rename', 'topics.auto_rename': 'Auto Rename',
'topics.edit.title': 'Rename', 'topics.edit.title': 'Edit Name',
'topics.edit.placeholder': 'Enter new name', 'topics.edit.placeholder': 'Enter new name',
'topics.delete.all.title': 'Delete all topics', 'topics.delete.all.title': 'Delete all topics',
'topics.delete.all.content': 'Are you sure you want to delete all topics?', 'topics.delete.all.content': 'Are you sure you want to delete all topics?',
'topics.move_to': 'Move to',
'topics.list': 'Topic List', 'topics.list': 'Topic List',
'topics.export.title': 'Export',
'topics.export.image': 'Export as image',
'input.new_topic': 'New Topic', 'input.new_topic': 'New Topic',
'input.topics': ' Topics ', 'input.topics': ' Topics ',
'input.clear': 'Clear Messages', 'input.clear': 'Clear',
'input.new.context': 'Clear Context', 'input.new.context': 'Clear Context',
'input.expand': 'Expand', 'input.expand': 'Expand',
'input.collapse': 'Collapse', 'input.collapse': 'Collapse',
@@ -81,7 +91,7 @@ const resources = {
'input.send': 'Send', 'input.send': 'Send',
'input.pause': 'Pause', 'input.pause': 'Pause',
'input.settings': 'Settings', 'input.settings': 'Settings',
'input.upload': 'Upload image png、jpg、jpeg', 'input.upload': 'Upload image or text file',
'input.context_count.tip': 'Context Count', 'input.context_count.tip': 'Context Count',
'input.estimated_tokens.tip': 'Estimated tokens', 'input.estimated_tokens.tip': 'Estimated tokens',
'settings.temperature': 'Temperature', 'settings.temperature': 'Temperature',
@@ -97,20 +107,35 @@ const resources = {
'settings.max': 'Max', 'settings.max': 'Max',
'suggestions.title': 'Suggested Questions', 'suggestions.title': 'Suggested Questions',
'add.assistant.title': 'Add Assistant', 'add.assistant.title': 'Add Assistant',
'message.new.context': 'New Context' 'message.new.context': 'New Context',
'message.new.branch': 'New Branch',
'assistant.search.placeholder': 'Search'
},
assistants: {
title: 'Assistants',
abbr: 'Assistant',
search: 'Search assistants...'
},
files: {
title: 'Files',
file: 'File',
name: 'Name',
size: 'Size',
count: 'Count',
created_at: 'Created At'
}, },
agents: { agents: {
title: 'Agents', title: 'Assistants',
my_agents: 'My Agents', my_agents: 'My Assistants',
'add.title': 'Add Agent', 'add.title': 'Add Assistant',
'edit.title': 'Edit Agent', 'edit.title': 'Edit Assistant',
'add.name': 'Name', 'add.name': 'Name',
'add.name.placeholder': 'Enter name', 'add.name.placeholder': 'Enter name',
'add.prompt': 'Prompt', 'add.prompt': 'Prompt',
'add.prompt.placeholder': 'Enter prompt', 'add.prompt.placeholder': 'Enter prompt',
'add.button': 'Add', 'add.button': 'Add',
'manage.title': 'Manage Agents', 'manage.title': 'Manage Assistants',
'delete.popup.content': 'Are you sure you want to delete this agent?', 'delete.popup.content': 'Are you sure you want to delete this assistant?',
'tag.default': 'Default', 'tag.default': 'Default',
'tag.system': 'System', 'tag.system': 'System',
'tag.user': 'Mine' 'tag.user': 'Mine'
@@ -133,7 +158,8 @@ const resources = {
stepfun: 'StepFun', stepfun: 'StepFun',
doubao: 'Doubao', doubao: 'Doubao',
minimax: 'MiniMax', minimax: 'MiniMax',
'graphrag-kylin-mountain': 'GraphRAG' 'graphrag-kylin-mountain': 'GraphRAG',
github: 'GitHub Models'
}, },
settings: { settings: {
title: 'Settings', title: 'Settings',
@@ -149,6 +175,7 @@ const resources = {
'messages.input.title': 'Input Settings', 'messages.input.title': 'Input Settings',
'messages.input.show_estimated_tokens': 'Show estimated input tokens', 'messages.input.show_estimated_tokens': 'Show estimated input tokens',
'messages.input.send_shortcuts': 'Send shortcuts', 'messages.input.send_shortcuts': 'Send shortcuts',
'messages.input.paste_long_text_as_file': 'Paste long text as file',
'general.title': 'General Settings', 'general.title': 'General Settings',
'general.user_name': 'User Name', 'general.user_name': 'User Name',
'general.user_name.placeholder': 'Enter your name', 'general.user_name.placeholder': 'Enter your name',
@@ -157,6 +184,8 @@ const resources = {
'general.restore.button': 'Restore', 'general.restore.button': 'Restore',
'general.reset.title': 'Data Reset', 'general.reset.title': 'Data Reset',
'general.reset.button': 'Reset', 'general.reset.button': 'Reset',
'advanced.title': 'Advanced Settings',
'advanced.click_assistant_switch_to_topics': 'Auto switch to topic',
'provider.api_key': 'API Key', 'provider.api_key': 'API Key',
'provider.check': 'Check', 'provider.check': 'Check',
'provider.get_api_key': 'Get API Key', 'provider.get_api_key': 'Get API Key',
@@ -288,7 +317,9 @@ const resources = {
add: '添加', add: '添加',
added: '已添加', added: '已添加',
manage: '管理', manage: '管理',
select_model: '选择模型' select_model: '选择模型',
'show.all': '显示全部',
collapse: '收起'
}, },
message: { message: {
copied: '已复制', copied: '已复制',
@@ -304,13 +335,18 @@ const resources = {
'chat.completion.paused': '会话已停止', 'chat.completion.paused': '会话已停止',
'switch.disabled': '模型回复完成后才能切换', 'switch.disabled': '模型回复完成后才能切换',
'restore.success': '恢复成功', 'restore.success': '恢复成功',
'backup.success': '备份成功',
'reset.confirm.content': '确定要重置所有数据吗?', 'reset.confirm.content': '确定要重置所有数据吗?',
'reset.double.confirm.title': '数据丢失!!!', 'reset.double.confirm.title': '数据丢失!!!',
'reset.double.confirm.content': '你的全部数据都会丢失,如果没有备份数据,将无法恢复,确定要继续吗?' 'reset.double.confirm.content': '你的全部数据都会丢失,如果没有备份数据,将无法恢复,确定要继续吗?',
'upgrade.success.title': '升级成功',
'upgrade.success.content': '重启应用以完成升级',
'upgrade.success.button': '重启',
'topic.added': '话题添加成功'
}, },
chat: { chat: {
save: '保存', save: '保存',
'default.name': '默认助手', 'default.name': '⭐️ 默认助手',
'default.description': '你好,我是默认助手。你可以立刻开始跟我聊天。', 'default.description': '你好,我是默认助手。你可以立刻开始跟我聊天。',
'default.topic.name': '默认话题', 'default.topic.name': '默认话题',
'topics.title': '话题', 'topics.title': '话题',
@@ -319,7 +355,10 @@ const resources = {
'topics.edit.placeholder': '输入新名称', 'topics.edit.placeholder': '输入新名称',
'topics.delete.all.title': '删除所有话题', 'topics.delete.all.title': '删除所有话题',
'topics.delete.all.content': '确定要删除所有话题吗?', 'topics.delete.all.content': '确定要删除所有话题吗?',
'topics.move_to': '移动到',
'topics.list': '话题列表', 'topics.list': '话题列表',
'topics.export.title': '导出',
'topics.export.image': '导出为图片',
'input.new_topic': '新话题', 'input.new_topic': '新话题',
'input.topics': ' 话题 ', 'input.topics': ' 话题 ',
'input.clear': '清除会话消息', 'input.clear': '清除会话消息',
@@ -332,7 +371,7 @@ const resources = {
'input.send': '发送', 'input.send': '发送',
'input.pause': '暂停', 'input.pause': '暂停',
'input.settings': '设置', 'input.settings': '设置',
'input.upload': '上传图片 png、jpg、jpeg', 'input.upload': '上传图片或纯文本文件',
'input.context_count.tip': '上下文数', 'input.context_count.tip': '上下文数',
'input.estimated_tokens.tip': '预估 token 数', 'input.estimated_tokens.tip': '预估 token 数',
'settings.temperature': '模型温度', 'settings.temperature': '模型温度',
@@ -348,8 +387,23 @@ const resources = {
'settings.set_as_default': '应用到默认助手', 'settings.set_as_default': '应用到默认助手',
'settings.max': '不限', 'settings.max': '不限',
'suggestions.title': '建议的问题', 'suggestions.title': '建议的问题',
'add.assistant.title': '添加智能体', 'add.assistant.title': '添加助手',
'message.new.context': '清除上下文' 'message.new.context': '清除上下文',
'message.new.branch': '新分支',
'assistant.search.placeholder': '搜索'
},
assistants: {
title: '助手',
abbr: '助手',
search: '搜索助手'
},
files: {
title: '文件',
file: '文件',
name: '文件名',
size: '大小',
count: '文件数',
created_at: '创建时间'
}, },
agents: { agents: {
title: '智能体', title: '智能体',
@@ -385,7 +439,8 @@ const resources = {
stepfun: '阶跃星辰', stepfun: '阶跃星辰',
doubao: '豆包', doubao: '豆包',
minimax: 'MiniMax', minimax: 'MiniMax',
'graphrag-kylin-mountain': 'GraphRAG' 'graphrag-kylin-mountain': 'GraphRAG',
github: 'GitHub Models'
}, },
settings: { settings: {
title: '设置', title: '设置',
@@ -401,6 +456,7 @@ const resources = {
'messages.input.title': '输入设置', 'messages.input.title': '输入设置',
'messages.input.show_estimated_tokens': '状态显示', 'messages.input.show_estimated_tokens': '状态显示',
'messages.input.send_shortcuts': '发送快捷键', 'messages.input.send_shortcuts': '发送快捷键',
'messages.input.paste_long_text_as_file': '长文本粘贴为文件',
'general.title': '常规设置', 'general.title': '常规设置',
'general.user_name': '用户名', 'general.user_name': '用户名',
'general.user_name.placeholder': '请输入用户名', 'general.user_name.placeholder': '请输入用户名',
@@ -409,6 +465,8 @@ const resources = {
'general.restore.button': '恢复', 'general.restore.button': '恢复',
'general.reset.title': '重置数据', 'general.reset.title': '重置数据',
'general.reset.button': '重置', 'general.reset.button': '重置',
'advanced.title': '高级设置',
'advanced.click_assistant_switch_to_topics': '点击助手切换到话题',
'provider.api_key': 'API 密钥', 'provider.api_key': 'API 密钥',
'provider.check': '检查', 'provider.check': '检查',
'provider.get_api_key': '点击这里获取密钥', 'provider.get_api_key': '点击这里获取密钥',

View File

@@ -2,7 +2,7 @@ import KeyvStorage from '@kangfenmao/keyv-storage'
import localforage from 'localforage' import localforage from 'localforage'
import { APP_NAME } from './config/env' import { APP_NAME } from './config/env'
import { ThemeMode } from './store/settings' import { ThemeMode } from './types'
import { loadScript } from './utils' import { loadScript } from './utils'
export async function initMermaid(theme: ThemeMode) { export async function initMermaid(theme: ThemeMode) {

View File

@@ -33,6 +33,7 @@ const AppsPage: FC = () => {
icon: null, icon: null,
closable: true, closable: true,
maskClosable: true, maskClosable: true,
centered: true,
okButtonProps: { type: 'primary', disabled: Boolean(added) }, okButtonProps: { type: 'primary', disabled: Boolean(added) },
okText: added ? t('button.added') : t('button.add'), okText: added ? t('button.added') : t('button.add'),
onOk: () => onAddAgent(agent) onOk: () => onAddAgent(agent)

View File

@@ -1,8 +1,10 @@
import 'emoji-picker-element' import 'emoji-picker-element'
import { LoadingOutlined, ThunderboltOutlined } from '@ant-design/icons'
import EmojiPicker from '@renderer/components/EmojiPicker' import EmojiPicker from '@renderer/components/EmojiPicker'
import { TopView } from '@renderer/components/TopView' import { TopView } from '@renderer/components/TopView'
import { useAgents } from '@renderer/hooks/useAgents' import { useAgents } from '@renderer/hooks/useAgents'
import { fetchGenerate } from '@renderer/services/api'
import { syncAgentToAssistant } from '@renderer/services/assistant' import { syncAgentToAssistant } from '@renderer/services/assistant'
import { Agent } from '@renderer/types' import { Agent } from '@renderer/types'
import { getLeadingEmoji, uuid } from '@renderer/utils' import { getLeadingEmoji, uuid } from '@renderer/utils'
@@ -29,6 +31,7 @@ const PopupContainer: React.FC<Props> = ({ agent, resolve }) => {
const { addAgent, updateAgent } = useAgents() const { addAgent, updateAgent } = useAgents()
const formRef = useRef<FormInstance>(null) const formRef = useRef<FormInstance>(null)
const [emoji, setEmoji] = useState(agent?.emoji) const [emoji, setEmoji] = useState(agent?.emoji)
const [loading, setLoading] = useState(false)
const onFinish = (values: FieldType) => { const onFinish = (values: FieldType) => {
const _emoji = emoji || getLeadingEmoji(values.name) const _emoji = emoji || getLeadingEmoji(values.name)
@@ -81,16 +84,44 @@ const PopupContainer: React.FC<Props> = ({ agent, resolve }) => {
} }
}, [agent, form]) }, [agent, form])
const handleButtonClick = async () => {
const prompt = `你是一个专业的 prompt 优化助手我会给你一段prompt你需要帮我优化它仅回复优化后的 prompt 不要添加任何解释,使用 [CRISPE提示框架] 回复。`
const name = formRef.current?.getFieldValue('name')
const content = formRef.current?.getFieldValue('prompt')
const promptText = content || name
if (!promptText) {
return
}
if (content) {
navigator.clipboard.writeText(content)
}
setLoading(true)
try {
const prefixedContent = `请帮我优化下面这段 prompt使用 CRISPE 提示框架,请使用 Markdown 格式回复,不要使用 codeblock: ${promptText}`
const generatedText = await fetchGenerate({ prompt, content: prefixedContent })
formRef.current?.setFieldValue('prompt', generatedText)
} catch (error) {
console.error('Error fetching data:', error)
}
setLoading(false)
}
return ( return (
<Modal <Modal
style={{ marginTop: '10vh' }}
title={agent ? t('agents.edit.title') : t('agents.add.title')} title={agent ? t('agents.edit.title') : t('agents.add.title')}
open={open} open={open}
onOk={() => formRef.current?.submit()} onOk={() => formRef.current?.submit()}
onCancel={onCancel} onCancel={onCancel}
maskClosable={false} maskClosable={false}
afterClose={onClose} afterClose={onClose}
okText={agent ? t('common.save') : t('agents.add.button')}> okText={agent ? t('common.save') : t('agents.add.button')}
centered>
<Form <Form
ref={formRef} ref={formRef}
form={form} form={form}
@@ -100,16 +131,28 @@ const PopupContainer: React.FC<Props> = ({ agent, resolve }) => {
style={{ marginTop: 25 }} style={{ marginTop: 25 }}
onFinish={onFinish}> onFinish={onFinish}>
<Form.Item name="name" label="Emoji"> <Form.Item name="name" label="Emoji">
<Popover content={<EmojiPicker onEmojiClick={setEmoji} />} trigger="click" arrow> <Popover content={<EmojiPicker onEmojiClick={setEmoji} />} arrow>
<Button icon={emoji && <span style={{ fontSize: 20 }}>{emoji}</span>}>{t('common.select')}</Button> <Button icon={emoji && <span style={{ fontSize: 20 }}>{emoji}</span>}>{t('common.select')}</Button>
</Popover> </Popover>
</Form.Item> </Form.Item>
<Form.Item name="name" label={t('agents.add.name')} rules={[{ required: true }]}> <Form.Item name="name" label={t('agents.add.name')} rules={[{ required: true }]}>
<Input placeholder={t('agents.add.name.placeholder')} spellCheck={false} allowClear /> <Input placeholder={t('agents.add.name.placeholder')} spellCheck={false} allowClear />
</Form.Item> </Form.Item>
<Form.Item name="prompt" label={t('agents.add.prompt')} rules={[{ required: true }]}> <div style={{ position: 'relative' }}>
<TextArea placeholder={t('agents.add.prompt.placeholder')} spellCheck={false} rows={4} /> <Form.Item
name="prompt"
label={t('agents.add.prompt')}
rules={[{ required: true }]}
style={{ position: 'relative' }}>
<TextArea placeholder={t('agents.add.prompt.placeholder')} spellCheck={false} rows={10} />
</Form.Item> </Form.Item>
<Button
icon={loading ? <LoadingOutlined /> : <ThunderboltOutlined />}
onClick={handleButtonClick}
style={{ position: 'absolute', top: 8, right: 8 }}
disabled={loading}
/>
</div>
</Form> </Form>
</Modal> </Modal>
) )

View File

@@ -35,13 +35,13 @@ const PopupContainer: React.FC = () => {
return ( return (
<Modal <Modal
style={{ marginTop: '10vh' }}
title={t('agents.manage.title')} title={t('agents.manage.title')}
open={open} open={open}
onOk={onOk} onOk={onOk}
onCancel={onCancel} onCancel={onCancel}
afterClose={onClose} afterClose={onClose}
footer={null}> footer={null}
centered>
<Container> <Container>
{agents.length > 0 && ( {agents.length > 0 && (
<DragableList list={agents} onUpdate={updateAgents}> <DragableList list={agents} onUpdate={updateAgents}>

View File

@@ -4,23 +4,22 @@ import { Center } from '@renderer/components/Layout'
import { getAllMinApps } from '@renderer/config/minapp' import { getAllMinApps } from '@renderer/config/minapp'
import { Empty, Input } from 'antd' import { Empty, Input } from 'antd'
import { isEmpty } from 'lodash' import { isEmpty } from 'lodash'
import { FC, useState } from 'react' import { FC, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
import App from './App' import App from './App'
const list = getAllMinApps()
const AppsPage: FC = () => { const AppsPage: FC = () => {
const { t } = useTranslation() const { t } = useTranslation()
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const apps = useMemo(() => getAllMinApps(), [])
const apps = search const filteredApps = search
? list.filter( ? apps.filter(
(app) => app.name.toLowerCase().includes(search.toLowerCase()) || app.url.includes(search.toLowerCase()) (app) => app.name.toLowerCase().includes(search.toLowerCase()) || app.url.includes(search.toLowerCase())
) )
: list : apps
return ( return (
<Container> <Container>
@@ -42,10 +41,10 @@ const AppsPage: FC = () => {
</Navbar> </Navbar>
<ContentContainer> <ContentContainer>
<AppsContainer> <AppsContainer>
{apps.map((app) => ( {filteredApps.map((app) => (
<App key={app.name} app={app} /> <App key={app.id} app={app} />
))} ))}
{isEmpty(apps) && ( {isEmpty(filteredApps) && (
<Center style={{ flex: 1 }}> <Center style={{ flex: 1 }}>
<Empty /> <Empty />
</Center> </Center>

View File

@@ -0,0 +1,100 @@
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
import { VStack } from '@renderer/components/Layout'
import db from '@renderer/databases'
import FileManager from '@renderer/services/file'
import { FileType, FileTypes } from '@renderer/types'
import { Image, Table } from 'antd'
import dayjs from 'dayjs'
import { useLiveQuery } from 'dexie-react-hooks'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
const FilesPage: FC = () => {
const { t } = useTranslation()
const files = useLiveQuery<FileType[]>(() => db.files.orderBy('created_at').reverse().toArray())
const dataSource = files?.map((file) => {
const isImage = file.type === FileTypes.IMAGE
const ImageView = <Image src={'file://' + file.path} preview={false} style={{ maxHeight: '40px' }} />
return {
key: file.id,
file: isImage ? ImageView : <FileNameText className="text-nowrap">{file.origin_name}</FileNameText>,
name: <a href={'file://' + FileManager.getSafePath(file)}>{file.origin_name}</a>,
size: `${(file.size / 1024 / 1024).toFixed(2)} MB`,
count: file.count,
created_at: dayjs(file.created_at).format('MM-DD HH:mm')
}
})
const columns = [
{
title: t('files.file'),
dataIndex: 'file',
key: 'file',
width: '300px'
},
{
title: t('files.name'),
dataIndex: 'name',
key: 'name'
},
{
title: t('files.size'),
dataIndex: 'size',
key: 'size',
width: '100px'
},
{
title: t('files.count'),
dataIndex: 'count',
key: 'count',
width: '100px'
},
{
title: t('files.created_at'),
dataIndex: 'created_at',
key: 'created_at',
width: '120px'
}
]
return (
<Container>
<Navbar>
<NavbarCenter style={{ borderRight: 'none' }}>{t('files.title')}</NavbarCenter>
</Navbar>
<ContentContainer>
<VStack style={{ flex: 1 }}>
<Table dataSource={dataSource} columns={columns} style={{ width: '100%', height: '100%' }} size="small" />
</VStack>
</ContentContainer>
</Container>
)
}
const Container = styled.div`
display: flex;
flex: 1;
flex-direction: column;
height: 100%;
`
const ContentContainer = styled.div`
display: flex;
flex: 1;
flex-direction: row;
justify-content: center;
height: 100%;
overflow-y: scroll;
background-color: var(--color-background);
padding: 20px;
`
const FileNameText = styled.div`
font-size: 14px;
color: var(--color-text);
max-width: 300px;
`
export default FilesPage

View File

@@ -1,40 +1,52 @@
import { CopyOutlined, DeleteOutlined, EditOutlined, MinusCircleOutlined } from '@ant-design/icons' import { DeleteOutlined, EditOutlined, MinusCircleOutlined, PlusOutlined } from '@ant-design/icons'
import DragableList from '@renderer/components/DragableList' import DragableList from '@renderer/components/DragableList'
import CopyIcon from '@renderer/components/Icons/CopyIcon'
import AssistantSettingPopup from '@renderer/components/Popups/AssistantSettingPopup' import AssistantSettingPopup from '@renderer/components/Popups/AssistantSettingPopup'
import { useAssistant, useAssistants } from '@renderer/hooks/useAssistant' import { useAssistant, useAssistants } from '@renderer/hooks/useAssistant'
import { useShowTopics } from '@renderer/hooks/useStore' import { useSettings } from '@renderer/hooks/useSettings'
import { getDefaultTopic, syncAsistantToAgent } from '@renderer/services/assistant' import { getDefaultTopic, syncAsistantToAgent } from '@renderer/services/assistant'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/event' import { EVENT_NAMES, EventEmitter } from '@renderer/services/event'
import { useAppSelector } from '@renderer/store' import { useAppDispatch, useAppSelector } from '@renderer/store'
import { setSearching } from '@renderer/store/runtime'
import { Assistant } from '@renderer/types' import { Assistant } from '@renderer/types'
import { uuid } from '@renderer/utils' import { uuid } from '@renderer/utils'
import { Dropdown } from 'antd' import { Dropdown, Input, InputRef } from 'antd'
import { ItemType } from 'antd/es/menu/interface' import { ItemType } from 'antd/es/menu/interface'
import { last } from 'lodash' import { isEmpty, last } from 'lodash'
import { FC, useCallback } from 'react' import { FC, useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
interface Props { interface Props {
activeAssistant: Assistant activeAssistant: Assistant
setActiveAssistant: (assistant: Assistant) => void setActiveAssistant: (assistant: Assistant) => void
onCreateDefaultAssistant: () => void
onCreateAssistant: () => void onCreateAssistant: () => void
} }
const Assistants: FC<Props> = ({ activeAssistant, setActiveAssistant, onCreateAssistant }) => { const Assistants: FC<Props> = ({
activeAssistant,
setActiveAssistant,
onCreateAssistant,
onCreateDefaultAssistant
}) => {
const { assistants, removeAssistant, addAssistant, updateAssistants } = useAssistants() const { assistants, removeAssistant, addAssistant, updateAssistants } = useAssistants()
const generating = useAppSelector((state) => state.runtime.generating) const generating = useAppSelector((state) => state.runtime.generating)
const [search, setSearch] = useState('')
const [dragging, setDragging] = useState(false)
const { updateAssistant, removeAllTopics } = useAssistant(activeAssistant.id) const { updateAssistant, removeAllTopics } = useAssistant(activeAssistant.id)
const { showTopics, toggleShowTopics } = useShowTopics() const { clickAssistantToShowTopic, topicPosition } = useSettings()
const searchRef = useRef<InputRef>(null)
const { t } = useTranslation() const { t } = useTranslation()
const dispatch = useAppDispatch()
const onDelete = useCallback( const onDelete = useCallback(
(assistant: Assistant) => { (assistant: Assistant) => {
const _assistant = last(assistants.filter((a) => a.id !== assistant.id)) const _assistant = last(assistants.filter((a) => a.id !== assistant.id))
_assistant ? setActiveAssistant(_assistant) : onCreateAssistant() _assistant ? setActiveAssistant(_assistant) : onCreateDefaultAssistant()
removeAssistant(assistant.id) removeAssistant(assistant.id)
}, },
[assistants, onCreateAssistant, removeAssistant, setActiveAssistant] [assistants, onCreateDefaultAssistant, removeAssistant, setActiveAssistant]
) )
const onEditAssistant = useCallback( const onEditAssistant = useCallback(
@@ -58,7 +70,7 @@ const Assistants: FC<Props> = ({ activeAssistant, setActiveAssistant, onCreateAs
{ {
label: t('common.duplicate'), label: t('common.duplicate'),
key: 'duplicate', key: 'duplicate',
icon: <CopyOutlined />, icon: <CopyIcon />,
onClick: async () => { onClick: async () => {
const _assistant: Assistant = { ...assistant, id: uuid(), topics: [getDefaultTopic()] } const _assistant: Assistant = { ...assistant, id: uuid(), topics: [getDefaultTopic()] }
addAssistant(_assistant) addAssistant(_assistant)
@@ -73,6 +85,7 @@ const Assistants: FC<Props> = ({ activeAssistant, setActiveAssistant, onCreateAs
window.modal.confirm({ window.modal.confirm({
title: t('chat.topics.delete.all.title'), title: t('chat.topics.delete.all.title'),
content: t('chat.topics.delete.all.content'), content: t('chat.topics.delete.all.content'),
centered: true,
okButtonProps: { danger: true }, okButtonProps: { danger: true },
onOk: removeAllTopics onOk: removeAllTopics
}) })
@@ -99,32 +112,108 @@ const Assistants: FC<Props> = ({ activeAssistant, setActiveAssistant, onCreateAs
}) })
} }
if (topicPosition === 'left' && clickAssistantToShowTopic) {
EventEmitter.emit(EVENT_NAMES.SWITCH_TOPIC_SIDEBAR) EventEmitter.emit(EVENT_NAMES.SWITCH_TOPIC_SIDEBAR)
}
setActiveAssistant(assistant) setActiveAssistant(assistant)
}, },
[generating, setActiveAssistant, t] [clickAssistantToShowTopic, generating, setActiveAssistant, t, topicPosition]
) )
const list = assistants.filter((assistant) => assistant.name?.toLowerCase().includes(search.toLowerCase().trim()))
const onSearch = (e: React.KeyboardEvent<HTMLInputElement>) => {
const isEnterPressed = e.keyCode == 13
if (e.key === 'Escape') {
return searchRef.current?.blur()
}
if (isEnterPressed) {
if (list.length > 0) {
if (list.length === 1) {
onSwitchAssistant(list[0])
setSearch('')
setTimeout(() => searchRef.current?.blur(), 0)
return
}
const index = list.findIndex((a) => a.id === activeAssistant?.id)
onSwitchAssistant(index === list.length - 1 ? list[0] : list[index + 1])
}
}
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
searchRef.current?.focus()
searchRef.current?.select()
}
}
// Command or Ctrl + K create new topic
useEffect(() => {
const onKeydown = (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
searchRef.current?.focus()
searchRef.current?.select()
}
}
document.addEventListener('keydown', onKeydown)
return () => document.removeEventListener('keydown', onKeydown)
}, [activeAssistant?.id, list, onSwitchAssistant])
return ( return (
<Container> <Container>
<DragableList list={assistants} onUpdate={updateAssistants}> {assistants.length >= 10 && (
<SearchContainer>
<Input
placeholder={t('chat.assistant.search.placeholder')}
suffix={<CommandKey>+K</CommandKey>}
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{ borderRadius: 4, borderWidth: 0.5 }}
onKeyDown={onSearch}
ref={searchRef}
onFocus={() => dispatch(setSearching(true))}
onBlur={() => {
dispatch(setSearching(false))
setSearch('')
}}
allowClear
/>
</SearchContainer>
)}
<DragableList
list={list}
onUpdate={updateAssistants}
droppableProps={{ isDropDisabled: !isEmpty(search) }}
style={{ paddingBottom: dragging ? '34px' : 0 }}
onDragStart={() => setDragging(true)}
onDragEnd={() => setDragging(false)}>
{(assistant) => { {(assistant) => {
const isCurrent = assistant.id === activeAssistant?.id const isCurrent = assistant.id === activeAssistant?.id
return ( return (
<Dropdown key={assistant.id} menu={{ items: getMenuItems(assistant) }} trigger={['contextMenu']}> <Dropdown key={assistant.id} menu={{ items: getMenuItems(assistant) }} trigger={['contextMenu']}>
<AssistantItem onClick={() => onSwitchAssistant(assistant)} className={isCurrent ? 'active' : ''}> <AssistantItem onClick={() => onSwitchAssistant(assistant)} className={isCurrent ? 'active' : ''}>
<AssistantName className="name">{assistant.name || t('chat.default.name')}</AssistantName> <AssistantName className="name">{assistant.name || t('chat.default.name')}</AssistantName>
<ArrowRightButton {isCurrent && (
className={`arrow-button ${isCurrent && showTopics ? 'active' : ''}`} <ArrowRightButton onClick={() => EventEmitter.emit(EVENT_NAMES.SWITCH_TOPIC_SIDEBAR)}>
onClick={() => isCurrent && toggleShowTopics()}>
<i className="iconfont icon-gridlines" /> <i className="iconfont icon-gridlines" />
</ArrowRightButton> </ArrowRightButton>
)}
{false && <TopicCount className="topics-count">{assistant.topics.length}</TopicCount>} {false && <TopicCount className="topics-count">{assistant.topics.length}</TopicCount>}
</AssistantItem> </AssistantItem>
</Dropdown> </Dropdown>
) )
}} }}
</DragableList> </DragableList>
{!dragging && (
<AssistantItem onClick={onCreateAssistant}>
<AssistantName>
<PlusOutlined style={{ color: 'var(--color-text-2)', marginRight: 4 }} />
{t('chat.add.assistant.title')}
</AssistantName>
</AssistantItem>
)}
</Container> </Container>
) )
} }
@@ -132,9 +221,6 @@ const Assistants: FC<Props> = ({ activeAssistant, setActiveAssistant, onCreateAs
const Container = styled.div` const Container = styled.div`
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-width: var(--assistants-width);
max-width: var(--assistants-width);
border-right: 0.5px solid var(--color-border);
height: calc(100vh - var(--navbar-height)); height: calc(100vh - var(--navbar-height));
overflow-y: auto; overflow-y: auto;
padding-top: 10px; padding-top: 10px;
@@ -150,12 +236,15 @@ const AssistantItem = styled.div`
border-radius: 4px; border-radius: 4px;
margin: 0 10px; margin: 0 10px;
padding-right: 35px; padding-right: 35px;
cursor: pointer;
font-family: Ubuntu; font-family: Ubuntu;
cursor: pointer;
.iconfont { .iconfont {
opacity: 0; opacity: 0;
color: var(--color-text-3); color: var(--color-text-3);
} }
&:hover {
background-color: var(--color-background-soft);
}
&.active { &.active {
background-color: var(--color-background-mute); background-color: var(--color-background-mute);
.name { .name {
@@ -185,28 +274,23 @@ const ArrowRightButton = styled.div`
flex-direction: row; flex-direction: row;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
width: 24px; width: 22px;
height: 24px; height: 22px;
min-width: 24px; min-width: 22px;
min-height: 24px; min-height: 22px;
border-radius: 4px; border-radius: 4px;
position: absolute; position: absolute;
right: 10px;
top: 5px;
.anticon {
font-size: 14px;
}
&:hover {
background-color: var(--color-background);
}
&.active {
background-color: var(--color-background); background-color: var(--color-background);
right: 9px;
top: 6px;
.iconfont {
font-size: 12px;
} }
` `
const TopicCount = styled.div` const TopicCount = styled.div`
color: var(--color-text-3); color: var(--color-text-2);
font-size: 12px; font-size: 10px;
margin-right: 3px; margin-right: 3px;
background-color: var(--color-background-mute); background-color: var(--color-background-mute);
opacity: 0.8; opacity: 0.8;
@@ -219,4 +303,18 @@ const TopicCount = styled.div`
align-items: center; align-items: center;
` `
const SearchContainer = styled.div`
margin: 0 10px;
margin-bottom: 10px;
`
const CommandKey = styled.div`
color: var(--color-text-2);
font-size: 10px;
padding: 2px 5px;
border-radius: 4px;
background-color: var(--color-background);
margin-right: -4px;
`
export default Assistants export default Assistants

View File

@@ -8,30 +8,34 @@ import styled from 'styled-components'
import Inputbar from './Inputbar/Inputbar' import Inputbar from './Inputbar/Inputbar'
import Messages from './Messages/Messages' import Messages from './Messages/Messages'
import Topics from './Topics' import RightSidebar from './RightSidebar'
interface Props { interface Props {
assistant: Assistant assistant: Assistant
activeTopic: Topic activeTopic: Topic
setActiveTopic: (topic: Topic) => void setActiveTopic: (topic: Topic) => void
setActiveAssistant: (assistant: Assistant) => void
} }
const Chat: FC<Props> = (props) => { const Chat: FC<Props> = (props) => {
const { assistant } = useAssistant(props.assistant.id) const { assistant } = useAssistant(props.assistant.id)
const { showTopics } = useShowTopics()
const { topicPosition } = useSettings() const { topicPosition } = useSettings()
const { showTopics } = useShowTopics()
return ( return (
<Container id="chat"> <Container id="chat">
{showTopics && topicPosition === 'left' && (
<Topics assistant={assistant} activeTopic={props.activeTopic} setActiveTopic={props.setActiveTopic} />
)}
<Main vertical flex={1} justify="space-between"> <Main vertical flex={1} justify="space-between">
<Messages assistant={assistant} topic={props.activeTopic} setActiveTopic={props.setActiveTopic} /> <Messages assistant={assistant} topic={props.activeTopic} setActiveTopic={props.setActiveTopic} />
<Inputbar assistant={assistant} setActiveTopic={props.setActiveTopic} /> <Inputbar assistant={assistant} setActiveTopic={props.setActiveTopic} />
</Main> </Main>
{showTopics && topicPosition === 'right' && ( {topicPosition === 'right' && showTopics && (
<Topics assistant={assistant} activeTopic={props.activeTopic} setActiveTopic={props.setActiveTopic} /> <RightSidebar
activeAssistant={assistant}
activeTopic={props.activeTopic}
setActiveAssistant={props.setActiveAssistant}
setActiveTopic={props.setActiveTopic}
position="right"
/>
)} )}
</Container> </Container>
) )

View File

@@ -1,54 +1,43 @@
import { useAssistants, useDefaultAssistant } from '@renderer/hooks/useAssistant' import { useAssistants } from '@renderer/hooks/useAssistant'
import { useShowAssistants } from '@renderer/hooks/useStore' import { useShowAssistants } from '@renderer/hooks/useStore'
import { useActiveTopic } from '@renderer/hooks/useTopic' import { useActiveTopic } from '@renderer/hooks/useTopic'
import { Assistant, Topic } from '@renderer/types' import { Assistant } from '@renderer/types'
import { uuid } from '@renderer/utils'
import { FC, useState } from 'react' import { FC, useState } from 'react'
import styled from 'styled-components' import styled from 'styled-components'
import Assistants from './Assistants'
import Chat from './Chat' import Chat from './Chat'
import Navbar from './Navbar' import Navbar from './Navbar'
import RightSidebar from './RightSidebar'
let _activeAssistant: Assistant let _activeAssistant: Assistant
const HomePage: FC = () => { const HomePage: FC = () => {
const { assistants, addAssistant } = useAssistants() const { assistants } = useAssistants()
const [activeAssistant, setActiveAssistant] = useState(_activeAssistant || assistants[0]) const [activeAssistant, setActiveAssistant] = useState(_activeAssistant || assistants[0])
const { showAssistants } = useShowAssistants() const { showAssistants } = useShowAssistants()
const { defaultAssistant } = useDefaultAssistant()
const { activeTopic, setActiveTopic } = useActiveTopic(activeAssistant) const { activeTopic, setActiveTopic } = useActiveTopic(activeAssistant)
_activeAssistant = activeAssistant _activeAssistant = activeAssistant
const onCreateDefaultAssistant = () => {
const assistant = { ...defaultAssistant, id: uuid() }
addAssistant(assistant)
setActiveAssistant(assistant)
}
const onSetActiveTopic = (topic: Topic) => {
setActiveTopic(topic)
}
return ( return (
<Container> <Container>
<Navbar <Navbar activeAssistant={activeAssistant} activeTopic={activeTopic} setActiveTopic={setActiveTopic} />
activeAssistant={activeAssistant}
setActiveAssistant={setActiveAssistant}
activeTopic={activeTopic}
setActiveTopic={onSetActiveTopic}
/>
<ContentContainer> <ContentContainer>
{showAssistants && ( {showAssistants && (
<Assistants <RightSidebar
activeAssistant={activeAssistant} activeAssistant={activeAssistant}
activeTopic={activeTopic}
setActiveAssistant={setActiveAssistant} setActiveAssistant={setActiveAssistant}
onCreateAssistant={onCreateDefaultAssistant} setActiveTopic={setActiveTopic}
position="left"
/> />
)} )}
<Chat assistant={activeAssistant} activeTopic={activeTopic} setActiveTopic={onSetActiveTopic} /> <Chat
assistant={activeAssistant}
activeTopic={activeTopic}
setActiveTopic={setActiveTopic}
setActiveAssistant={setActiveAssistant}
/>
</ContentContainer> </ContentContainer>
</Container> </Container>
) )

View File

@@ -1,29 +1,44 @@
import { PaperClipOutlined } from '@ant-design/icons' import { PaperClipOutlined } from '@ant-design/icons'
import { Tooltip, Upload } from 'antd' import { imageExts, textExts } from '@renderer/config/constant'
import { isVisionModel } from '@renderer/config/models'
import { FileType, Model } from '@renderer/types'
import { Tooltip } from 'antd'
import { FC } from 'react' import { FC } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
interface Props { interface Props {
files: File[] model: Model
setFiles: (files: File[]) => void files: FileType[]
setFiles: (files: FileType[]) => void
ToolbarButton: any ToolbarButton: any
} }
const AttachmentButton: FC<Props> = ({ files, setFiles, ToolbarButton }) => { const AttachmentButton: FC<Props> = ({ model, files, setFiles, ToolbarButton }) => {
const { t } = useTranslation() const { t } = useTranslation()
const extensions = isVisionModel(model) ? [...imageExts, ...textExts] : [...textExts]
const onSelectFile = async () => {
if (files.length > 0) {
return setFiles([])
}
const _files = await window.api.file.select({
filters: [
{
name: 'Files',
extensions: extensions.map((i) => i.replace('.', ''))
}
]
})
_files && setFiles(_files)
}
return ( return (
<Tooltip placement="top" title={t('chat.input.upload')} arrow> <Tooltip placement="top" title={t('chat.input.upload')} arrow>
<Upload <ToolbarButton type="text" className={files.length ? 'active' : ''} onClick={onSelectFile}>
customRequest={() => {}}
accept="image/*"
itemRender={() => null}
maxCount={1}
onChange={async ({ file }) => file?.originFileObj && setFiles([file.originFileObj as File])}>
<ToolbarButton type="text" className={files.length ? 'active' : ''}>
<PaperClipOutlined style={{ rotate: '135deg' }} /> <PaperClipOutlined style={{ rotate: '135deg' }} />
</ToolbarButton> </ToolbarButton>
</Upload>
</Tooltip> </Tooltip>
) )
} }

View File

@@ -0,0 +1,42 @@
import FileManager from '@renderer/services/file'
import { FileType } from '@renderer/types'
import { Upload } from 'antd'
import { isEmpty } from 'lodash'
import { FC } from 'react'
import styled from 'styled-components'
interface Props {
files: FileType[]
setFiles: (files: FileType[]) => void
}
const AttachmentPreview: FC<Props> = ({ files, setFiles }) => {
if (isEmpty(files)) {
return null
}
return (
<Container>
<Upload
listType="picture-card"
fileList={files.map((file) => ({
uid: file.id,
url: 'file://' + FileManager.getSafePath(file),
status: 'done',
name: file.name
}))}
onRemove={(item) => setFiles(files.filter((file) => item.uid !== file.id))}
/>
</Container>
)
}
const Container = styled.div`
display: flex;
flex-direction: row;
gap: 10px;
margin: 10px 20px;
margin-right: 0;
`
export default AttachmentPreview

View File

@@ -4,22 +4,25 @@ import {
FormOutlined, FormOutlined,
FullscreenExitOutlined, FullscreenExitOutlined,
FullscreenOutlined, FullscreenOutlined,
HistoryOutlined,
PauseCircleOutlined, PauseCircleOutlined,
QuestionCircleOutlined QuestionCircleOutlined
} from '@ant-design/icons' } from '@ant-design/icons'
import { isWindows } from '@renderer/config/constant' import { imageExts, textExts } from '@renderer/config/constant'
import { isVisionModel } from '@renderer/config/models'
import db from '@renderer/databases'
import { useAssistant } from '@renderer/hooks/useAssistant' import { useAssistant } from '@renderer/hooks/useAssistant'
import { useSettings } from '@renderer/hooks/useSettings' import { useSettings } from '@renderer/hooks/useSettings'
import { useShowTopics } from '@renderer/hooks/useStore' import { useRuntime, useShowTopics } from '@renderer/hooks/useStore'
import { getDefaultTopic } from '@renderer/services/assistant' import { getDefaultTopic } from '@renderer/services/assistant'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/event' import { EVENT_NAMES, EventEmitter } from '@renderer/services/event'
import { estimateInputTokenCount } from '@renderer/services/messages' import FileManager from '@renderer/services/file'
import store, { useAppSelector } from '@renderer/store' import { estimateTextTokens } from '@renderer/services/tokens'
import { setGenerating } from '@renderer/store/runtime' import store, { useAppDispatch, useAppSelector } from '@renderer/store'
import { Assistant, Message, Topic } from '@renderer/types' import { setGenerating, setSearching } from '@renderer/store/runtime'
import { delay, uuid } from '@renderer/utils' import { Assistant, FileType, Message, Topic } from '@renderer/types'
import { Button, Divider, Popconfirm, Popover, Tag, Tooltip } from 'antd' import { delay, getFileExtension, uuid } from '@renderer/utils'
import { insertTextAtCursor } from '@renderer/utils/input'
import { Button, Popconfirm, Tooltip } from 'antd'
import TextArea, { TextAreaRef } from 'antd/es/input/TextArea' import TextArea, { TextAreaRef } from 'antd/es/input/TextArea'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { debounce, isEmpty } from 'lodash' import { debounce, isEmpty } from 'lodash'
@@ -27,8 +30,10 @@ import { CSSProperties, FC, useCallback, useEffect, useMemo, useRef, useState }
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
import SettingsTab from '../Settings' import AttachmentButton from './AttachmentButton'
import AttachmentPreview from './AttachmentPreview'
import SendMessageButton from './SendMessageButton' import SendMessageButton from './SendMessageButton'
import TokenCount from './TokenCount'
interface Props { interface Props {
assistant: Assistant assistant: Assistant
@@ -36,25 +41,33 @@ interface Props {
} }
let _text = '' let _text = ''
let _files: FileType[] = []
const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => { const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
const [text, setText] = useState(_text) const [text, setText] = useState(_text)
const [inputFocus, setInputFocus] = useState(false) const [inputFocus, setInputFocus] = useState(false)
const { addTopic } = useAssistant(assistant.id) const { addTopic, model } = useAssistant(assistant.id)
const { sendMessageShortcut, showInputEstimatedTokens, fontSize } = useSettings() const { sendMessageShortcut, fontSize, pasteLongTextAsFile } = useSettings()
const [expended, setExpend] = useState(false) const [expended, setExpend] = useState(false)
const [estimateTokenCount, setEstimateTokenCount] = useState(0) const [estimateTokenCount, setEstimateTokenCount] = useState(0)
const [contextCount, setContextCount] = useState(0) const [contextCount, setContextCount] = useState(0)
const generating = useAppSelector((state) => state.runtime.generating) const generating = useAppSelector((state) => state.runtime.generating)
const textareaRef = useRef<TextAreaRef>(null) const textareaRef = useRef<TextAreaRef>(null)
const [files, setFiles] = useState<File[]>([]) const [files, setFiles] = useState<FileType[]>(_files)
const { t } = useTranslation() const { t } = useTranslation()
const containerRef = useRef(null) const containerRef = useRef(null)
const { toggleShowTopics } = useShowTopics() const { showTopics, toggleShowTopics } = useShowTopics()
const { searching } = useRuntime()
const dispatch = useAppDispatch()
const isVision = useMemo(() => isVisionModel(model), [model])
const supportExts = useMemo(() => [...textExts, ...(isVision ? imageExts : [])], [isVision])
const inputTokenCount = useMemo(() => estimateTextTokens(text), [text])
_text = text _text = text
_files = files
const sendMessage = useCallback(() => { const sendMessage = useCallback(async () => {
if (generating) { if (generating) {
return return
} }
@@ -74,7 +87,7 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
} }
if (files.length > 0) { if (files.length > 0) {
message.files = files message.files = await FileManager.uploadFiles(files)
} }
EventEmitter.emit(EVENT_NAMES.SEND_MESSAGE, message) EventEmitter.emit(EVENT_NAMES.SEND_MESSAGE, message)
@@ -87,8 +100,6 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
setExpend(false) setExpend(false)
}, [assistant.id, assistant.topics, generating, files, text]) }, [assistant.id, assistant.topics, generating, files, text])
const inputTokenCount = useMemo(() => estimateInputTokenCount(text), [text])
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => { const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
const isEnterPressed = event.keyCode == 13 const isEnterPressed = event.keyCode == 13
@@ -116,6 +127,7 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
const topic = getDefaultTopic() const topic = getDefaultTopic()
addTopic(topic) addTopic(topic)
setActiveTopic(topic) setActiveTopic(topic)
db.topics.add({ id: topic.id, messages: [] })
}, [addTopic, setActiveTopic]) }, [addTopic, setActiveTopic])
const clearTopic = async () => { const clearTopic = async () => {
@@ -132,10 +144,7 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
} }
const onNewContext = () => { const onNewContext = () => {
if (generating) { if (generating) return onPause()
onPause()
return
}
EventEmitter.emit(EVENT_NAMES.NEW_CONTEXT) EventEmitter.emit(EVENT_NAMES.NEW_CONTEXT)
} }
@@ -165,6 +174,52 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
const onInput = () => !expended && resizeTextArea() const onInput = () => !expended && resizeTextArea()
const onPaste = useCallback(
async (event: ClipboardEvent) => {
for (const file of event.clipboardData?.files || []) {
event.preventDefault()
if (file.path === '') {
if (file.type.startsWith('image/')) {
const tempFilePath = await window.api.file.create(file.name)
const arrayBuffer = await file.arrayBuffer()
const uint8Array = new Uint8Array(arrayBuffer)
await window.api.file.write(tempFilePath, uint8Array)
const selectedFile = await window.api.file.get(tempFilePath)
selectedFile && setFiles((prevFiles) => [...prevFiles, selectedFile])
break
}
}
if (file.path) {
if (supportExts.includes(getFileExtension(file.path))) {
const selectedFile = await window.api.file.get(file.path)
selectedFile && setFiles((prevFiles) => [...prevFiles, selectedFile])
}
}
}
if (pasteLongTextAsFile) {
const item = event.clipboardData?.items[0]
if (item && item.kind === 'string' && item.type === 'text/plain') {
event.preventDefault()
item.getAsString(async (pasteText) => {
if (pasteText.length > 1500) {
const tempFilePath = await window.api.file.create('pasted_text.txt')
await window.api.file.write(tempFilePath, pasteText)
const selectedFile = await window.api.file.get(tempFilePath)
selectedFile && setFiles((prevFiles) => [...prevFiles, selectedFile])
} else {
insertTextAtCursor({ text, pasteText, textareaRef, setText })
setTimeout(() => resizeTextArea(), 0)
}
})
}
}
},
[pasteLongTextAsFile, supportExts, text]
)
// Command or Ctrl + N create new topic // Command or Ctrl + N create new topic
useEffect(() => { useEffect(() => {
const onKeydown = (e) => { const onKeydown = (e) => {
@@ -186,6 +241,7 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
EventEmitter.on(EVENT_NAMES.EDIT_MESSAGE, (message: Message) => { EventEmitter.on(EVENT_NAMES.EDIT_MESSAGE, (message: Message) => {
setText(message.content) setText(message.content)
textareaRef.current?.focus() textareaRef.current?.focus()
setTimeout(() => resizeTextArea(), 0)
}), }),
EventEmitter.on(EVENT_NAMES.ESTIMATED_TOKEN_COUNT, ({ tokensCount, contextCount }) => { EventEmitter.on(EVENT_NAMES.ESTIMATED_TOKEN_COUNT, ({ tokensCount, contextCount }) => {
_setEstimateTokenCount(tokensCount) _setEstimateTokenCount(tokensCount)
@@ -200,7 +256,9 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
}, [assistant]) }, [assistant])
return ( return (
<Container id="inputbar" className={inputFocus ? 'focus' : ''} ref={containerRef}> <Container>
<AttachmentPreview files={files} setFiles={setFiles} />
<InputBarContainer id="inputbar" className={inputFocus ? 'focus' : ''} ref={containerRef}>
<Textarea <Textarea
value={text} value={text}
onChange={(e) => setText(e.target.value)} onChange={(e) => setText(e.target.value)}
@@ -216,6 +274,9 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
onFocus={() => setInputFocus(true)} onFocus={() => setInputFocus(true)}
onBlur={() => setInputFocus(false)} onBlur={() => setInputFocus(false)}
onInput={onInput} onInput={onInput}
disabled={searching}
onPaste={(e) => onPaste(e.nativeEvent)}
onClick={() => searching && dispatch(setSearching(false))}
/> />
<Toolbar> <Toolbar>
<ToolbarMenu> <ToolbarMenu>
@@ -224,11 +285,6 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
<FormOutlined /> <FormOutlined />
</ToolbarButton> </ToolbarButton>
</Tooltip> </Tooltip>
<Tooltip placement="top" title={t('chat.input.new.context')} arrow>
<ToolbarButton type="text" onClick={onNewContext}>
<i className="iconfont icon-grid-row-2copy" />
</ToolbarButton>
</Tooltip>
<Tooltip placement="top" title={t('chat.input.clear')} arrow> <Tooltip placement="top" title={t('chat.input.clear')} arrow>
<Popconfirm <Popconfirm
title={t('chat.input.clear.content')} title={t('chat.input.clear.content')}
@@ -242,37 +298,29 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
</ToolbarButton> </ToolbarButton>
</Popconfirm> </Popconfirm>
</Tooltip> </Tooltip>
<Tooltip placement="top" title={t('chat.input.topics')} arrow>
<ToolbarButton type="text" onClick={toggleShowTopics}>
<HistoryOutlined />
</ToolbarButton>
</Tooltip>
<Popover content={<SettingsTab assistant={assistant} />} trigger="click" placement="topRight">
<Tooltip placement="top" title={t('chat.input.settings')} arrow> <Tooltip placement="top" title={t('chat.input.settings')} arrow>
<ToolbarButton type="text"> <ToolbarButton
type="text"
onClick={() => {
!showTopics && toggleShowTopics()
setTimeout(() => EventEmitter.emit(EVENT_NAMES.SHOW_CHAT_SETTINGS), 0)
}}>
<ControlOutlined /> <ControlOutlined />
</ToolbarButton> </ToolbarButton>
</Tooltip> </Tooltip>
</Popover> <AttachmentButton model={model} files={files} setFiles={setFiles} ToolbarButton={ToolbarButton} />
{/* <AttachmentButton files={files} setFiles={setFiles} ToolbarButton={ToolbarButton} /> */}
<Tooltip placement="top" title={expended ? t('chat.input.collapse') : t('chat.input.expand')} arrow> <Tooltip placement="top" title={expended ? t('chat.input.collapse') : t('chat.input.expand')} arrow>
<ToolbarButton type="text" onClick={onToggleExpended}> <ToolbarButton type="text" onClick={onToggleExpended}>
{expended ? <FullscreenExitOutlined /> : <FullscreenOutlined />} {expended ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
</ToolbarButton> </ToolbarButton>
</Tooltip> </Tooltip>
{showInputEstimatedTokens && ( <TokenCount
<TextCount> estimateTokenCount={estimateTokenCount}
<Tooltip title={t('chat.input.context_count.tip') + ' | ' + t('chat.input.estimated_tokens.tip')}> inputTokenCount={inputTokenCount}
<StyledTag> contextCount={contextCount}
<span style={isWindows ? { fontFamily: 'serif', marginRight: 2 } : { marginRight: 3 }}></span> ToolbarButton={ToolbarButton}
{contextCount} onClick={onNewContext}
<Divider type="vertical" style={{ marginTop: 2, marginLeft: 5, marginRight: 5 }} />{inputTokenCount} />
<span style={{ margin: '0 2px', fontSize: 10 }}>/</span>
{estimateTokenCount}
</StyledTag>
</Tooltip>
</TextCount>
)}
</ToolbarMenu> </ToolbarMenu>
<ToolbarMenu> <ToolbarMenu>
{generating && ( {generating && (
@@ -285,17 +333,22 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
{!generating && <SendMessageButton sendMessage={sendMessage} disabled={generating || !text} />} {!generating && <SendMessageButton sendMessage={sendMessage} disabled={generating || !text} />}
</ToolbarMenu> </ToolbarMenu>
</Toolbar> </Toolbar>
</InputBarContainer>
</Container> </Container>
) )
} }
const Container = styled.div`
display: flex;
flex-direction: column;
`
const TextareaStyle: CSSProperties = { const TextareaStyle: CSSProperties = {
paddingLeft: 0, paddingLeft: 0,
padding: '10px 15px 8px', padding: '10px 15px 8px'
transition: 'all 0.3s ease'
} }
const Container = styled.div` const InputBarContainer = styled.div`
border: 1px solid var(--color-border-soft); border: 1px solid var(--color-border-soft);
transition: all 0.3s ease; transition: all 0.3s ease;
position: relative; position: relative;
@@ -346,44 +399,32 @@ const ToolbarButton = styled(Button)`
justify-content: center; justify-content: center;
align-items: center; align-items: center;
padding: 0; padding: 0;
.iconfont {
font-size: 17px;
}
&.anticon, &.anticon,
&.iconfont { &.iconfont {
transition: all 0.3s ease; transition: all 0.3s ease;
color: var(--color-icon); color: var(--color-icon);
} }
&:hover, .icon-a-addchat {
&.active { font-size: 19px;
margin-bottom: -2px;
}
&:hover {
background-color: var(--color-background-soft); background-color: var(--color-background-soft);
.anticon, .anticon,
.iconfont { .iconfont {
color: var(--color-text-1); color: var(--color-text-1);
} }
} }
` &.active {
background-color: var(--color-primary) !important;
const TextCount = styled.div` .anticon,
font-size: 11px; .iconfont {
color: var(--color-text-3); color: var(--color-white-soft);
z-index: 10; }
padding: 2px; &:hover {
border-top-left-radius: 7px; background-color: var(--color-primary);
user-select: none; }
` }
const StyledTag = styled(Tag)`
cursor: pointer;
border-radius: 20px;
display: flex;
align-items: center;
padding: 2px 8px;
border-width: 0.5;
margin: 0;
height: 25px;
font-size: 12px;
line-height: 16px;
` `
export default Inputbar export default Inputbar

View File

@@ -0,0 +1,83 @@
import { ArrowUpOutlined, MenuOutlined, PicCenterOutlined } from '@ant-design/icons'
import { HStack, VStack } from '@renderer/components/Layout'
import { useSettings } from '@renderer/hooks/useSettings'
import { Divider, Popover, Tooltip } from 'antd'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
type Props = {
estimateTokenCount: number
inputTokenCount: number
contextCount: number
ToolbarButton: any
} & React.HTMLAttributes<HTMLDivElement>
const TokenCount: FC<Props> = ({ estimateTokenCount, inputTokenCount, contextCount, ToolbarButton, ...props }) => {
const { t } = useTranslation()
const { showInputEstimatedTokens } = useSettings()
if (!showInputEstimatedTokens) {
return null
}
const PopoverContent = () => {
return (
<VStack w="150px" background="100%">
<HStack justifyContent="space-between" w="100%">
<Text>{t('chat.input.context_count.tip')}</Text>
<Text>{contextCount}</Text>
</HStack>
<Divider style={{ margin: '5px 0' }} />
<HStack justifyContent="space-between" w="100%">
<Text>{t('chat.input.estimated_tokens.tip')}</Text>
<Text>{estimateTokenCount}</Text>
</HStack>
</VStack>
)
}
return (
<>
<ToolbarButton type="text" onClick={props.onClick}>
<Tooltip placement="top" title={t('chat.input.new.context')}>
<PicCenterOutlined />
</Tooltip>
</ToolbarButton>
<Container>
<Popover content={PopoverContent}>
<MenuOutlined /> {contextCount}
<Divider type="vertical" style={{ marginTop: 0, marginLeft: 5, marginRight: 5 }} />
<ArrowUpOutlined />
{inputTokenCount} / {estimateTokenCount}
</Popover>
</Container>
</>
)
}
const Container = styled.div`
font-size: 11px;
line-height: 16px;
color: var(--color-text-2);
z-index: 10;
padding: 3px 10px;
user-select: none;
font-family: Ubuntu;
border: 0.5px solid var(--color-text-3);
border-radius: 20px;
display: flex;
align-items: center;
cursor: pointer;
.anticon {
font-size: 10px;
margin-right: 3px;
}
`
const Text = styled.div`
font-size: 12px;
color: var(--color-text-1);
`
export default TokenCount

View File

@@ -2,8 +2,8 @@ import { CheckOutlined } from '@ant-design/icons'
import CopyIcon from '@renderer/components/Icons/CopyIcon' import CopyIcon from '@renderer/components/Icons/CopyIcon'
import { useTheme } from '@renderer/context/ThemeProvider' import { useTheme } from '@renderer/context/ThemeProvider'
import { initMermaid } from '@renderer/init' import { initMermaid } from '@renderer/init'
import { ThemeMode } from '@renderer/store/settings' import { ThemeMode } from '@renderer/types'
import React, { useState } from 'react' import React, { memo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter' import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
import { atomDark, oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism' import { atomDark, oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism'
@@ -17,55 +17,60 @@ interface CodeBlockProps {
[key: string]: any [key: string]: any
} }
const CodeBlock: React.FC<CodeBlockProps> = ({ children, className, ...rest }) => { const CodeBlock: React.FC<CodeBlockProps> = ({ children, className }) => {
const match = /language-(\w+)/.exec(className || '') const match = /language-(\w+)/.exec(className || '')
const [copied, setCopied] = useState(false) const showFooterCopyButton = children && children.length > 500
const { theme } = useTheme() const { theme } = useTheme()
const { t } = useTranslation()
const onCopy = () => {
navigator.clipboard.writeText(children)
window.message.success({ content: t('message.copied'), key: 'copy-code' })
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
if (match && match[1] === 'mermaid') { if (match && match[1] === 'mermaid') {
initMermaid(theme) initMermaid(theme)
return <Mermaid chart={children} /> return <Mermaid chart={children} />
} }
return match ? ( return match ? (
<> <div className="code-block">
<CodeHeader> <CodeHeader>
<CodeLanguage>{'<' + match[1].toUpperCase() + '>'}</CodeLanguage> <CodeLanguage>{'<' + match[1].toUpperCase() + '>'}</CodeLanguage>
{!copied && <CopyIcon className="copy" onClick={onCopy} />} <CopyButton text={children} />
{copied && <CheckOutlined style={{ color: 'var(--color-primary)' }} />}
</CodeHeader> </CodeHeader>
<SyntaxHighlighter <SyntaxHighlighter
{...rest}
language={match[1]} language={match[1]}
style={theme === ThemeMode.dark ? atomDark : oneLight} style={theme === ThemeMode.dark ? atomDark : oneLight}
wrapLongLines={true} wrapLongLines={true}
customStyle={{ customStyle={{
border: '0.5px solid var(--color-code-background)',
borderTopLeftRadius: 0, borderTopLeftRadius: 0,
borderTopRightRadius: 0, borderTopRightRadius: 0,
marginTop: 0, marginTop: 0
border: '0.5px solid var(--color-code-background)',
borderTop: 'none'
}}> }}>
{String(children).replace(/\n$/, '')} {String(children).replace(/\n$/, '')}
</SyntaxHighlighter> </SyntaxHighlighter>
</> {showFooterCopyButton && (
<CodeFooter>
<CopyButton text={children} style={{ marginTop: -40, marginRight: 10 }} />
</CodeFooter>
)}
</div>
) : ( ) : (
<SyntaxHighlighter <code className={className}>{children}</code>
{...rest} )
style={theme === ThemeMode.dark ? atomDark : oneLight} }
wrapLongLines={true}
customStyle={{ border: '0.5px solid var(--color-code-background)', padding: '8px 12px' }}> const CopyButton: React.FC<{ text: string; style?: React.CSSProperties }> = ({ text, style }) => {
{String(children).replace(/\n$/, '')} const [copied, setCopied] = useState(false)
</SyntaxHighlighter> const { t } = useTranslation()
const onCopy = () => {
navigator.clipboard.writeText(text)
window.message.success({ content: t('message.copied'), key: 'copy-code' })
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return copied ? (
<CheckOutlined style={{ color: 'var(--color-primary)', ...style }} />
) : (
<CopyIcon className="copy" style={style} onClick={onCopy} />
) )
} }
@@ -77,7 +82,7 @@ const CodeHeader = styled.div`
font-size: 14px; font-size: 14px;
font-weight: bold; font-weight: bold;
background-color: var(--color-code-background); background-color: var(--color-code-background);
height: 40px; height: 36px;
padding: 0 10px; padding: 0 10px;
border-top-left-radius: 8px; border-top-left-radius: 8px;
border-top-right-radius: 8px; border-top-right-radius: 8px;
@@ -95,4 +100,19 @@ const CodeLanguage = styled.div`
font-weight: bold; font-weight: bold;
` `
export default CodeBlock const CodeFooter = styled.div`
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
.copy {
cursor: pointer;
color: var(--color-text-3);
transition: color 0.3s;
}
.copy:hover {
color: var(--color-text-1);
}
`
export default memo(CodeBlock)

View File

@@ -1,11 +1,11 @@
import 'katex/dist/katex.min.css' import 'katex/dist/katex.min.css'
import { Message } from '@renderer/types' import { Message } from '@renderer/types'
import { convertMathFormula } from '@renderer/utils' import { escapeBrackets } from '@renderer/utils/formula'
import { isEmpty } from 'lodash' import { isEmpty } from 'lodash'
import { FC, useMemo } from 'react' import { FC, useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import ReactMarkdown from 'react-markdown' import ReactMarkdown, { Components } from 'react-markdown'
import rehypeKatex from 'rehype-katex' import rehypeKatex from 'rehype-katex'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math' import remarkMath from 'remark-math'
@@ -17,6 +17,14 @@ interface Props {
message: Message message: Message
} }
const rehypePlugins = [rehypeKatex]
const remarkPlugins = [remarkMath, remarkGfm]
const components = {
code: CodeBlock,
a: Link
}
const Markdown: FC<Props> = ({ message }) => { const Markdown: FC<Props> = ({ message }) => {
const { t } = useTranslation() const { t } = useTranslation()
@@ -24,25 +32,23 @@ const Markdown: FC<Props> = ({ message }) => {
const empty = isEmpty(message.content) const empty = isEmpty(message.content)
const paused = message.status === 'paused' const paused = message.status === 'paused'
const content = empty && paused ? t('message.chat.completion.paused') : message.content const content = empty && paused ? t('message.chat.completion.paused') : message.content
return convertMathFormula(content) return escapeBrackets(content)
}, [message.content, message.status, t]) }, [message.content, message.status, t])
return useMemo(() => {
return ( return (
<ReactMarkdown <ReactMarkdown
className="markdown" className="markdown"
rehypePlugins={[rehypeKatex]} rehypePlugins={rehypePlugins}
remarkPlugins={[[remarkMath, { singleDollarTextMath: false }], remarkGfm]} remarkPlugins={remarkPlugins}
components={components as Partial<Components>}
remarkRehypeOptions={{ remarkRehypeOptions={{
footnoteLabel: t('common.footnotes'), footnoteLabel: t('common.footnotes'),
footnoteLabelTagName: 'h4', footnoteLabelTagName: 'h4',
footnoteBackContent: ' ' footnoteBackContent: ' '
}} }}>
components={{ code: CodeBlock as any, a: Link as any }}>
{messageContent} {messageContent}
</ReactMarkdown> </ReactMarkdown>
) )
}, [messageContent, t])
} }
export default Markdown export default Markdown

View File

@@ -1,106 +1,127 @@
import { import { SyncOutlined } from '@ant-design/icons'
CheckOutlined,
DeleteOutlined,
EditOutlined,
MenuOutlined,
QuestionCircleOutlined,
SaveOutlined,
SyncOutlined
} from '@ant-design/icons'
import UserPopup from '@renderer/components/Popups/UserPopup' import UserPopup from '@renderer/components/Popups/UserPopup'
import { FONT_FAMILY } from '@renderer/config/constant' import { FONT_FAMILY } from '@renderer/config/constant'
import { APP_NAME, AppLogo, isLocalAi } from '@renderer/config/env'
import { startMinAppById } from '@renderer/config/minapp' import { startMinAppById } from '@renderer/config/minapp'
import { getModelLogo } from '@renderer/config/provider' import { getModelLogo } from '@renderer/config/provider'
import { useAssistant } from '@renderer/hooks/useAssistant' import { useAssistant } from '@renderer/hooks/useAssistant'
import useAvatar from '@renderer/hooks/useAvatar' import useAvatar from '@renderer/hooks/useAvatar'
import { useModel } from '@renderer/hooks/useModel' import { useModel } from '@renderer/hooks/useModel'
import { useSettings } from '@renderer/hooks/useSettings' import { useSettings } from '@renderer/hooks/useSettings'
import { useRuntime } from '@renderer/hooks/useStore' import { Message } from '@renderer/types'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/event'
import { Message, Model } from '@renderer/types'
import { firstLetter, removeLeadingEmoji } from '@renderer/utils' import { firstLetter, removeLeadingEmoji } from '@renderer/utils'
import { Alert, Avatar, Divider, Dropdown, Popconfirm, Tooltip } from 'antd' import { Alert, Avatar, Divider } from 'antd'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { upperFirst } from 'lodash' import { upperFirst } from 'lodash'
import { FC, memo, useCallback, useMemo, useState } from 'react' import { FC, memo, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
import SelectModelDropdown from '../components/SelectModelDropdown'
import Markdown from '../Markdown/Markdown' import Markdown from '../Markdown/Markdown'
import MessageAttachments from './MessageAttachments' import MessageAttachments from './MessageAttachments'
import MessageMenubar from './MessageMenubar'
import MessgeTokens from './MessageTokens'
interface Props { interface Props {
message: Message message: Message
index?: number index?: number
total?: number total?: number
showMenu?: boolean
onDeleteMessage?: (message: Message) => void onDeleteMessage?: (message: Message) => void
} }
const MessageItem: FC<Props> = ({ message, index, showMenu, onDeleteMessage }) => { const MessageItem: FC<Props> = ({ message, index, onDeleteMessage }) => {
const avatar = useAvatar() const avatar = useAvatar()
const { t } = useTranslation() const { t } = useTranslation()
const { assistant, setModel } = useAssistant(message.assistantId) const { assistant, setModel } = useAssistant(message.assistantId)
const model = useModel(message.modelId) const model = useModel(message.modelId)
const { userName, showMessageDivider, messageFont, fontSize } = useSettings() const { userName, showMessageDivider, messageFont, fontSize } = useSettings()
const { generating } = useRuntime()
const [copied, setCopied] = useState(false)
const isLastMessage = index === 0 const isLastMessage = index === 0
const isUserMessage = message.role === 'user'
const isAssistantMessage = message.role === 'assistant' const isAssistantMessage = message.role === 'assistant'
const canRegenerate = isLastMessage && isAssistantMessage
const showMetadata = Boolean(message.usage) && !generating
const onCopy = useCallback(() => {
navigator.clipboard.writeText(message.content)
window.message.success({ content: t('message.copied'), key: 'copy-message' })
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}, [message.content, t])
const onEdit = useCallback(() => EventEmitter.emit(EVENT_NAMES.EDIT_MESSAGE, message), [message])
const onRegenerate = useCallback(
(model: Model) => {
setModel(model)
setTimeout(() => EventEmitter.emit(EVENT_NAMES.REGENERATE_MESSAGE, model), 100)
},
[setModel]
)
const getUserName = useCallback(() => { const getUserName = useCallback(() => {
if (message.id === 'assistant') return assistant?.name if (isLocalAi && message.role !== 'user') return APP_NAME
if (message.role === 'assistant') return upperFirst(model?.name || model?.id) if (message.role === 'assistant') return upperFirst(model?.name || model?.id)
return userName || t('common.you') return userName || t('common.you')
}, [assistant?.name, message.id, message.role, model?.id, model?.name, t, userName]) }, [message.role, model?.id, model?.name, t, userName])
const fontFamily = useMemo(() => { const fontFamily = useMemo(() => {
return messageFont === 'serif' ? FONT_FAMILY.replace('sans-serif', 'serif').replace('Ubuntu, ', '') : FONT_FAMILY return messageFont === 'serif' ? FONT_FAMILY.replace('sans-serif', 'serif').replace('Ubuntu, ', '') : FONT_FAMILY
}, [messageFont]) }, [messageFont])
const messageBorder = showMessageDivider ? undefined : 'none' const messageBorder = showMessageDivider ? undefined : 'none'
const avatarSource = useMemo(() => (message.modelId ? getModelLogo(message.modelId) : undefined), [message.modelId])
const avatarSource = useMemo(() => {
if (isLocalAi) return AppLogo
return message.modelId ? getModelLogo(message.modelId) : undefined
}, [message.modelId])
const avatarName = useMemo(() => firstLetter(assistant?.name).toUpperCase(), [assistant?.name]) const avatarName = useMemo(() => firstLetter(assistant?.name).toUpperCase(), [assistant?.name])
const username = useMemo(() => removeLeadingEmoji(getUserName()), [getUserName]) const username = useMemo(() => removeLeadingEmoji(getUserName()), [getUserName])
const dropdownItems = useMemo( const showMiniApp = () => model?.provider && startMinAppById(model?.provider)
() => [
{ if (message.type === 'clear') {
label: t('chat.save'), return (
key: 'save', <Divider dashed style={{ padding: '0 20px' }} plain>
icon: <SaveOutlined />, {t('chat.message.new.context')}
onClick: () => { </Divider>
const fileName = message.createdAt + '.md' )
window.api.saveFile(fileName, message.content) }
}
} return (
], <MessageContainer key={message.id} className="message">
[t, message] <MessageHeader>
) <AvatarWrapper>
{isAssistantMessage ? (
<Avatar
src={avatarSource}
size={35}
style={{
borderRadius: '20%',
cursor: 'pointer',
border: '1px solid var(--color-border)'
}}
onClick={showMiniApp}>
{avatarName}
</Avatar>
) : (
<Avatar
src={avatar}
size={35}
style={{ borderRadius: '20%', cursor: 'pointer' }}
onClick={() => UserPopup.show()}
/>
)}
<UserWrap>
<UserName>{username}</UserName>
<MessageTime>{dayjs(message.createdAt).format('MM/DD HH:mm')}</MessageTime>
</UserWrap>
</AvatarWrapper>
</MessageHeader>
<MessageContentContainer style={{ fontFamily, fontSize }}>
<MessageContent message={message} />
<MessageFooter style={{ border: messageBorder, flexDirection: isLastMessage ? 'row-reverse' : undefined }}>
<MessgeTokens message={message} />
<MessageMenubar
message={message}
model={model}
index={index}
isLastMessage={isLastMessage}
isAssistantMessage={isAssistantMessage}
setModel={setModel}
onDeleteMessage={onDeleteMessage}
/>
</MessageFooter>
</MessageContentContainer>
</MessageContainer>
)
}
const MessageContent: React.FC<{ message: Message }> = ({ message }) => {
const { t } = useTranslation()
const MessageItem = useCallback(() => {
if (message.status === 'sending') { if (message.status === 'sending') {
return ( return (
<MessageContentLoading> <MessageContentLoading>
@@ -126,101 +147,6 @@ const MessageItem: FC<Props> = ({ message, index, showMenu, onDeleteMessage }) =
<MessageAttachments message={message} /> <MessageAttachments message={message} />
</> </>
) )
}, [message, t])
const showMiniApp = () => model?.provider && startMinAppById(model?.provider)
if (message.type === 'clear') {
return (
<Divider dashed style={{ padding: '0 20px' }} plain>
{t('chat.message.new.context')}
</Divider>
)
}
return (
<MessageContainer key={message.id} className="message">
<MessageHeader>
<AvatarWrapper>
{isAssistantMessage ? (
<Avatar
src={avatarSource}
size={35}
style={{ borderRadius: '20%', cursor: 'pointer' }}
onClick={showMiniApp}>
{avatarName}
</Avatar>
) : (
<Avatar
src={avatar}
size={35}
style={{ borderRadius: '20%', cursor: 'pointer' }}
onClick={() => UserPopup.show()}
/>
)}
<UserWrap>
<UserName>{username}</UserName>
<MessageTime>{dayjs(message.createdAt).format('MM/DD HH:mm')}</MessageTime>
</UserWrap>
</AvatarWrapper>
</MessageHeader>
<MessageContent style={{ fontFamily, fontSize }}>
<MessageItem />
<MessageFooter style={{ border: messageBorder }}>
{showMenu && (
<MenusBar className={`menubar ${isLastMessage && 'show'} ${(!isLastMessage || isUserMessage) && 'user'}`}>
{message.role === 'user' && (
<Tooltip title="Edit" mouseEnterDelay={0.8}>
<ActionButton onClick={onEdit}>
<EditOutlined />
</ActionButton>
</Tooltip>
)}
<Tooltip title={t('common.copy')} mouseEnterDelay={0.8}>
<ActionButton onClick={onCopy}>
{!copied && <i className="iconfont icon-copy"></i>}
{copied && <CheckOutlined style={{ color: 'var(--color-primary)' }} />}
</ActionButton>
</Tooltip>
{canRegenerate && (
<SelectModelDropdown model={model} onSelect={onRegenerate} placement="topLeft">
<Tooltip title={t('common.regenerate')} mouseEnterDelay={0.8}>
<ActionButton>
<SyncOutlined />
</ActionButton>
</Tooltip>
</SelectModelDropdown>
)}
<Popconfirm
title={t('message.message.delete.content')}
okButtonProps={{ danger: true }}
icon={<QuestionCircleOutlined style={{ color: 'red' }} />}
onConfirm={() => onDeleteMessage?.(message)}>
<Tooltip title={t('common.delete')} mouseEnterDelay={1}>
<ActionButton>
<DeleteOutlined />
</ActionButton>
</Tooltip>
</Popconfirm>
{!isUserMessage && (
<Dropdown menu={{ items: dropdownItems }} trigger={['click']} placement="topRight" arrow>
<ActionButton>
<MenuOutlined />
</ActionButton>
</Dropdown>
)}
</MenusBar>
)}
{showMetadata && (
<MessageMetadata>
Tokens: {message?.usage?.total_tokens} | {message?.usage?.prompt_tokens} |
{message?.usage?.completion_tokens}
</MessageMetadata>
)}
</MessageFooter>
</MessageContent>
</MessageContainer>
)
} }
const MessageContainer = styled.div` const MessageContainer = styled.div`
@@ -234,11 +160,6 @@ const MessageContainer = styled.div`
&.show { &.show {
opacity: 1; opacity: 1;
} }
&.user {
position: absolute;
top: 10px;
right: 15px;
}
} }
&:hover { &:hover {
.menubar { .menubar {
@@ -279,7 +200,7 @@ const MessageTime = styled.div`
color: var(--color-text-3); color: var(--color-text-3);
` `
const MessageContent = styled.div` const MessageContentContainer = styled.div`
display: flex; display: flex;
flex: 1; flex: 1;
flex-direction: column; flex-direction: column;
@@ -305,47 +226,4 @@ const MessageContentLoading = styled.div`
height: 32px; height: 32px;
` `
const MenusBar = styled.div`
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
gap: 6px;
margin-left: -5px;
`
const MessageMetadata = styled.div`
font-size: 12px;
color: var(--color-text-2);
user-select: text;
margin: 2px 0;
`
const ActionButton = styled.div`
cursor: pointer;
border-radius: 8px;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
width: 30px;
height: 30px;
transition: all 0.3s ease;
&:hover {
background-color: var(--color-background-mute);
.anticon {
color: var(--color-text-1);
}
}
.anticon,
.iconfont {
cursor: pointer;
font-size: 14px;
color: var(--color-icon);
}
&:hover {
color: var(--color-text-1);
}
`
export default memo(MessageItem) export default memo(MessageItem)

View File

@@ -1,5 +1,6 @@
import { Message } from '@renderer/types' import FileManager from '@renderer/services/file'
import { Image as AntdImage } from 'antd' import { FileTypes, Message } from '@renderer/types'
import { Image as AntdImage, Upload } from 'antd'
import { FC } from 'react' import { FC } from 'react'
import styled from 'styled-components' import styled from 'styled-components'
@@ -8,7 +9,32 @@ interface Props {
} }
const MessageAttachments: FC<Props> = ({ message }) => { const MessageAttachments: FC<Props> = ({ message }) => {
return <Container>{message.images?.map((image) => <Image src={image} key={image} width="33%" />)}</Container> if (!message.files) {
return null
}
if (message?.files && message.files[0]?.type === FileTypes.IMAGE) {
return (
<Container>
{message.files?.map((image) => <Image src={'file://' + image.path} key={image.id} width="33%" />)}
</Container>
)
}
return (
<Container style={{ marginTop: 2, marginBottom: 8 }}>
<Upload
listType="picture"
disabled
fileList={message.files?.map((file) => ({
uid: file.id,
url: 'file://' + FileManager.getSafePath(file),
status: 'done',
name: file.origin_name
}))}
/>
</Container>
)
} }
const Container = styled.div` const Container = styled.div`

View File

@@ -0,0 +1,164 @@
import {
CheckOutlined,
DeleteOutlined,
EditOutlined,
ForkOutlined,
MenuOutlined,
QuestionCircleOutlined,
SaveOutlined,
SyncOutlined
} from '@ant-design/icons'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/event'
import { Message, Model } from '@renderer/types'
import { removeTrailingDoubleSpaces } from '@renderer/utils'
import { Dropdown, Popconfirm, Tooltip } from 'antd'
import { FC, useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import SelectModelDropdown from '../components/SelectModelDropdown'
interface Props {
message: Message
model?: Model
index?: number
isLastMessage: boolean
isAssistantMessage: boolean
setModel: (model: Model) => void
onDeleteMessage?: (message: Message) => void
}
const MessageMenubar: FC<Props> = (props) => {
const { message, index, model, isLastMessage, isAssistantMessage, setModel, onDeleteMessage } = props
const { t } = useTranslation()
const [copied, setCopied] = useState(false)
const isUserMessage = message.role === 'user'
const canRegenerate = isLastMessage && isAssistantMessage
const onEdit = useCallback(() => EventEmitter.emit(EVENT_NAMES.EDIT_MESSAGE, message), [message])
const onCopy = useCallback(() => {
navigator.clipboard.writeText(removeTrailingDoubleSpaces(message.content))
window.message.success({ content: t('message.copied'), key: 'copy-message' })
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}, [message.content, t])
const onRegenerate = useCallback(
(model: Model) => {
setModel(model)
setTimeout(() => EventEmitter.emit(EVENT_NAMES.REGENERATE_MESSAGE, model), 100)
},
[setModel]
)
const onNewBranch = useCallback(() => {
EventEmitter.emit(EVENT_NAMES.NEW_BRANCH, index)
}, [index])
const dropdownItems = useMemo(
() => [
{
label: t('chat.save'),
key: 'save',
icon: <SaveOutlined />,
onClick: () => {
const fileName = message.createdAt + '.md'
window.api.file.save(fileName, message.content)
}
}
],
[t, message]
)
return (
<MenusBar className={`menubar ${isLastMessage && 'show'}`}>
{message.role === 'user' && (
<Tooltip title="Edit" mouseEnterDelay={0.8}>
<ActionButton onClick={onEdit}>
<EditOutlined />
</ActionButton>
</Tooltip>
)}
<Tooltip title={t('common.copy')} mouseEnterDelay={0.8}>
<ActionButton onClick={onCopy}>
{!copied && <i className="iconfont icon-copy"></i>}
{copied && <CheckOutlined style={{ color: 'var(--color-primary)' }} />}
</ActionButton>
</Tooltip>
{canRegenerate && (
<SelectModelDropdown model={model} onSelect={onRegenerate} placement="topLeft">
<Tooltip title={t('common.regenerate')} mouseEnterDelay={0.8}>
<ActionButton>
<SyncOutlined />
</ActionButton>
</Tooltip>
</SelectModelDropdown>
)}
{isAssistantMessage && (
<Tooltip title={t('chat.message.new.branch')} mouseEnterDelay={0.8}>
<ActionButton onClick={onNewBranch}>
<ForkOutlined />
</ActionButton>
</Tooltip>
)}
<Popconfirm
title={t('message.message.delete.content')}
okButtonProps={{ danger: true }}
icon={<QuestionCircleOutlined style={{ color: 'red' }} />}
onConfirm={() => onDeleteMessage?.(message)}>
<Tooltip title={t('common.delete')} mouseEnterDelay={1}>
<ActionButton>
<DeleteOutlined />
</ActionButton>
</Tooltip>
</Popconfirm>
{!isUserMessage && (
<Dropdown menu={{ items: dropdownItems }} trigger={['click']} placement="topRight" arrow>
<ActionButton>
<MenuOutlined />
</ActionButton>
</Dropdown>
)}
</MenusBar>
)
}
const MenusBar = styled.div`
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
gap: 6px;
margin-left: -5px;
`
const ActionButton = styled.div`
cursor: pointer;
border-radius: 8px;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
width: 30px;
height: 30px;
transition: all 0.2s ease;
&:hover {
background-color: var(--color-background-mute);
.anticon {
color: var(--color-text-1);
}
}
.anticon,
.iconfont {
cursor: pointer;
font-size: 14px;
color: var(--color-icon);
}
&:hover {
color: var(--color-text-1);
}
`
export default MessageMenubar

View File

@@ -0,0 +1,38 @@
import { useRuntime } from '@renderer/hooks/useStore'
import { Message } from '@renderer/types'
import styled from 'styled-components'
const MessgeTokens: React.FC<{ message: Message }> = ({ message }) => {
const { generating } = useRuntime()
if (!message.usage) {
return null
}
if (message.role === 'user') {
return <MessageMetadata>Tokens: {message?.usage?.total_tokens}</MessageMetadata>
}
if (generating) {
return null
}
if (message.role === 'assistant') {
return (
<MessageMetadata>
Tokens: {message?.usage?.total_tokens} | {message?.usage?.prompt_tokens} | {message?.usage?.completion_tokens}
</MessageMetadata>
)
}
return null
}
const MessageMetadata = styled.div`
font-size: 12px;
color: var(--color-text-2);
user-select: text;
margin: 2px 0;
`
export default MessgeTokens

View File

@@ -1,15 +1,15 @@
import db from '@renderer/databases'
import { useAssistant } from '@renderer/hooks/useAssistant' import { useAssistant } from '@renderer/hooks/useAssistant'
import { useProviderByAssistant } from '@renderer/hooks/useProvider' import { getTopic, TopicManager } from '@renderer/hooks/useTopic'
import { getTopic } from '@renderer/hooks/useTopic'
import { fetchChatCompletion, fetchMessagesSummary } from '@renderer/services/api' import { fetchChatCompletion, fetchMessagesSummary } from '@renderer/services/api'
import { getDefaultTopic } from '@renderer/services/assistant'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/event' import { EVENT_NAMES, EventEmitter } from '@renderer/services/event'
import { estimateHistoryTokenCount, filterMessages, getContextCount } from '@renderer/services/messages' import { deleteMessageFiles, filterMessages, getContextCount } from '@renderer/services/messages'
import LocalStorage from '@renderer/services/storage' import { estimateHistoryTokens, estimateMessageUsage } from '@renderer/services/tokens'
import { Assistant, Message, Model, Topic } from '@renderer/types' import { Assistant, Message, Model, Topic } from '@renderer/types'
import { getBriefInfo, runAsyncFunction, uuid } from '@renderer/utils' import { captureScrollableDiv, getBriefInfo, runAsyncFunction, uuid } from '@renderer/utils'
import { t } from 'i18next' import { t } from 'i18next'
import localforage from 'localforage' import { flatten, last, reverse, take } from 'lodash'
import { last, reverse } from 'lodash'
import { FC, useCallback, useEffect, useRef, useState } from 'react' import { FC, useCallback, useEffect, useRef, useState } from 'react'
import styled from 'styled-components' import styled from 'styled-components'
@@ -26,17 +26,25 @@ interface Props {
const Messages: FC<Props> = ({ assistant, topic, setActiveTopic }) => { const Messages: FC<Props> = ({ assistant, topic, setActiveTopic }) => {
const [messages, setMessages] = useState<Message[]>([]) const [messages, setMessages] = useState<Message[]>([])
const [lastMessage, setLastMessage] = useState<Message | null>(null) const [lastMessage, setLastMessage] = useState<Message | null>(null)
const provider = useProviderByAssistant(assistant)
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const { updateTopic } = useAssistant(assistant.id) const { updateTopic, addTopic } = useAssistant(assistant.id)
const onSendMessage = useCallback( const onSendMessage = useCallback(
(message: Message) => { async (message: Message) => {
if (message.role === 'user') {
estimateMessageUsage(message).then((usage) => {
setMessages((prev) => {
const _messages = prev.map((m) => (m.id === message.id ? { ...m, usage } : m))
db.topics.update(topic.id, { messages: _messages })
return _messages
})
})
}
const _messages = [...messages, message] const _messages = [...messages, message]
setMessages(_messages) setMessages(_messages)
localforage.setItem(`topic:${topic.id}`, { id: topic.id, messages: _messages }) db.topics.put({ id: topic.id, messages: _messages })
}, },
[messages, topic] [messages, topic.id]
) )
const autoRenameTopic = useCallback(async () => { const autoRenameTopic = useCallback(async () => {
@@ -55,7 +63,8 @@ const Messages: FC<Props> = ({ assistant, topic, setActiveTopic }) => {
(message: Message) => { (message: Message) => {
const _messages = messages.filter((m) => m.id !== message.id) const _messages = messages.filter((m) => m.id !== message.id)
setMessages(_messages) setMessages(_messages)
localforage.setItem(`topic:${topic.id}`, { id: topic.id, messages: _messages }) db.topics.update(topic.id, { messages: _messages })
deleteMessageFiles(message)
}, },
[messages, topic.id] [messages, topic.id]
) )
@@ -63,10 +72,15 @@ const Messages: FC<Props> = ({ assistant, topic, setActiveTopic }) => {
useEffect(() => { useEffect(() => {
const unsubscribes = [ const unsubscribes = [
EventEmitter.on(EVENT_NAMES.SEND_MESSAGE, async (msg: Message) => { EventEmitter.on(EVENT_NAMES.SEND_MESSAGE, async (msg: Message) => {
onSendMessage(msg) await onSendMessage(msg)
fetchChatCompletion({ assistant, messages: [...messages, msg], topic, onResponse: setLastMessage }) fetchChatCompletion({
assistant,
messages: [...messages, msg],
topic,
onResponse: setLastMessage
})
}), }),
EventEmitter.on(EVENT_NAMES.AI_CHAT_COMPLETION, async (msg: Message) => { EventEmitter.on(EVENT_NAMES.RECEIVE_MESSAGE, async (msg: Message) => {
setLastMessage(null) setLastMessage(null)
onSendMessage(msg) onSendMessage(msg)
setTimeout(() => EventEmitter.emit(EVENT_NAMES.AI_AUTO_RENAME), 100) setTimeout(() => EventEmitter.emit(EVENT_NAMES.AI_AUTO_RENAME), 100)
@@ -88,12 +102,19 @@ const Messages: FC<Props> = ({ assistant, topic, setActiveTopic }) => {
EventEmitter.on(EVENT_NAMES.CLEAR_MESSAGES, () => { EventEmitter.on(EVENT_NAMES.CLEAR_MESSAGES, () => {
setMessages([]) setMessages([])
updateTopic({ ...topic, messages: [] }) updateTopic({ ...topic, messages: [] })
LocalStorage.clearTopicMessages(topic.id) TopicManager.clearTopicMessages(topic.id)
}),
EventEmitter.on(EVENT_NAMES.EXPORT_TOPIC_IMAGE, async () => {
const imageData = await captureScrollableDiv(containerRef)
if (imageData) {
window.api.file.saveImage(topic.name, imageData)
}
}), }),
EventEmitter.on(EVENT_NAMES.NEW_CONTEXT, () => { EventEmitter.on(EVENT_NAMES.NEW_CONTEXT, () => {
const lastMessage = last(messages) const lastMessage = last(messages)
if (lastMessage && lastMessage.type === 'clear') { if (lastMessage && lastMessage.type === 'clear') {
onDeleteMessage(lastMessage)
return return
} }
@@ -111,14 +132,43 @@ const Messages: FC<Props> = ({ assistant, topic, setActiveTopic }) => {
status: 'success', status: 'success',
type: 'clear' type: 'clear'
} as Message) } as Message)
}),
EventEmitter.on(EVENT_NAMES.NEW_BRANCH, async (index: number) => {
const newTopic = getDefaultTopic()
newTopic.name = topic.name
const branchMessages = take(messages, messages.length - index)
// 将分支的消息放入数据库
await db.topics.add({ id: newTopic.id, messages: branchMessages })
addTopic(newTopic)
setActiveTopic(newTopic)
autoRenameTopic()
// 由于复制了消息,消息中附带的文件的总数变了,需要更新
const filesArr = branchMessages.map((m) => m.files)
const files = flatten(filesArr).filter(Boolean)
files.map(async (f) => {
const file = await db.files.get({ id: f?.id })
file && db.files.update(file.id, { count: file.count + 1 })
})
}) })
] ]
return () => unsubscribes.forEach((unsub) => unsub()) return () => unsubscribes.forEach((unsub) => unsub())
}, [assistant, messages, provider, topic, autoRenameTopic, updateTopic, onSendMessage]) }, [
addTopic,
assistant,
autoRenameTopic,
messages,
onDeleteMessage,
onSendMessage,
setActiveTopic,
topic,
updateTopic
])
useEffect(() => { useEffect(() => {
runAsyncFunction(async () => { runAsyncFunction(async () => {
const messages = (await LocalStorage.getTopicMessages(topic.id)) || [] const messages = (await TopicManager.getTopicMessages(topic.id)) || []
setMessages(messages) setMessages(messages)
}) })
}, [topic.id]) }, [topic.id])
@@ -128,18 +178,20 @@ const Messages: FC<Props> = ({ assistant, topic, setActiveTopic }) => {
}, [messages]) }, [messages])
useEffect(() => { useEffect(() => {
runAsyncFunction(async () => {
EventEmitter.emit(EVENT_NAMES.ESTIMATED_TOKEN_COUNT, { EventEmitter.emit(EVENT_NAMES.ESTIMATED_TOKEN_COUNT, {
tokensCount: estimateHistoryTokenCount(assistant, messages), tokensCount: await estimateHistoryTokens(assistant, messages),
contextCount: getContextCount(assistant, messages) contextCount: getContextCount(assistant, messages)
}) })
})
}, [assistant, messages]) }, [assistant, messages])
return ( return (
<Container id="messages" key={assistant.id} ref={containerRef}> <Container id="messages" key={assistant.id} ref={containerRef}>
<Suggestions assistant={assistant} messages={messages} lastMessage={lastMessage} /> <Suggestions assistant={assistant} messages={messages} lastMessage={lastMessage} />
{lastMessage && <MessageItem message={lastMessage} />} {lastMessage && <MessageItem key={lastMessage.id} message={lastMessage} />}
{reverse([...messages]).map((message, index) => ( {reverse([...messages]).map((message, index) => (
<MessageItem key={message.id} message={message} showMenu index={index} onDeleteMessage={onDeleteMessage} /> <MessageItem key={message.id} message={message} index={index} onDeleteMessage={onDeleteMessage} />
))} ))}
<Prompt assistant={assistant} key={assistant.prompt} /> <Prompt assistant={assistant} key={assistant.prompt} />
</Container> </Container>
@@ -154,6 +206,7 @@ const Container = styled.div`
flex-direction: column-reverse; flex-direction: column-reverse;
max-height: calc(100vh - var(--input-bar-height) - var(--navbar-height)); max-height: calc(100vh - var(--input-bar-height) - var(--navbar-height));
padding: 10px 0; padding: 10px 0;
background-color: var(--color-background);
` `
export default Messages export default Messages

View File

@@ -1,15 +1,17 @@
import { FormOutlined } from '@ant-design/icons' import { FormOutlined } from '@ant-design/icons'
import { Navbar, NavbarCenter, NavbarLeft, NavbarRight } from '@renderer/components/app/Navbar' import { Navbar, NavbarLeft, NavbarRight } from '@renderer/components/app/Navbar'
import { HStack } from '@renderer/components/Layout' import { HStack } from '@renderer/components/Layout'
import AddAssistantPopup from '@renderer/components/Popups/AddAssistantPopup'
import AssistantSettingPopup from '@renderer/components/Popups/AssistantSettingPopup' import AssistantSettingPopup from '@renderer/components/Popups/AssistantSettingPopup'
import { isMac, isWindows } from '@renderer/config/constant' import { isMac, isWindows } from '@renderer/config/constant'
import { useTheme } from '@renderer/context/ThemeProvider' import { useTheme } from '@renderer/context/ThemeProvider'
import db from '@renderer/databases'
import { useAssistant } from '@renderer/hooks/useAssistant' import { useAssistant } from '@renderer/hooks/useAssistant'
import { useSettings } from '@renderer/hooks/useSettings' import { useSettings } from '@renderer/hooks/useSettings'
import { useShowAssistants, useShowTopics } from '@renderer/hooks/useStore' import { useShowAssistants, useShowTopics } from '@renderer/hooks/useStore'
import { getDefaultTopic } from '@renderer/services/assistant' import { getDefaultTopic, syncAsistantToAgent } from '@renderer/services/assistant'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/event'
import { Assistant, Topic } from '@renderer/types' import { Assistant, Topic } from '@renderer/types'
import { Switch } from 'antd'
import { FC, useCallback } from 'react' import { FC, useCallback } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
@@ -19,28 +21,31 @@ import SelectModelButton from './components/SelectModelButton'
interface Props { interface Props {
activeAssistant: Assistant activeAssistant: Assistant
activeTopic: Topic activeTopic: Topic
setActiveAssistant: (assistant: Assistant) => void
setActiveTopic: (topic: Topic) => void setActiveTopic: (topic: Topic) => void
} }
const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant, setActiveTopic }) => { const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveTopic }) => {
const { assistant, addTopic } = useAssistant(activeAssistant.id) const { assistant, updateAssistant, addTopic } = useAssistant(activeAssistant.id)
const { t } = useTranslation()
const { showAssistants, toggleShowAssistants } = useShowAssistants() const { showAssistants, toggleShowAssistants } = useShowAssistants()
const { showTopics, toggleShowTopics } = useShowTopics()
const { theme, toggleTheme } = useTheme() const { theme, toggleTheme } = useTheme()
const { topicPosition } = useSettings() const { topicPosition } = useSettings()
const { showTopics, toggleShowTopics } = useShowTopics()
const { t } = useTranslation()
const onCreateAssistant = async () => { const onEditAssistant = useCallback(async () => {
const assistant = await AddAssistantPopup.show() const _assistant = await AssistantSettingPopup.show({ assistant })
assistant && setActiveAssistant(assistant) updateAssistant(_assistant)
} syncAsistantToAgent(_assistant)
}, [assistant, updateAssistant])
const addNewTopic = useCallback(() => { const addNewTopic = useCallback(() => {
const topic = getDefaultTopic() const topic = getDefaultTopic()
addTopic(topic) addTopic(topic)
setActiveTopic(topic) setActiveTopic(topic)
}, [addTopic, setActiveTopic]) db.topics.add({ id: topic.id, messages: [] })
window.message.success({ content: t('message.topic.added'), key: 'topic-added' })
setTimeout(() => EventEmitter.emit(EVENT_NAMES.SHOW_TOPIC_SIDEBAR), 0)
}, [addTopic, setActiveTopic, t])
return ( return (
<Navbar> <Navbar>
@@ -49,61 +54,32 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant, setActiv
<NewButton onClick={toggleShowAssistants} style={{ marginLeft: isMac ? 8 : 0 }}> <NewButton onClick={toggleShowAssistants} style={{ marginLeft: isMac ? 8 : 0 }}>
<i className="iconfont icon-hide-sidebar" /> <i className="iconfont icon-hide-sidebar" />
</NewButton> </NewButton>
<NewButton onClick={onCreateAssistant}>
<i className="iconfont icon-a-addchat" />
</NewButton>
</NavbarLeft>
)}
{showTopics && topicPosition === 'left' && (
<NavbarCenter
style={{
paddingLeft: isMac && !showAssistants ? 16 : 8,
paddingRight: 8,
maxWidth: 'var(--topic-list-width)',
justifyContent: 'space-between'
}}>
<HStack alignItems="center">
{!showAssistants && (
<NewButton onClick={toggleShowAssistants} style={{ marginRight: isMac ? 8 : 25 }}>
<i className="iconfont icon-show-sidebar" />
</NewButton>
)}
{showAssistants && (
<TitleText>
{t('chat.topics.title')} ({assistant.topics.length})
</TitleText>
)}
</HStack>
<NewButton onClick={addNewTopic}> <NewButton onClick={addNewTopic}>
<FormOutlined /> <FormOutlined />
</NewButton> </NewButton>
</NavbarCenter> </NavbarLeft>
)} )}
<NavbarRight style={{ justifyContent: 'space-between', paddingRight: isWindows ? 140 : 12, flex: 1 }}> <NavbarRight style={{ justifyContent: 'space-between', paddingRight: isWindows ? 140 : 12, flex: 1 }}>
<HStack alignItems="center"> <HStack alignItems="center">
{!showAssistants && (topicPosition === 'left' ? !showTopics : true) && ( {!showAssistants && (
<NewButton <NewButton
onClick={() => toggleShowAssistants()} onClick={() => toggleShowAssistants()}
style={{ marginRight: isMac ? 8 : 25, marginLeft: isMac ? 4 : 0 }}> style={{ marginRight: isMac ? 8 : 25, marginLeft: isMac ? 4 : 0 }}>
<i className="iconfont icon-show-sidebar" /> <i className="iconfont icon-show-sidebar" />
</NewButton> </NewButton>
)} )}
<TitleText <TitleText style={{ marginRight: 10, cursor: 'pointer' }} className="nodrag" onClick={onEditAssistant}>
style={{ marginRight: 10, cursor: 'pointer' }}
className="nodrag"
onClick={() => AssistantSettingPopup.show({ assistant })}>
{assistant.name} {assistant.name}
</TitleText> </TitleText>
<SelectModelButton assistant={assistant} /> <SelectModelButton assistant={assistant} />
</HStack> </HStack>
<HStack alignItems="center"> <HStack alignItems="center">
<NewButton onClick={toggleTheme} style={{ marginRight: 3 }}> <ThemeSwitch
{theme === 'dark' ? ( checkedChildren={<i className="iconfont icon-theme icon-dark1" />}
<i className="iconfont icon-theme icon-theme-light" /> unCheckedChildren={<i className="iconfont icon-theme icon-theme-light" />}
) : ( checked={theme === 'dark'}
<i className="iconfont icon-a-darkmode" /> onChange={toggleTheme}
)} />
</NewButton>
{topicPosition === 'right' && ( {topicPosition === 'right' && (
<NewButton onClick={toggleShowTopics}> <NewButton onClick={toggleShowTopics}>
<i className={`iconfont icon-${showTopics ? 'show' : 'hide'}-sidebar`} /> <i className={`iconfont icon-${showTopics ? 'show' : 'hide'}-sidebar`} />
@@ -117,7 +93,7 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant, setActiv
export const NewButton = styled.div` export const NewButton = styled.div`
-webkit-app-region: none; -webkit-app-region: none;
border-radius: 4px; border-radius: 8px;
height: 30px; height: 30px;
padding: 0 7px; padding: 0 7px;
display: flex; display: flex;
@@ -153,4 +129,12 @@ const TitleText = styled.span`
font-weight: 500; font-weight: 500;
` `
const ThemeSwitch = styled(Switch)`
-webkit-app-region: no-drag;
margin-right: 10px;
.icon-theme {
font-size: 14px;
}
`
export default HeaderNavbar export default HeaderNavbar

View File

@@ -1,74 +1,136 @@
import { BarsOutlined, SettingOutlined } from '@ant-design/icons' import { BarsOutlined, SettingOutlined } from '@ant-design/icons'
import AddAssistantPopup from '@renderer/components/Popups/AddAssistantPopup'
import { useAssistants, useDefaultAssistant } from '@renderer/hooks/useAssistant'
import { useSettings } from '@renderer/hooks/useSettings'
import { useShowTopics } from '@renderer/hooks/useStore' import { useShowTopics } from '@renderer/hooks/useStore'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/event' import { EVENT_NAMES, EventEmitter } from '@renderer/services/event'
import { Assistant, Topic } from '@renderer/types' import { Assistant, Topic } from '@renderer/types'
import { Segmented } from 'antd' import { uuid } from '@renderer/utils'
import { Segmented, SegmentedProps } from 'antd'
import { FC, useEffect, useState } from 'react' import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
import Assistants from './Assistants'
import Settings from './Settings' import Settings from './Settings'
import Topics from './Topics' import Topics from './Topics'
interface Props { interface Props {
assistant: Assistant activeAssistant: Assistant
activeTopic: Topic activeTopic: Topic
setActiveAssistant: (assistant: Assistant) => void
setActiveTopic: (topic: Topic) => void setActiveTopic: (topic: Topic) => void
position: 'left' | 'right'
} }
const RightSidebar: FC<Props> = (props) => { type Tab = 'assistants' | 'topic' | 'settings'
const [tab, setTab] = useState<'topic' | 'settings'>('topic')
const { showTopics, setShowTopics } = useShowTopics() let _tab: any = ''
const RightSidebar: FC<Props> = ({ activeAssistant, activeTopic, setActiveAssistant, setActiveTopic, position }) => {
const { addAssistant } = useAssistants()
const [tab, setTab] = useState<Tab>(position === 'left' ? _tab || 'assistants' : 'topic')
const { topicPosition } = useSettings()
const { defaultAssistant } = useDefaultAssistant()
const { toggleShowTopics } = useShowTopics()
const { t } = useTranslation() const { t } = useTranslation()
const isTopicTab = tab === 'topic'
const isSettingsTab = tab === 'settings' const borderStyle = '0.5px solid var(--color-border)'
const border = position === 'left' ? { borderRight: borderStyle } : { borderLeft: borderStyle }
if (position === 'left' && topicPosition === 'left') {
_tab = tab
}
const showTab = !(position === 'left' && topicPosition === 'right')
const assistantTab = {
label: t('assistants.abbr'),
value: 'assistants',
icon: <i className="iconfont icon-business-smart-assistant" />
}
const onCreateAssistant = async () => {
const assistant = await AddAssistantPopup.show()
assistant && setActiveAssistant(assistant)
}
const onCreateDefaultAssistant = () => {
const assistant = { ...defaultAssistant, id: uuid() }
addAssistant(assistant)
setActiveAssistant(assistant)
}
useEffect(() => { useEffect(() => {
const unsubscribes = [ const unsubscribes = [
EventEmitter.on(EVENT_NAMES.SHOW_ASSISTANTS, (): any => {
showTab && setTab('assistants')
}),
EventEmitter.on(EVENT_NAMES.SHOW_TOPIC_SIDEBAR, (): any => { EventEmitter.on(EVENT_NAMES.SHOW_TOPIC_SIDEBAR, (): any => {
if (showTopics && isTopicTab) { showTab && setTab('topic')
return setShowTopics(false)
}
if (showTopics) {
return setTab('topic')
}
setShowTopics(true)
setTab('topic')
}), }),
EventEmitter.on(EVENT_NAMES.SHOW_CHAT_SETTINGS, (): any => { EventEmitter.on(EVENT_NAMES.SHOW_CHAT_SETTINGS, (): any => {
if (showTopics && isSettingsTab) { showTab && setTab('settings')
return setShowTopics(false)
}
if (showTopics) {
return setTab('settings')
}
setShowTopics(true)
setTab('settings')
}), }),
EventEmitter.on(EVENT_NAMES.SWITCH_TOPIC_SIDEBAR, () => setTab('topic')) EventEmitter.on(EVENT_NAMES.SWITCH_TOPIC_SIDEBAR, () => {
showTab && setTab('topic')
if (position === 'left' && topicPosition === 'right') {
toggleShowTopics()
}
})
] ]
return () => unsubscribes.forEach((unsub) => unsub()) return () => unsubscribes.forEach((unsub) => unsub())
}, [isSettingsTab, isTopicTab, showTopics, setShowTopics]) }, [position, showTab, tab, toggleShowTopics, topicPosition])
if (!showTopics) { useEffect(() => {
return null if (position === 'right' && topicPosition === 'right' && tab === 'assistants') {
setTab('topic')
} }
if (position === 'left' && topicPosition === 'right' && tab !== 'assistants') {
setTab('assistants')
}
}, [position, tab, topicPosition])
return ( return (
<Container> <Container style={border}>
{showTab && (
<Segmented <Segmented
value={tab} value={tab}
style={{ borderRadius: 0, padding: '10px', gap: 5, borderBottom: '0.5px solid var(--color-border)' }} className="segmented-tab"
options={[ style={{ borderRadius: 0, padding: '10px', gap: 2, borderBottom: borderStyle }}
{ label: t('common.topics'), value: 'topic', icon: <BarsOutlined /> }, options={
{ label: t('settings.title'), value: 'settings', icon: <SettingOutlined /> } [
]} position === 'left' && topicPosition === 'left' ? assistantTab : undefined,
block {
label: t('common.topics'),
value: 'topic',
icon: <BarsOutlined />
},
{
label: t('settings.title'),
value: 'settings',
icon: <SettingOutlined />
}
].filter(Boolean) as SegmentedProps['options']
}
onChange={(value) => setTab(value as 'topic' | 'settings')} onChange={(value) => setTab(value as 'topic' | 'settings')}
block
/> />
)}
<TabContent> <TabContent>
{tab === 'topic' && <Topics {...props} />} {tab === 'assistants' && (
{tab === 'settings' && <Settings assistant={props.assistant} />} <Assistants
activeAssistant={activeAssistant}
setActiveAssistant={setActiveAssistant}
onCreateAssistant={onCreateAssistant}
onCreateDefaultAssistant={onCreateDefaultAssistant}
/>
)}
{tab === 'topic' && (
<Topics assistant={activeAssistant} activeTopic={activeTopic} setActiveTopic={setActiveTopic} />
)}
{tab === 'settings' && <Settings assistant={activeAssistant} />}
</TabContent> </TabContent>
</Container> </Container>
) )
@@ -77,9 +139,8 @@ const RightSidebar: FC<Props> = (props) => {
const Container = styled.div` const Container = styled.div`
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: var(--topic-list-width); width: var(--assistants-width);
height: calc(100vh - var(--navbar-height)); height: calc(100vh - var(--navbar-height));
border-left: 0.5px solid var(--color-border);
.collapsed { .collapsed {
width: 0; width: 0;
border-left: none; border-left: none;
@@ -91,6 +152,7 @@ const TabContent = styled.div`
flex: 1; flex: 1;
flex-direction: column; flex-direction: column;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden;
` `
export default RightSidebar export default RightSidebar

View File

@@ -8,6 +8,7 @@ import { useAppDispatch } from '@renderer/store'
import { import {
setFontSize, setFontSize,
setMessageFont, setMessageFont,
setPasteLongTextAsFile,
setShowInputEstimatedTokens, setShowInputEstimatedTokens,
setShowMessageDivider setShowMessageDivider
} from '@renderer/store/settings' } from '@renderer/store/settings'
@@ -33,8 +34,14 @@ const SettingsTab: FC<Props> = (props) => {
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const { showMessageDivider, messageFont, showInputEstimatedTokens, sendMessageShortcut, setSendMessageShortcut } = const {
useSettings() showMessageDivider,
messageFont,
showInputEstimatedTokens,
sendMessageShortcut,
setSendMessageShortcut,
pasteLongTextAsFile
} = useSettings()
const onUpdateAssistantSettings = (settings: Partial<AssistantSettings>) => { const onUpdateAssistantSettings = (settings: Partial<AssistantSettings>) => {
updateAssistantSettings({ updateAssistantSettings({
@@ -104,7 +111,7 @@ const SettingsTab: FC<Props> = (props) => {
<Col span={24}> <Col span={24}>
<Slider <Slider
min={0} min={0}
max={1.2} max={2}
onChange={setTemperature} onChange={setTemperature}
onChangeComplete={onTemperatureChange} onChangeComplete={onTemperatureChange}
value={typeof temperature === 'number' ? temperature : 0} value={typeof temperature === 'number' ? temperature : 0}
@@ -187,10 +194,7 @@ const SettingsTab: FC<Props> = (props) => {
<Slider <Slider
value={fontSizeValue} value={fontSizeValue}
onChange={(value) => setFontSizeValue(value)} onChange={(value) => setFontSizeValue(value)}
onChangeComplete={(value) => { onChangeComplete={(value) => dispatch(setFontSize(value))}
dispatch(setFontSize(value))
console.debug('set font size', value)
}}
min={12} min={12}
max={18} max={18}
step={1} step={1}
@@ -213,6 +217,15 @@ const SettingsTab: FC<Props> = (props) => {
/> />
</SettingRow> </SettingRow>
<SettingDivider /> <SettingDivider />
<SettingRow>
<SettingRowTitleSmall>{t('settings.messages.input.paste_long_text_as_file')}</SettingRowTitleSmall>
<Switch
size="small"
checked={pasteLongTextAsFile}
onChange={(checked) => dispatch(setPasteLongTextAsFile(checked))}
/>
</SettingRow>
<SettingDivider />
<SettingRow> <SettingRow>
<SettingRowTitleSmall>{t('settings.messages.input.send_shortcuts')}</SettingRowTitleSmall> <SettingRowTitleSmall>{t('settings.messages.input.send_shortcuts')}</SettingRowTitleSmall>
</SettingRow> </SettingRow>
@@ -235,8 +248,8 @@ const Container = styled.div`
flex: 1; flex: 1;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
min-width: 300px;
padding-bottom: 10px; padding-bottom: 10px;
padding: 10px 15px;
` `
const Label = styled.p` const Label = styled.p`

View File

@@ -1,13 +1,14 @@
import { DeleteOutlined, EditOutlined, OpenAIOutlined } from '@ant-design/icons' import { CloseOutlined, DeleteOutlined, EditOutlined, FolderOutlined, UploadOutlined } from '@ant-design/icons'
import DragableList from '@renderer/components/DragableList' import DragableList from '@renderer/components/DragableList'
import PromptPopup from '@renderer/components/Popups/PromptPopup' import PromptPopup from '@renderer/components/Popups/PromptPopup'
import { useAssistant } from '@renderer/hooks/useAssistant' import { useAssistant, useAssistants } from '@renderer/hooks/useAssistant'
import { useSettings } from '@renderer/hooks/useSettings' import { TopicManager } from '@renderer/hooks/useTopic'
import { fetchMessagesSummary } from '@renderer/services/api' import { fetchMessagesSummary } from '@renderer/services/api'
import LocalStorage from '@renderer/services/storage' import { EVENT_NAMES, EventEmitter } from '@renderer/services/event'
import { useAppSelector } from '@renderer/store' import { useAppSelector } from '@renderer/store'
import { Assistant, Topic } from '@renderer/types' import { Assistant, Topic } from '@renderer/types'
import { Dropdown, MenuProps } from 'antd' import { Dropdown, MenuProps } from 'antd'
import { findIndex } from 'lodash'
import { FC, useCallback } from 'react' import { FC, useCallback } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
@@ -19,12 +20,49 @@ interface Props {
} }
const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic }) => { const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic }) => {
const { assistant, removeTopic, updateTopic, updateTopics } = useAssistant(_assistant.id) const { assistants } = useAssistants()
const { assistant, removeTopic, moveTopic, updateTopic, updateTopics } = useAssistant(_assistant.id)
const { t } = useTranslation() const { t } = useTranslation()
const generating = useAppSelector((state) => state.runtime.generating) const generating = useAppSelector((state) => state.runtime.generating)
const { topicPosition } = useSettings()
const borderStyle = '0.5px solid var(--color-border)' const onDeleteTopic = useCallback(
(topic: Topic) => {
if (generating) {
window.message.warning({ content: t('message.switch.disabled'), key: 'generating' })
return
}
if (assistant.topics.length > 1) {
const index = findIndex(assistant.topics, (t) => t.id === topic.id)
setActiveTopic(assistant.topics[index + 1 === assistant.topics.length ? 0 : index + 1])
removeTopic(topic)
}
},
[assistant.topics, generating, removeTopic, setActiveTopic, t]
)
const onMoveTopic = useCallback(
(topic: Topic, toAssistant: Assistant) => {
if (generating) {
window.message.warning({ content: t('message.switch.disabled'), key: 'generating' })
return
}
const index = findIndex(assistant.topics, (t) => t.id === topic.id)
setActiveTopic(assistant.topics[index + 1 === assistant.topics.length ? 0 : index + 1])
moveTopic(topic, toAssistant)
},
[assistant.topics, generating, moveTopic, setActiveTopic, t]
)
const onSwitchTopic = useCallback(
(topic: Topic) => {
if (generating) {
window.message.warning({ content: t('message.switch.disabled'), key: 'generating' })
return
}
setActiveTopic(topic)
},
[generating, setActiveTopic, t]
)
const getTopicMenuItems = useCallback( const getTopicMenuItems = useCallback(
(topic: Topic) => { (topic: Topic) => {
@@ -32,9 +70,9 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
{ {
label: t('chat.topics.auto_rename'), label: t('chat.topics.auto_rename'),
key: 'auto-rename', key: 'auto-rename',
icon: <OpenAIOutlined />, icon: <i className="iconfont icon-business-smart-assistant" style={{ fontSize: '14px' }} />,
async onClick() { async onClick() {
const messages = await LocalStorage.getTopicMessages(topic.id) const messages = await TopicManager.getTopicMessages(topic.id)
if (messages.length >= 2) { if (messages.length >= 2) {
const summaryText = await fetchMessagesSummary({ messages, assistant }) const summaryText = await fetchMessagesSummary({ messages, assistant })
if (summaryText) { if (summaryText) {
@@ -57,8 +95,35 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
updateTopic({ ...topic, name }) updateTopic({ ...topic, name })
} }
} }
},
{
label: t('chat.topics.export.title'),
key: 'export',
icon: <UploadOutlined />,
children: [
{
label: t('chat.topics.export.image'),
key: 'image',
onClick: () => EventEmitter.emit(EVENT_NAMES.EXPORT_TOPIC_IMAGE, topic)
} }
] ]
}
]
if (assistants.length > 1 && assistant.topics.length > 1) {
menus.push({
label: t('chat.topics.move_to'),
key: 'move',
icon: <FolderOutlined />,
children: assistants
.filter((a) => a.id !== assistant.id)
.map((a) => ({
label: a.name,
key: a.id,
onClick: () => onMoveTopic(topic, a)
}))
})
}
if (assistant.topics.length > 1) { if (assistant.topics.length > 1) {
menus.push({ type: 'divider' }) menus.push({ type: 'divider' })
@@ -67,39 +132,37 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
danger: true, danger: true,
key: 'delete', key: 'delete',
icon: <DeleteOutlined />, icon: <DeleteOutlined />,
onClick() { onClick: () => onDeleteTopic(topic)
if (assistant.topics.length === 1) return
removeTopic(topic)
setActiveTopic(assistant.topics[0])
}
}) })
} }
return menus return menus
}, },
[assistant, removeTopic, setActiveTopic, t, updateTopic] [assistant, assistants, onDeleteTopic, onMoveTopic, t, updateTopic]
)
const onSwitchTopic = useCallback(
(topic: Topic) => {
if (generating) {
window.message.warning({ content: t('message.switch.disabled'), key: 'switch-assistant' })
return
}
setActiveTopic(topic)
},
[generating, setActiveTopic, t]
) )
return ( return (
<Container style={topicPosition === 'left' ? { borderRight: borderStyle } : { borderLeft: borderStyle }}> <Container>
<DragableList list={assistant.topics} onUpdate={updateTopics}> <DragableList list={assistant.topics} onUpdate={updateTopics}>
{(topic) => { {(topic) => {
const isActive = topic.id === activeTopic?.id const isActive = topic.id === activeTopic?.id
return ( return (
<Dropdown menu={{ items: getTopicMenuItems(topic) }} trigger={['contextMenu']} key={topic.id}> <Dropdown menu={{ items: getTopicMenuItems(topic) }} trigger={['contextMenu']} key={topic.id}>
<TopicListItem className={isActive ? 'active' : ''} onClick={() => onSwitchTopic(topic)}> <TopicListItem className={isActive ? 'active' : ''} onClick={() => onSwitchTopic(topic)}>
{topic.name} <TopicName className="name">
<TopicHash>#</TopicHash>
{topic.name.replace('`', '')}
</TopicName>
{assistant.topics.length > 1 && isActive && (
<MenuButton
className="menu"
onClick={(e) => {
e.stopPropagation()
onDeleteTopic(topic)
}}>
<CloseOutlined />
</MenuButton>
)}
</TopicListItem> </TopicListItem>
</Dropdown> </Dropdown>
) )
@@ -114,29 +177,80 @@ const Container = styled.div`
flex: 1; flex: 1;
flex-direction: column; flex-direction: column;
padding-top: 10px; padding-top: 10px;
min-width: var(--topic-list-width);
max-width: var(--topic-list-width);
overflow-y: scroll; overflow-y: scroll;
height: calc(100vh - var(--navbar-height)); max-height: calc(100vh - var(--navbar-height) - 70px);
&::-webkit-scrollbar {
display: none;
}
` `
const TopicListItem = styled.div` const TopicListItem = styled.div`
padding: 7px 10px; padding: 7px 10px;
margin: 0 10px; margin: 0 10px;
cursor: pointer;
border-radius: 4px; border-radius: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-family: Ubuntu; font-family: Ubuntu;
font-size: 13px; font-size: 13px;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
position: relative;
font-family: Ubuntu;
cursor: pointer;
.menu {
opacity: 0;
color: var(--color-text-3);
}
&:hover { &:hover {
background-color: var(--color-background-soft); background-color: var(--color-background-soft);
.name {
opacity: 1;
}
} }
&.active { &.active {
background-color: var(--color-background-mute); background-color: var(--color-background-mute);
.name {
opacity: 1;
font-weight: 500; font-weight: 500;
} }
.menu {
opacity: 1;
background-color: var(--color-background-mute);
&:hover {
color: var(--color-text-2);
}
}
}
`
const TopicName = styled.div`
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
font-size: 13px;
opacity: 0.6;
`
const MenuButton = styled.div`
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
min-width: 22px;
min-height: 22px;
position: absolute;
right: 8px;
top: 6px;
.anticon {
font-size: 12px;
}
`
const TopicHash = styled.span`
font-size: 13px;
color: var(--color-text-3);
margin-right: 2px;
` `
export default Topics export default Topics

View File

@@ -1,5 +1,7 @@
import ModelAvatar from '@renderer/components/Avatar/ModelAvatar' import ModelAvatar from '@renderer/components/Avatar/ModelAvatar'
import VisionIcon from '@renderer/components/Icons/VisionIcon'
import { isLocalAi } from '@renderer/config/env' import { isLocalAi } from '@renderer/config/env'
import { isVisionModel } from '@renderer/config/models'
import { useAssistant } from '@renderer/hooks/useAssistant' import { useAssistant } from '@renderer/hooks/useAssistant'
import { Assistant } from '@renderer/types' import { Assistant } from '@renderer/types'
import { Button } from 'antd' import { Button } from 'antd'
@@ -27,6 +29,7 @@ const SelectModelButton: FC<Props> = ({ assistant }) => {
<DropdownButton size="small" type="default"> <DropdownButton size="small" type="default">
<ModelAvatar model={model} size={20} /> <ModelAvatar model={model} size={20} />
<ModelName>{model ? upperFirst(model.name) : t('button.select_model')}</ModelName> <ModelName>{model ? upperFirst(model.name) : t('button.select_model')}</ModelName>
{isVisionModel(model) && <VisionIcon style={{ marginLeft: 0 }} />}
</DropdownButton> </DropdownButton>
</SelectModelDropdown> </SelectModelDropdown>
) )

View File

@@ -1,5 +1,8 @@
import VisionIcon from '@renderer/components/Icons/VisionIcon'
import { isVisionModel } from '@renderer/config/models'
import { getModelLogo } from '@renderer/config/provider' import { getModelLogo } from '@renderer/config/provider'
import { useProviders } from '@renderer/hooks/useProvider' import { useProviders } from '@renderer/hooks/useProvider'
import { getModelUniqId } from '@renderer/services/model'
import { Model } from '@renderer/types' import { Model } from '@renderer/types'
import { Avatar, Dropdown, DropdownProps, MenuProps } from 'antd' import { Avatar, Dropdown, DropdownProps, MenuProps } from 'antd'
import { first, reverse, sortBy, upperFirst } from 'lodash' import { first, reverse, sortBy, upperFirst } from 'lodash'
@@ -23,9 +26,12 @@ const SelectModelDropdown: FC<Props & PropsWithChildren> = ({ children, model, o
label: p.isSystem ? t(`provider.${p.id}`) : p.name, label: p.isSystem ? t(`provider.${p.id}`) : p.name,
type: 'group', type: 'group',
children: reverse(sortBy(p.models, 'name')).map((m) => ({ children: reverse(sortBy(p.models, 'name')).map((m) => ({
key: m?.id, key: getModelUniqId(m),
label: upperFirst(m?.name), label: (
defaultSelectedKeys: [model?.id], <div>
{upperFirst(m?.name)} {isVisionModel(m) && <VisionIcon />}
</div>
),
icon: ( icon: (
<Avatar src={getModelLogo(m?.id || '')} size={24}> <Avatar src={getModelLogo(m?.id || '')} size={24}>
{first(m?.name)} {first(m?.name)}
@@ -37,7 +43,11 @@ const SelectModelDropdown: FC<Props & PropsWithChildren> = ({ children, model, o
return ( return (
<DropdownMenu <DropdownMenu
menu={{ items, style: { maxHeight: '80vh', overflow: 'auto' }, selectedKeys: model ? [model.id] : [] }} menu={{
items,
style: { maxHeight: '55vh', overflow: 'auto' },
selectedKeys: model ? [getModelUniqId(model)] : []
}}
trigger={['click']} trigger={['click']}
arrow arrow
placement="bottom" placement="bottom"

View File

@@ -37,7 +37,7 @@ const Suggestions: FC<Props> = ({ assistant, messages, lastMessage }) => {
useEffect(() => { useEffect(() => {
const unsubscribes = [ const unsubscribes = [
EventEmitter.on(EVENT_NAMES.AI_CHAT_COMPLETION, async (msg: Message) => { EventEmitter.on(EVENT_NAMES.RECEIVE_MESSAGE, async (msg: Message) => {
setLoadingSuggestions(true) setLoadingSuggestions(true)
const _suggestions = await fetchSuggestions({ assistant, messages: [...messages, msg] }) const _suggestions = await fetchSuggestions({ assistant, messages: [...messages, msg] })
if (_suggestions.length) { if (_suggestions.length) {

View File

@@ -108,18 +108,18 @@ const AssistantSettings: FC = () => {
<Col span={21}> <Col span={21}>
<Slider <Slider
min={0} min={0}
max={1.2} max={2}
onChange={setTemperature} onChange={setTemperature}
onChangeComplete={onTemperatureChange} onChangeComplete={onTemperatureChange}
value={typeof temperature === 'number' ? temperature : 0} value={typeof temperature === 'number' ? temperature : 0}
marks={{ 0: '0', 0.7: '0.7', 1: '1', 1.2: '1.2' }} marks={{ 0: '0', 0.7: '0.7', 2: '2' }}
step={0.1} step={0.1}
/> />
</Col> </Col>
<Col span={3}> <Col span={3}>
<InputNumber <InputNumber
min={0} min={0}
max={1.2} max={2}
step={0.1} step={0.1}
value={temperature} value={temperature}
onChange={onTemperatureChange} onChange={onTemperatureChange}

View File

@@ -1,13 +1,15 @@
import { FolderOpenOutlined, SaveOutlined } from '@ant-design/icons' import { FolderOpenOutlined, SaveOutlined } from '@ant-design/icons'
import { HStack } from '@renderer/components/Layout' import { HStack } from '@renderer/components/Layout'
import { isMac } from '@renderer/config/constant'
import { useSettings } from '@renderer/hooks/useSettings' import { useSettings } from '@renderer/hooks/useSettings'
import i18n from '@renderer/i18n' import i18n from '@renderer/i18n'
import { backup, reset, restore } from '@renderer/services/backup' import { backup, reset, restore } from '@renderer/services/backup'
import { useAppDispatch } from '@renderer/store' import { useAppDispatch } from '@renderer/store'
import { setLanguage, setUserName, ThemeMode } from '@renderer/store/settings' import { setClickAssistantToShowTopic, setLanguage } from '@renderer/store/settings'
import { setProxyUrl as _setProxyUrl } from '@renderer/store/settings' import { setProxyUrl as _setProxyUrl } from '@renderer/store/settings'
import { ThemeMode } from '@renderer/types'
import { isValidProxyUrl } from '@renderer/utils' import { isValidProxyUrl } from '@renderer/utils'
import { Button, Input, Select } from 'antd' import { Button, Input, Select, Switch } from 'antd'
import { FC, useState } from 'react' import { FC, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@@ -17,10 +19,10 @@ const GeneralSettings: FC = () => {
const { const {
language, language,
proxyUrl: storeProxyUrl, proxyUrl: storeProxyUrl,
userName,
theme, theme,
windowStyle, windowStyle,
topicPosition, topicPosition,
clickAssistantToShowTopic,
setTheme, setTheme,
setWindowStyle, setWindowStyle,
setTopicPosition setTopicPosition
@@ -75,6 +77,8 @@ const GeneralSettings: FC = () => {
]} ]}
/> />
</SettingRow> </SettingRow>
{isMac && (
<>
<SettingDivider /> <SettingDivider />
<SettingRow> <SettingRow>
<SettingRowTitle>{t('settings.theme.window.style.title')}</SettingRowTitle> <SettingRowTitle>{t('settings.theme.window.style.title')}</SettingRowTitle>
@@ -88,6 +92,8 @@ const GeneralSettings: FC = () => {
]} ]}
/> />
</SettingRow> </SettingRow>
</>
)}
<SettingDivider /> <SettingDivider />
<SettingRow> <SettingRow>
<SettingRowTitle>{t('settings.topic.position')}</SettingRowTitle> <SettingRowTitle>{t('settings.topic.position')}</SettingRowTitle>
@@ -102,17 +108,18 @@ const GeneralSettings: FC = () => {
/> />
</SettingRow> </SettingRow>
<SettingDivider /> <SettingDivider />
<SettingRow> {topicPosition === 'left' && (
<SettingRowTitle>{t('settings.general.user_name')}</SettingRowTitle> <>
<Input <SettingRow style={{ minHeight: 32 }}>
placeholder={t('settings.general.user_name.placeholder')} <SettingRowTitle>{t('settings.advanced.click_assistant_switch_to_topics')}</SettingRowTitle>
value={userName} <Switch
onChange={(e) => dispatch(setUserName(e.target.value))} checked={clickAssistantToShowTopic}
style={{ width: 180 }} onChange={(checked) => dispatch(setClickAssistantToShowTopic(checked))}
maxLength={30}
/> />
</SettingRow> </SettingRow>
<SettingDivider /> <SettingDivider />
</>
)}
<SettingRow> <SettingRow>
<SettingRowTitle>{t('settings.proxy.title')}</SettingRowTitle> <SettingRowTitle>{t('settings.proxy.title')}</SettingRowTitle>
<Input <Input

View File

@@ -1,10 +1,11 @@
import { EditOutlined, MessageOutlined, TranslationOutlined } from '@ant-design/icons' import { EditOutlined, MessageOutlined, TranslationOutlined } from '@ant-design/icons'
import { useDefaultModel } from '@renderer/hooks/useAssistant' import { useDefaultModel } from '@renderer/hooks/useAssistant'
import { useProviders } from '@renderer/hooks/useProvider' import { useProviders } from '@renderer/hooks/useProvider'
import { getModelUniqId, hasModel } from '@renderer/services/model'
import { Model } from '@renderer/types' import { Model } from '@renderer/types'
import { Select } from 'antd' import { Select } from 'antd'
import { find, sortBy, upperFirst } from 'lodash' import { find, sortBy, upperFirst } from 'lodash'
import { FC } from 'react' import { FC, useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { SettingContainer, SettingDivider, SettingTitle } from '.' import { SettingContainer, SettingDivider, SettingTitle } from '.'
@@ -23,11 +24,24 @@ const ModelSettings: FC = () => {
title: p.name, title: p.name,
options: sortBy(p.models, 'name').map((m) => ({ options: sortBy(p.models, 'name').map((m) => ({
label: upperFirst(m.name), label: upperFirst(m.name),
value: m.id value: getModelUniqId(m)
})) }))
})) }))
const iconStyle = { fontSize: 16, marginRight: 8 } const defaultModelValue = useMemo(
() => (hasModel(defaultModel) ? getModelUniqId(defaultModel) : undefined),
[defaultModel]
)
const defaultTopicNamingModel = useMemo(
() => (hasModel(topicNamingModel) ? getModelUniqId(topicNamingModel) : undefined),
[topicNamingModel]
)
const defaultTranslateModel = useMemo(
() => (hasModel(translateModel) ? getModelUniqId(translateModel) : undefined),
[translateModel]
)
return ( return (
<SettingContainer> <SettingContainer>
@@ -39,10 +53,12 @@ const ModelSettings: FC = () => {
</SettingTitle> </SettingTitle>
<SettingDivider /> <SettingDivider />
<Select <Select
defaultValue={defaultModel.id} value={defaultModelValue}
defaultValue={defaultModelValue}
style={{ width: 360 }} style={{ width: 360 }}
onChange={(id) => setDefaultModel(find(allModels, { id }) as Model)} onChange={(value) => setDefaultModel(find(allModels, JSON.parse(value)) as Model)}
options={selectOptions} options={selectOptions}
placeholder={t('settings.models.empty')}
/> />
<div style={{ height: 30 }} /> <div style={{ height: 30 }} />
<SettingTitle> <SettingTitle>
@@ -53,10 +69,12 @@ const ModelSettings: FC = () => {
</SettingTitle> </SettingTitle>
<SettingDivider /> <SettingDivider />
<Select <Select
defaultValue={topicNamingModel.id} value={defaultTopicNamingModel}
defaultValue={defaultTopicNamingModel}
style={{ width: 360 }} style={{ width: 360 }}
onChange={(id) => setTopicNamingModel(find(allModels, { id }) as Model)} onChange={(value) => setTopicNamingModel(find(allModels, JSON.parse(value)) as Model)}
options={selectOptions} options={selectOptions}
placeholder={t('settings.models.empty')}
/> />
<div style={{ height: 30 }} /> <div style={{ height: 30 }} />
<SettingTitle> <SettingTitle>
@@ -67,9 +85,10 @@ const ModelSettings: FC = () => {
</SettingTitle> </SettingTitle>
<SettingDivider /> <SettingDivider />
<Select <Select
defaultValue={translateModel?.id} value={defaultTranslateModel}
defaultValue={defaultTranslateModel}
style={{ width: 360 }} style={{ width: 360 }}
onChange={(id) => setTranslateModel(find(allModels, { id }) as Model)} onChange={(value) => setTranslateModel(find(allModels, JSON.parse(value)) as Model)}
options={selectOptions} options={selectOptions}
placeholder={t('settings.models.empty')} placeholder={t('settings.models.empty')}
/> />
@@ -77,4 +96,6 @@ const ModelSettings: FC = () => {
) )
} }
const iconStyle = { fontSize: 16, marginRight: 8 }
export default ModelSettings export default ModelSettings

View File

@@ -67,7 +67,8 @@ const PopupContainer: React.FC<Props> = ({ title, provider, resolve }) => {
onCancel={onCancel} onCancel={onCancel}
maskClosable={false} maskClosable={false}
afterClose={onClose} afterClose={onClose}
footer={null}> footer={null}
centered>
<Form <Form
form={form} form={form}
labelCol={{ flex: '110px' }} labelCol={{ flex: '110px' }}

View File

@@ -38,6 +38,7 @@ const PopupContainer: React.FC<Props> = ({ provider, resolve }) => {
afterClose={onClose} afterClose={onClose}
width={360} width={360}
closable={false} closable={false}
centered
title={t('settings.provider.edit.name')} title={t('settings.provider.edit.name')}
okButtonProps={{ disabled: buttonDisabled }}> okButtonProps={{ disabled: buttonDisabled }}>
<Input <Input

View File

@@ -1,5 +1,6 @@
import { LoadingOutlined, MinusOutlined, PlusOutlined, QuestionCircleOutlined } from '@ant-design/icons' import { LoadingOutlined, MinusOutlined, PlusOutlined, QuestionCircleOutlined } from '@ant-design/icons'
import { SYSTEM_MODELS } from '@renderer/config/models' import VisionIcon from '@renderer/components/Icons/VisionIcon'
import { isVisionModel, SYSTEM_MODELS } from '@renderer/config/models'
import { getModelLogo } from '@renderer/config/provider' import { getModelLogo } from '@renderer/config/provider'
import { useProvider } from '@renderer/hooks/useProvider' import { useProvider } from '@renderer/hooks/useProvider'
import { fetchModels } from '@renderer/services/api' import { fetchModels } from '@renderer/services/api'
@@ -107,7 +108,8 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
styles={{ styles={{
content: { padding: 0 }, content: { padding: 0 },
header: { padding: 22, paddingBottom: 15 } header: { padding: 22, paddingBottom: 15 }
}}> }}
centered>
<SearchContainer> <SearchContainer>
<Search placeholder={t('settings.provider.search_placeholder')} allowClear onSearch={setSearchText} /> <Search placeholder={t('settings.provider.search_placeholder')} allowClear onSearch={setSearchText} />
</SearchContainer> </SearchContainer>
@@ -125,6 +127,7 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
</Avatar> </Avatar>
<ListItemName> <ListItemName>
{model.name} {model.name}
{isVisionModel(model) && <VisionIcon />}
{isFreeModel(model) && ( {isFreeModel(model) && (
<Tag style={{ marginLeft: 10 }} color="green"> <Tag style={{ marginLeft: 10 }} color="green">
Free Free
@@ -154,7 +157,8 @@ const onShowModelInfo = (model: Model) => {
title: model.name, title: model.name,
content: model?.description, content: model?.description,
icon: null, icon: null,
maskClosable: true maskClosable: true,
width: 600
}) })
} }

View File

@@ -6,6 +6,8 @@ import {
MinusCircleOutlined, MinusCircleOutlined,
PlusOutlined PlusOutlined
} from '@ant-design/icons' } from '@ant-design/icons'
import VisionIcon from '@renderer/components/Icons/VisionIcon'
import { isVisionModel } from '@renderer/config/models'
import { getModelLogo } from '@renderer/config/provider' import { getModelLogo } from '@renderer/config/provider'
import { PROVIDER_CONFIG } from '@renderer/config/provider' import { PROVIDER_CONFIG } from '@renderer/config/provider'
import { useTheme } from '@renderer/context/ThemeProvider' import { useTheme } from '@renderer/context/ThemeProvider'
@@ -14,7 +16,7 @@ import { checkApi } from '@renderer/services/api'
import { Provider } from '@renderer/types' import { Provider } from '@renderer/types'
import { Avatar, Button, Card, Divider, Flex, Input, Space, Switch } from 'antd' import { Avatar, Button, Card, Divider, Flex, Input, Space, Switch } from 'antd'
import Link from 'antd/es/typography/Link' import Link from 'antd/es/typography/Link'
import { groupBy } from 'lodash' import { groupBy, isEmpty } from 'lodash'
import { FC, useEffect, useState } from 'react' import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
@@ -72,7 +74,6 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
const docsWebsite = providerConfig?.websites?.docs const docsWebsite = providerConfig?.websites?.docs
const modelsWebsite = providerConfig?.websites?.models const modelsWebsite = providerConfig?.websites?.models
const configedApiHost = providerConfig?.api?.url const configedApiHost = providerConfig?.api?.url
const apiEditable = provider.isSystem ? providerConfig?.api?.editable : true
const onReset = () => { const onReset = () => {
setApiHost(configedApiHost) setApiHost(configedApiHost)
@@ -131,9 +132,10 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
placeholder={t('settings.provider.api_host')} placeholder={t('settings.provider.api_host')}
onChange={(e) => setApiHost(e.target.value)} onChange={(e) => setApiHost(e.target.value)}
onBlur={onUpdateApiHost} onBlur={onUpdateApiHost}
disabled={!apiEditable}
/> />
{apiEditable && <Button onClick={onReset}>{t('settings.provider.api.url.reset')}</Button>} {!isEmpty(configedApiHost) && apiHost !== configedApiHost && (
<Button onClick={onReset}>{t('settings.provider.api.url.reset')}</Button>
)}
</Space.Compact> </Space.Compact>
{provider.id === 'ollama' && <OllamSettings />} {provider.id === 'ollama' && <OllamSettings />}
{provider.id === 'graphrag-kylin-mountain' && provider.models.length > 0 && ( {provider.id === 'graphrag-kylin-mountain' && provider.models.length > 0 && (
@@ -148,7 +150,7 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
<Avatar src={getModelLogo(model.id)} size={22} style={{ marginRight: '8px' }}> <Avatar src={getModelLogo(model.id)} size={22} style={{ marginRight: '8px' }}>
{model.name[0].toUpperCase()} {model.name[0].toUpperCase()}
</Avatar> </Avatar>
{model.name} {model.name} {isVisionModel(model) && <VisionIcon />}
</ModelListHeader> </ModelListHeader>
<RemoveIcon onClick={() => removeModel(model)} /> <RemoveIcon onClick={() => removeModel(model)} />
</ModelListItem> </ModelListItem>

View File

@@ -71,6 +71,7 @@ const ProvidersList: FC = () => {
content: t('settings.provider.delete.content'), content: t('settings.provider.delete.content'),
okButtonProps: { danger: true }, okButtonProps: { danger: true },
okText: t('common.delete'), okText: t('common.delete'),
centered: true,
onOk: () => { onOk: () => {
setSelectedProvider(providers.filter((p) => p.isSystem)[0]) setSelectedProvider(providers.filter((p) => p.isSystem)[0])
removeProvider(provider) removeProvider(provider)

View File

@@ -90,7 +90,7 @@ const ContentContainer = styled.div`
const SettingMenus = styled.ul` const SettingMenus = styled.ul`
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-width: var(--assistants-width); min-width: var(--settings-width);
border-right: 0.5px solid var(--color-border); border-right: 0.5px solid var(--color-border);
padding: 10px; padding: 10px;
` `
@@ -118,6 +118,7 @@ const MenuItem = styled.li`
} }
.iconfont { .iconfont {
font-size: 18px; font-size: 18px;
line-height: 18px;
opacity: 0.7; opacity: 0.7;
margin-left: -1px; margin-left: -1px;
} }

View File

@@ -10,19 +10,15 @@ export default class AiProvider {
this.sdk = ProviderFactory.create(provider) this.sdk = ProviderFactory.create(provider)
} }
public async completions( public async completions({ messages, assistant, onChunk, onFilterMessages }: CompletionsParams): Promise<void> {
messages: Message[], return this.sdk.completions({ messages, assistant, onChunk, onFilterMessages })
assistant: Assistant,
onChunk: ({ text, usage }: { text?: string; usage?: OpenAI.Completions.CompletionUsage }) => void
): Promise<void> {
return this.sdk.completions(messages, assistant, onChunk)
} }
public async translate(message: Message, assistant: Assistant): Promise<string> { public async translate(message: Message, assistant: Assistant): Promise<string> {
return this.sdk.translate(message, assistant) return this.sdk.translate(message, assistant)
} }
public async summaries(messages: Message[], assistant: Assistant): Promise<string | null> { public async summaries(messages: Message[], assistant: Assistant): Promise<string> {
return this.sdk.summaries(messages, assistant) return this.sdk.summaries(messages, assistant)
} }
@@ -30,6 +26,10 @@ export default class AiProvider {
return this.sdk.suggestions(messages, assistant) return this.sdk.suggestions(messages, assistant)
} }
public async generateText({ prompt, content }: { prompt: string; content: string }): Promise<string> {
return this.sdk.generateText({ prompt, content })
}
public async check(): Promise<{ valid: boolean; error: Error | null }> { public async check(): Promise<{ valid: boolean; error: Error | null }> {
return this.sdk.check() return this.sdk.check()
} }

View File

@@ -4,8 +4,8 @@ import { DEFAULT_MAX_TOKENS } from '@renderer/config/constant'
import { getAssistantSettings, getDefaultModel, getTopNamingModel } from '@renderer/services/assistant' import { getAssistantSettings, getDefaultModel, getTopNamingModel } from '@renderer/services/assistant'
import { EVENT_NAMES } from '@renderer/services/event' import { EVENT_NAMES } from '@renderer/services/event'
import { filterContextMessages, filterMessages } from '@renderer/services/messages' import { filterContextMessages, filterMessages } from '@renderer/services/messages'
import { Assistant, Message, Provider, Suggestion } from '@renderer/types' import { Assistant, FileTypes, Message, Provider, Suggestion } from '@renderer/types'
import { first, sum, takeRight } from 'lodash' import { first, flatten, sum, takeRight } from 'lodash'
import OpenAI from 'openai' import OpenAI from 'openai'
import BaseProvider from './BaseProvider' import BaseProvider from './BaseProvider'
@@ -18,21 +18,55 @@ export default class AnthropicProvider extends BaseProvider {
this.sdk = new Anthropic({ apiKey: provider.apiKey, baseURL: this.getBaseURL() }) this.sdk = new Anthropic({ apiKey: provider.apiKey, baseURL: this.getBaseURL() })
} }
public async completions( public getBaseURL(): string {
messages: Message[], return this.provider.apiHost
assistant: Assistant, }
onChunk: ({ text, usage }: { text?: string; usage?: OpenAI.Completions.CompletionUsage }) => void
) { private async getMessageParam(message: Message): Promise<MessageParam> {
const parts: MessageParam['content'] = [{ type: 'text', text: message.content }]
for (const file of message.files || []) {
if (file.type === FileTypes.IMAGE) {
const base64Data = await window.api.file.base64Image(file.id + file.ext)
parts.push({
type: 'image',
source: {
data: base64Data.base64,
media_type: base64Data.mime.replace('jpg', 'jpeg') as any,
type: 'base64'
}
})
}
if (file.type === FileTypes.TEXT) {
const fileContent = await (await window.api.file.read(file.id + file.ext)).trim()
parts.push({
type: 'text',
text: file.origin_name + '\n' + fileContent
})
}
}
return {
role: message.role,
content: parts
}
}
public async completions({ messages, assistant, onChunk, onFilterMessages }: CompletionsParams) {
const defaultModel = getDefaultModel() const defaultModel = getDefaultModel()
const model = assistant.model || defaultModel const model = assistant.model || defaultModel
const { contextCount, maxTokens } = getAssistantSettings(assistant) const { contextCount, maxTokens } = getAssistantSettings(assistant)
const userMessages = filterMessages(filterContextMessages(takeRight(messages, contextCount + 2))).map((message) => { const userMessagesParams: MessageParam[] = []
return { const _messages = filterMessages(filterContextMessages(takeRight(messages, contextCount + 2)))
role: message.role,
content: message.content onFilterMessages(_messages)
for (const message of _messages) {
userMessagesParams.push(await this.getMessageParam(message))
} }
})
const userMessages = flatten(userMessagesParams)
if (first(userMessages)?.role === 'assistant') { if (first(userMessages)?.role === 'assistant') {
userMessages.shift() userMessages.shift()
@@ -42,7 +76,7 @@ export default class AnthropicProvider extends BaseProvider {
const stream = this.sdk.messages const stream = this.sdk.messages
.stream({ .stream({
model: model.id, model: model.id,
messages: userMessages.filter(Boolean) as MessageParam[], messages: userMessages,
max_tokens: maxTokens || DEFAULT_MAX_TOKENS, max_tokens: maxTokens || DEFAULT_MAX_TOKENS,
temperature: assistant?.settings?.temperature, temperature: assistant?.settings?.temperature,
system: assistant.prompt, system: assistant.prompt,
@@ -50,8 +84,8 @@ export default class AnthropicProvider extends BaseProvider {
}) })
.on('text', (text) => { .on('text', (text) => {
if (window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED)) { if (window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED)) {
resolve() stream.controller.abort()
return stream.controller.abort() return resolve()
} }
onChunk({ text }) onChunk({ text })
}) })
@@ -90,7 +124,7 @@ export default class AnthropicProvider extends BaseProvider {
return response.content[0].type === 'text' ? response.content[0].text : '' return response.content[0].type === 'text' ? response.content[0].text : ''
} }
public async summaries(messages: Message[], assistant: Assistant): Promise<string | null> { public async summaries(messages: Message[], assistant: Assistant): Promise<string> {
const model = getTopNamingModel() || assistant.model || getDefaultModel() const model = getTopNamingModel() || assistant.model || getDefaultModel()
const userMessages = takeRight(messages, 5).map((message) => ({ const userMessages = takeRight(messages, 5).map((message) => ({
@@ -115,7 +149,26 @@ export default class AnthropicProvider extends BaseProvider {
max_tokens: 4096 max_tokens: 4096
}) })
return message.content[0].type === 'text' ? message.content[0].text : null return message.content[0].type === 'text' ? message.content[0].text : ''
}
public async generateText({ prompt, content }: { prompt: string; content: string }): Promise<string> {
const model = getDefaultModel()
const message = await this.sdk.messages.create({
messages: [
{
role: 'user',
content
}
],
model: model.id,
system: prompt,
stream: false,
max_tokens: 4096
})
return message.content[0].type === 'text' ? message.content[0].text : ''
} }
public async suggestions(): Promise<Suggestion[]> { public async suggestions(): Promise<Suggestion[]> {

View File

@@ -20,14 +20,11 @@ export default abstract class BaseProvider {
return this.provider.id === 'ollama' ? getOllamaKeepAliveTime() : undefined return this.provider.id === 'ollama' ? getOllamaKeepAliveTime() : undefined
} }
abstract completions( abstract completions({ messages, assistant, onChunk, onFilterMessages }: CompletionsParams): Promise<void>
messages: Message[],
assistant: Assistant,
onChunk: ({ text, usage }: { text?: string; usage?: OpenAI.Completions.CompletionUsage }) => void
): Promise<void>
abstract translate(message: Message, assistant: Assistant): Promise<string> abstract translate(message: Message, assistant: Assistant): Promise<string>
abstract summaries(messages: Message[], assistant: Assistant): Promise<string | null> abstract summaries(messages: Message[], assistant: Assistant): Promise<string>
abstract suggestions(messages: Message[], assistant: Assistant): Promise<Suggestion[]> abstract suggestions(messages: Message[], assistant: Assistant): Promise<Suggestion[]>
abstract generateText({ prompt, content }: { prompt: string; content: string }): Promise<string>
abstract check(): Promise<{ valid: boolean; error: Error | null }> abstract check(): Promise<{ valid: boolean; error: Error | null }>
abstract models(): Promise<OpenAI.Models.Model[]> abstract models(): Promise<OpenAI.Models.Model[]>
} }

View File

@@ -1,8 +1,8 @@
import { GoogleGenerativeAI } from '@google/generative-ai' import { Content, GoogleGenerativeAI, InlineDataPart, Part, TextPart } from '@google/generative-ai'
import { getAssistantSettings, getDefaultModel, getTopNamingModel } from '@renderer/services/assistant' import { getAssistantSettings, getDefaultModel, getTopNamingModel } from '@renderer/services/assistant'
import { EVENT_NAMES } from '@renderer/services/event' import { EVENT_NAMES } from '@renderer/services/event'
import { filterContextMessages, filterMessages } from '@renderer/services/messages' import { filterContextMessages, filterMessages } from '@renderer/services/messages'
import { Assistant, Message, Provider, Suggestion } from '@renderer/types' import { Assistant, FileTypes, Message, Provider, Suggestion } from '@renderer/types'
import axios from 'axios' import axios from 'axios'
import { isEmpty, takeRight } from 'lodash' import { isEmpty, takeRight } from 'lodash'
import OpenAI from 'openai' import OpenAI from 'openai'
@@ -17,21 +17,50 @@ export default class GeminiProvider extends BaseProvider {
this.sdk = new GoogleGenerativeAI(provider.apiKey) this.sdk = new GoogleGenerativeAI(provider.apiKey)
} }
public async completions( private async getMessageContents(message: Message): Promise<Content> {
messages: Message[], const role = message.role === 'user' ? 'user' : 'model'
assistant: Assistant,
onChunk: ({ text, usage }: { text?: string; usage?: OpenAI.Completions.CompletionUsage }) => void const parts: Part[] = [{ text: message.content }]
) {
for (const file of message.files || []) {
if (file.type === FileTypes.IMAGE) {
const base64Data = await window.api.file.base64Image(file.id + file.ext)
parts.push({
inlineData: {
data: base64Data.base64,
mimeType: base64Data.mime
}
} as InlineDataPart)
}
if (file.type === FileTypes.TEXT) {
const fileContent = await (await window.api.file.read(file.id + file.ext)).trim()
parts.push({
text: file.origin_name + '\n' + fileContent
} as TextPart)
}
}
return {
role,
parts
}
}
public async completions({ messages, assistant, onChunk, onFilterMessages }: CompletionsParams) {
const defaultModel = getDefaultModel() const defaultModel = getDefaultModel()
const model = assistant.model || defaultModel const model = assistant.model || defaultModel
const { contextCount, maxTokens } = getAssistantSettings(assistant) const { contextCount, maxTokens } = getAssistantSettings(assistant)
const userMessages = filterMessages(filterContextMessages(takeRight(messages, contextCount + 1))).map((message) => { const userMessages = filterMessages(filterContextMessages(takeRight(messages, contextCount + 1)))
return { onFilterMessages(userMessages)
role: message.role,
content: message.content const userLastMessage = userMessages.pop()
const history: Content[] = []
for (const message of userMessages) {
history.push(await this.getMessageContents(message))
} }
})
const geminiModel = this.sdk.getGenerativeModel({ const geminiModel = this.sdk.getGenerativeModel({
model: model.id, model: model.id,
@@ -42,16 +71,9 @@ export default class GeminiProvider extends BaseProvider {
} }
}) })
const userLastMessage = userMessages.pop() const chat = geminiModel.startChat({ history })
const messageContents = await this.getMessageContents(userLastMessage!)
const chat = geminiModel.startChat({ const userMessagesStream = await chat.sendMessageStream(messageContents.parts)
history: userMessages.map((message) => ({
role: message.role === 'user' ? 'user' : 'model',
parts: [{ text: message.content }]
}))
})
const userMessagesStream = await chat.sendMessageStream(userLastMessage?.content!)
for await (const chunk of userMessagesStream.stream) { for await (const chunk of userMessagesStream.stream) {
if (window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED)) break if (window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED)) break
@@ -85,7 +107,7 @@ export default class GeminiProvider extends BaseProvider {
return response.text() return response.text()
} }
public async summaries(messages: Message[], assistant: Assistant): Promise<string | null> { public async summaries(messages: Message[], assistant: Assistant): Promise<string> {
const model = getTopNamingModel() || assistant.model || getDefaultModel() const model = getTopNamingModel() || assistant.model || getDefaultModel()
const userMessages = takeRight(messages, 5).map((message) => ({ const userMessages = takeRight(messages, 5).map((message) => ({
@@ -120,6 +142,18 @@ export default class GeminiProvider extends BaseProvider {
return response.text() return response.text()
} }
public async generateText({ prompt, content }: { prompt: string; content: string }): Promise<string> {
const model = getDefaultModel()
const systemMessage = { role: 'system', content: prompt }
const geminiModel = this.sdk.getGenerativeModel({ model: model.id })
const chat = await geminiModel.startChat({ systemInstruction: systemMessage.content })
const { response } = await chat.sendMessage(content)
return response.text()
}
public async suggestions(): Promise<Suggestion[]> { public async suggestions(): Promise<Suggestion[]> {
return [] return []
} }

View File

@@ -1,9 +1,10 @@
import { isLocalAi } from '@renderer/config/env' import { isLocalAi } from '@renderer/config/env'
import { isVisionModel } from '@renderer/config/models'
import { getAssistantSettings, getDefaultModel, getTopNamingModel } from '@renderer/services/assistant' import { getAssistantSettings, getDefaultModel, getTopNamingModel } from '@renderer/services/assistant'
import { EVENT_NAMES } from '@renderer/services/event' import { EVENT_NAMES } from '@renderer/services/event'
import { filterContextMessages, filterMessages } from '@renderer/services/messages' import { filterContextMessages, filterMessages } from '@renderer/services/messages'
import { Assistant, Message, Provider, Suggestion } from '@renderer/types' import { Assistant, FileTypes, Message, Model, Provider, Suggestion } from '@renderer/types'
import { fileToBase64, removeQuotes } from '@renderer/utils' import { removeQuotes } from '@renderer/utils'
import { first, takeRight } from 'lodash' import { first, takeRight } from 'lodash'
import OpenAI from 'openai' import OpenAI from 'openai'
import { import {
@@ -26,61 +27,131 @@ export default class OpenAIProvider extends BaseProvider {
}) })
} }
private async getMessageContent(message: Message): Promise<string | ChatCompletionContentPart[]> { private isSupportStreamOutput(modelId: string): boolean {
const file = first(message.files) if (this.provider.id === 'openai' && modelId.includes('o1-')) {
return false
if (!file) { }
return message.content return true
} }
if (file.type.includes('image')) { private get isNotSupportFiles() {
return [ const providers = ['deepseek', 'baichuan', 'minimax', 'yi', 'doubao']
{ type: 'text', text: message.content }, return providers.includes(this.provider.id)
}
private async getMessageParam(
message: Message,
model: Model
): Promise<OpenAI.Chat.Completions.ChatCompletionMessageParam> {
const isVision = isVisionModel(model)
if (!message.files) {
return {
role: message.role,
content: message.content
}
}
if (this.isNotSupportFiles) {
if (message.files) {
const textFiles = message.files.filter((file) => file.type === FileTypes.TEXT)
if (textFiles.length > 0) {
let text = ''
const divider = '\n\n---\n\n'
for (const file of textFiles) {
const fileContent = (await window.api.file.read(file.id + file.ext)).trim()
const fileNameRow = 'file: ' + file.origin_name + '\n\n'
text = text + fileNameRow + fileContent + divider
}
return {
role: message.role,
content: message.content + divider + text
}
}
}
return {
role: message.role,
content: message.content
}
}
const parts: ChatCompletionContentPart[] = [
{ {
type: 'image_url', type: 'text',
image_url: { text: message.content
url: await fileToBase64(file)
}
} }
] ]
for (const file of message.files || []) {
if (file.type === FileTypes.IMAGE && isVision) {
const image = await window.api.file.base64Image(file.id + file.ext)
parts.push({
type: 'image_url',
image_url: { url: image.data }
})
}
if (file.type === FileTypes.TEXT) {
const fileContent = await (await window.api.file.read(file.id + file.ext)).trim()
parts.push({
type: 'text',
text: file.origin_name + '\n' + fileContent
})
}
} }
return message.content return {
role: message.role,
content: parts
} as ChatCompletionMessageParam
} }
async completions( async completions({ messages, assistant, onChunk, onFilterMessages }: CompletionsParams): Promise<void> {
messages: Message[],
assistant: Assistant,
onChunk: ({ text, usage }: { text?: string; usage?: OpenAI.Completions.CompletionUsage }) => void
): Promise<void> {
const defaultModel = getDefaultModel() const defaultModel = getDefaultModel()
const model = assistant.model || defaultModel const model = assistant.model || defaultModel
const { contextCount, maxTokens } = getAssistantSettings(assistant) const { contextCount, maxTokens } = getAssistantSettings(assistant)
const systemMessage = assistant.prompt ? { role: 'system', content: assistant.prompt } : undefined const systemMessage = assistant.prompt ? { role: 'system', content: assistant.prompt } : undefined
const userMessages: ChatCompletionMessageParam[] = [] const userMessages: ChatCompletionMessageParam[] = []
for (const message of filterMessages(filterContextMessages(takeRight(messages, contextCount + 1)))) { const _messages = filterMessages(filterContextMessages(takeRight(messages, contextCount + 1)))
userMessages.push({ onFilterMessages(_messages)
role: message.role,
content: await this.getMessageContent(message) for (const message of _messages) {
} as ChatCompletionMessageParam) userMessages.push(await this.getMessageParam(message, model))
} }
const isSupportStreamOutput = this.isSupportStreamOutput(model.id)
// @ts-ignore key is not typed // @ts-ignore key is not typed
const stream = await this.sdk.chat.completions.create({ const stream = await this.sdk.chat.completions.create({
model: model.id, model: model.id,
messages: [systemMessage, ...userMessages].filter(Boolean) as ChatCompletionMessageParam[], messages: [systemMessage, ...userMessages].filter(Boolean) as ChatCompletionMessageParam[],
stream: true, stream: isSupportStreamOutput,
temperature: assistant?.settings?.temperature, temperature: assistant?.settings?.temperature,
max_tokens: maxTokens, max_tokens: maxTokens,
keep_alive: this.keepAliveTime keep_alive: this.keepAliveTime
}) })
if (!isSupportStreamOutput) {
return onChunk({
text: stream.choices[0].message?.content || '',
usage: stream.usage
})
}
for await (const chunk of stream) { for await (const chunk of stream) {
if (window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED)) break if (window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED)) {
onChunk({ text: chunk.choices[0]?.delta?.content || '', usage: chunk.usage }) break
}
onChunk({
text: chunk.choices[0]?.delta?.content || '',
usage: chunk.usage
})
} }
} }
@@ -103,7 +174,7 @@ export default class OpenAIProvider extends BaseProvider {
return response.choices[0].message?.content || '' return response.choices[0].message?.content || ''
} }
public async summaries(messages: Message[], assistant: Assistant): Promise<string | null> { public async summaries(messages: Message[], assistant: Assistant): Promise<string> {
const model = getTopNamingModel() || assistant.model || getDefaultModel() const model = getTopNamingModel() || assistant.model || getDefaultModel()
const userMessages = takeRight(messages, 5).map((message) => ({ const userMessages = takeRight(messages, 5).map((message) => ({
@@ -128,6 +199,21 @@ export default class OpenAIProvider extends BaseProvider {
return removeQuotes(response.choices[0].message?.content?.substring(0, 50) || '') return removeQuotes(response.choices[0].message?.content?.substring(0, 50) || '')
} }
public async generateText({ prompt, content }: { prompt: string; content: string }): Promise<string> {
const model = getDefaultModel()
const response = await this.sdk.chat.completions.create({
model: model.id,
stream: false,
messages: [
{ role: 'user', content },
{ role: 'system', content: prompt }
]
})
return response.choices[0].message?.content || ''
}
async suggestions(messages: Message[], assistant: Assistant): Promise<Suggestion[]> { async suggestions(messages: Message[], assistant: Assistant): Promise<Suggestion[]> {
const model = assistant.model const model = assistant.model
@@ -178,6 +264,17 @@ export default class OpenAIProvider extends BaseProvider {
public async models(): Promise<OpenAI.Models.Model[]> { public async models(): Promise<OpenAI.Models.Model[]> {
try { try {
const response = await this.sdk.models.list() const response = await this.sdk.models.list()
if (this.provider.id === 'github') {
// @ts-ignore key is not typed
return response.body.map((model) => ({
id: model.name,
description: model.summary,
object: 'model',
owned_by: model.publisher
}))
}
return response.data return response.data
} catch (error) { } catch (error) {
return [] return []

11
src/renderer/src/providers/index.d.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
interface ChunkCallbackData {
text?: string
usage?: OpenAI.Completions.CompletionUsage
}
interface CompletionsParams {
messages: Message[]
assistant: Assistant
onChunk: ({ text, usage }: ChunkCallbackData) => void
onFilterMessages: (messages: Message[]) => void
}

View File

@@ -16,6 +16,7 @@ import {
} from './assistant' } from './assistant'
import { EVENT_NAMES, EventEmitter } from './event' import { EVENT_NAMES, EventEmitter } from './event'
import { filterMessages } from './messages' import { filterMessages } from './messages'
import { estimateMessagesUsage } from './tokens'
export async function fetchChatCompletion({ export async function fetchChatCompletion({
messages, messages,
@@ -55,18 +56,36 @@ export async function fetchChatCompletion({
const timer = setInterval(() => { const timer = setInterval(() => {
if (window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED)) { if (window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED)) {
paused = true paused = true
message.status = 'paused'
EventEmitter.emit(EVENT_NAMES.RECEIVE_MESSAGE, message)
store.dispatch(setGenerating(false))
onResponse({ ...message, status: 'paused' }) onResponse({ ...message, status: 'paused' })
clearInterval(timer) clearInterval(timer)
} }
}, 1000) }, 1000)
try { try {
await AI.completions(messages, assistant, ({ text, usage }) => { let _messages: Message[] = []
await AI.completions({
messages,
assistant,
onFilterMessages: (messages) => (_messages = messages),
onChunk: ({ text, usage }) => {
message.content = message.content + text || '' message.content = message.content + text || ''
message.usage = usage message.usage = usage
onResponse({ ...message, status: 'pending' }) onResponse({ ...message, status: 'pending' })
}
}) })
message.status = 'success' message.status = 'success'
if (!message.usage || !message?.usage?.completion_tokens) {
message.usage = await estimateMessagesUsage({
assistant,
messages: [..._messages, message]
})
}
} catch (error: any) { } catch (error: any) {
message.content = `Error: ${error.message}` message.content = `Error: ${error.message}`
message.status = 'error' message.status = 'error'
@@ -82,7 +101,7 @@ export async function fetchChatCompletion({
message.status = window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED) ? 'paused' : message.status message.status = window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED) ? 'paused' : message.status
// Emit chat completion event // Emit chat completion event
EventEmitter.emit(EVENT_NAMES.AI_CHAT_COMPLETION, message) EventEmitter.emit(EVENT_NAMES.RECEIVE_MESSAGE, message)
// Reset generating state // Reset generating state
store.dispatch(setGenerating(false)) store.dispatch(setGenerating(false))
@@ -129,6 +148,23 @@ export async function fetchMessagesSummary({ messages, assistant }: { messages:
} }
} }
export async function fetchGenerate({ prompt, content }: { prompt: string; content: string }): Promise<string> {
const model = getDefaultModel()
const provider = getProviderByModel(model)
if (!hasApiKey(provider)) {
return ''
}
const AI = new AiProvider(provider)
try {
return await AI.generateText({ prompt, content })
} catch (error: any) {
return ''
}
}
export async function fetchSuggestions({ export async function fetchSuggestions({
messages, messages,
assistant assistant

View File

@@ -15,6 +15,10 @@ export function getDefaultAssistant(): Assistant {
} }
} }
export function getDefaultAssistantSettings() {
return store.getState().assistants.defaultAssistant.settings
}
export function getDefaultTopic(): Topic { export function getDefaultTopic(): Topic {
return { return {
id: uuid(), id: uuid(),
@@ -84,8 +88,9 @@ export function covertAgentToAssistant(agent: Agent): Assistant {
return { return {
...getDefaultAssistant(), ...getDefaultAssistant(),
...agent, ...agent,
id: agent.group === 'system' ? uuid() : String(agent.id),
name: getAssistantNameWithAgent(agent), name: getAssistantNameWithAgent(agent),
id: agent.group === 'system' ? uuid() : String(agent.id) settings: getDefaultAssistantSettings()
} }
} }

View File

@@ -1,35 +1,30 @@
import db from '@renderer/databases'
import i18n from '@renderer/i18n' import i18n from '@renderer/i18n'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import localforage from 'localforage' import localforage from 'localforage'
export async function backup() { export async function backup() {
const indexedKeys = await localforage.keys() const version = 2
const version = 1
const time = new Date().getTime() const time = new Date().getTime()
const data = { const data = {
time, time,
version, version,
localStorage, localStorage,
indexedDB: [] as { key: string; value: any }[] indexedDB: await backupDatabase()
}
for (const key of indexedKeys) {
data.indexedDB.push({
key,
value: await localforage.getItem(key)
})
} }
const filename = `cherry-studio.${dayjs().format('YYYYMMDD')}.bak` const filename = `cherry-studio.${dayjs().format('YYYYMMDD')}.bak`
const fileContnet = JSON.stringify(data) const fileContnet = JSON.stringify(data)
const file = await window.api.compress(fileContnet) const file = await window.api.compress(fileContnet)
window.api.saveFile(filename, file) await window.api.file.save(filename, file)
window.message.success({ content: i18n.t('message.backup.success'), key: 'backup' })
} }
export async function restore() { export async function restore() {
const file = await window.api.openFile() const file = await window.api.file.open()
if (file) { if (file) {
try { try {
@@ -37,17 +32,32 @@ export async function restore() {
const data = JSON.parse(content) const data = JSON.parse(content)
if (data.version === 1) { if (data.version === 1) {
localStorage.setItem('persist:cherry-studio', data.localStorage['persist:cherry-studio']) await clearDatabase()
for (const { key, value } of data.indexedDB) { for (const { key, value } of data.indexedDB) {
await localforage.setItem(key, value) if (key.startsWith('topic:')) {
await db.table('topics').add({ id: value.id, messages: value.messages })
}
if (key === 'image://avatar') {
await db.table('settings').add({ id: key, value })
}
} }
await localStorage.setItem('persist:cherry-studio', data.localStorage['persist:cherry-studio'])
window.message.success({ content: i18n.t('message.restore.success'), key: 'restore' }) window.message.success({ content: i18n.t('message.restore.success'), key: 'restore' })
setTimeout(() => window.api.reload(), 1500) setTimeout(() => window.api.reload(), 1000)
} else { return
window.message.error({ content: i18n.t('error.backup.file_format'), key: 'restore' })
} }
if (data.version === 2) {
localStorage.setItem('persist:cherry-studio', data.localStorage['persist:cherry-studio'])
await restoreDatabase(data.indexedDB)
window.message.success({ content: i18n.t('message.restore.success'), key: 'restore' })
setTimeout(() => window.api.reload(), 1000)
return
}
window.message.error({ content: i18n.t('error.backup.file_format'), key: 'restore' })
} catch (error) { } catch (error) {
console.error(error) console.error(error)
window.message.error({ content: i18n.t('error.backup.file_format'), key: 'restore' }) window.message.error({ content: i18n.t('error.backup.file_format'), key: 'restore' })
@@ -59,16 +69,52 @@ export async function reset() {
window.modal.confirm({ window.modal.confirm({
title: i18n.t('common.warning'), title: i18n.t('common.warning'),
content: i18n.t('message.reset.confirm.content'), content: i18n.t('message.reset.confirm.content'),
centered: true,
onOk: async () => { onOk: async () => {
window.modal.confirm({ window.modal.confirm({
title: i18n.t('message.reset.double.confirm.title'), title: i18n.t('message.reset.double.confirm.title'),
content: i18n.t('message.reset.double.confirm.content'), content: i18n.t('message.reset.double.confirm.content'),
centered: true,
onOk: async () => { onOk: async () => {
await localStorage.clear() await localStorage.clear()
await localforage.clear() await localforage.clear()
await clearDatabase()
await window.api.file.clear()
window.api.reload() window.api.reload()
} }
}) })
} }
}) })
} }
/************************************* Backup Utils ************************************** */
async function backupDatabase() {
const tables = db.tables
const backup = {}
for (const table of tables) {
backup[table.name] = await table.toArray()
}
return backup
}
async function restoreDatabase(backup: Record<string, any>) {
await db.transaction('rw', db.tables, async () => {
for (const tableName in backup) {
await db.table(tableName).clear()
await db.table(tableName).bulkAdd(backup[tableName])
}
})
}
async function clearDatabase() {
const storeNames = await db.tables.map((table) => table.name)
await db.transaction('rw', db.tables, async () => {
for (const storeName of storeNames) {
await db[storeName].clear()
}
})
}

View File

@@ -4,7 +4,7 @@ export const EventEmitter = new Emittery()
export const EVENT_NAMES = { export const EVENT_NAMES = {
SEND_MESSAGE: 'SEND_MESSAGE', SEND_MESSAGE: 'SEND_MESSAGE',
AI_CHAT_COMPLETION: 'AI_CHAT_COMPLETION', RECEIVE_MESSAGE: 'RECEIVE_MESSAGE',
AI_AUTO_RENAME: 'AI_AUTO_RENAME', AI_AUTO_RENAME: 'AI_AUTO_RENAME',
CLEAR_MESSAGES: 'CLEAR_MESSAGES', CLEAR_MESSAGES: 'CLEAR_MESSAGES',
ADD_ASSISTANT: 'ADD_ASSISTANT', ADD_ASSISTANT: 'ADD_ASSISTANT',
@@ -12,8 +12,11 @@ export const EVENT_NAMES = {
REGENERATE_MESSAGE: 'REGENERATE_MESSAGE', REGENERATE_MESSAGE: 'REGENERATE_MESSAGE',
CHAT_COMPLETION_PAUSED: 'CHAT_COMPLETION_PAUSED', CHAT_COMPLETION_PAUSED: 'CHAT_COMPLETION_PAUSED',
ESTIMATED_TOKEN_COUNT: 'ESTIMATED_TOKEN_COUNT', ESTIMATED_TOKEN_COUNT: 'ESTIMATED_TOKEN_COUNT',
SHOW_ASSISTANTS: 'SHOW_ASSISTANTS',
SHOW_CHAT_SETTINGS: 'SHOW_CHAT_SETTINGS', SHOW_CHAT_SETTINGS: 'SHOW_CHAT_SETTINGS',
SHOW_TOPIC_SIDEBAR: 'SHOW_TOPIC_SIDEBAR', SHOW_TOPIC_SIDEBAR: 'SHOW_TOPIC_SIDEBAR',
SWITCH_TOPIC_SIDEBAR: 'SWITCH_TOPIC_SIDEBAR', SWITCH_TOPIC_SIDEBAR: 'SWITCH_TOPIC_SIDEBAR',
NEW_CONTEXT: 'NEW_CONTEXT' NEW_CONTEXT: 'NEW_CONTEXT',
NEW_BRANCH: 'NEW_BRANCH',
EXPORT_TOPIC_IMAGE: 'EXPORT_TOPIC_IMAGE'
} }

View File

@@ -0,0 +1,66 @@
import db from '@renderer/databases'
import { FileType } from '@renderer/types'
import { getFileDirectory } from '@renderer/utils'
class FileManager {
static async selectFiles(options?: Electron.OpenDialogOptions): Promise<FileType[] | null> {
const files = await window.api.file.select(options)
return files
}
static async uploadFile(file: FileType): Promise<FileType> {
const uploadFile = await window.api.file.upload(file)
const fileRecord = await db.files.get(uploadFile.id)
if (fileRecord) {
await db.files.update(fileRecord.id, { ...fileRecord, count: fileRecord.count + 1 })
return fileRecord
}
await db.files.add(uploadFile)
return uploadFile
}
static async uploadFiles(files: FileType[]): Promise<FileType[]> {
return Promise.all(files.map((file) => this.uploadFile(file)))
}
static async getFile(id: string): Promise<FileType | undefined> {
return db.files.get(id)
}
static async deleteFile(id: string): Promise<void> {
const file = await this.getFile(id)
if (!file) {
return
}
if (file.count > 1) {
await db.files.update(id, { ...file, count: file.count - 1 })
return
}
db.files.delete(id)
await window.api.file.delete(id + file.ext)
}
static async deleteFiles(ids: string[]): Promise<void> {
await Promise.all(ids.map((id) => this.deleteFile(id)))
}
static async allFiles(): Promise<FileType[]> {
return db.files.toArray()
}
static isDangerFile(file: FileType) {
return ['.sh', '.bat', '.cmd', '.ps1', '.vbs', 'reg'].includes(file.ext)
}
static getSafePath(file: FileType) {
return this.isDangerFile(file) ? getFileDirectory(file.path) : file.path
}
}
export default FileManager

View File

@@ -1,9 +1,8 @@
import { DEFAULT_CONEXTCOUNT } from '@renderer/config/constant' import { DEFAULT_CONEXTCOUNT } from '@renderer/config/constant'
import { Assistant, Message } from '@renderer/types' import { Assistant, Message } from '@renderer/types'
import { GPTTokens } from 'gpt-tokens'
import { isEmpty, takeRight } from 'lodash' import { isEmpty, takeRight } from 'lodash'
import { getAssistantSettings } from './assistant' import FileManager from './file'
export const filterMessages = (messages: Message[]) => { export const filterMessages = (messages: Message[]) => {
return messages return messages
@@ -34,28 +33,6 @@ export function getContextCount(assistant: Assistant, messages: Message[]) {
return messagesCount - (clearIndex + 1) return messagesCount - (clearIndex + 1)
} }
export function estimateInputTokenCount(text: string) { export function deleteMessageFiles(message: Message) {
const input = new GPTTokens({ message.files && FileManager.deleteFiles(message.files.map((f) => f.id))
model: 'gpt-4o',
messages: [{ role: 'user', content: text }]
})
return input.usedTokens - 7
}
export function estimateHistoryTokenCount(assistant: Assistant, msgs: Message[]) {
const { contextCount } = getAssistantSettings(assistant)
const all = new GPTTokens({
model: 'gpt-4o',
messages: [
{ role: 'system', content: assistant.prompt },
...filterMessages(filterContextMessages(takeRight(msgs, contextCount))).map((message) => ({
role: message.role,
content: message.content
}))
]
})
return all.usedTokens - 7
} }

View File

@@ -0,0 +1,16 @@
import store from '@renderer/store'
import { Model } from '@renderer/types'
import { pick } from 'lodash'
export const getModelUniqId = (m?: Model) => {
return m?.id ? JSON.stringify(pick(m, ['id', 'provider'])) : ''
}
export const hasModel = (m?: Model) => {
const allModels = store
.getState()
.llm.providers.map((p) => p.models)
.flat()
return allModels.find((model) => model.id === m?.id)
}

View File

@@ -1,47 +1,27 @@
import { Topic } from '@renderer/types' import db from '@renderer/databases'
import { convertToBase64 } from '@renderer/utils' import { convertToBase64 } from '@renderer/utils'
import localforage from 'localforage'
const IMAGE_PREFIX = 'image://' const IMAGE_PREFIX = 'image://'
export default class LocalStorage { export default class ImageStorage {
static async getTopic(id: string) { static async set(key: string, file: File) {
return localforage.getItem<Topic>(`topic:${id}`) const id = IMAGE_PREFIX + key
}
static async getTopicMessages(id: string) {
const topic = await this.getTopic(id)
return topic ? topic.messages : []
}
static async removeTopic(id: string) {
localforage.removeItem(`topic:${id}`)
}
static async clearTopicMessages(id: string) {
const topic = await this.getTopic(id)
if (topic) {
topic.messages = []
await localforage.setItem(`topic:${id}`, topic)
}
}
static async storeImage(name: string, file: File) {
try { try {
const base64Image = await convertToBase64(file) const base64Image = await convertToBase64(file)
if (typeof base64Image === 'string') { if (typeof base64Image === 'string') {
await localforage.setItem(IMAGE_PREFIX + name, base64Image) if (await db.settings.get(id)) {
db.settings.update(id, { value: base64Image })
return
}
await db.settings.add({ id, value: base64Image })
} }
} catch (error) { } catch (error) {
console.error('Error storing the image', error) console.error('Error storing the image', error)
} }
} }
static async getImage(name: string) { static async get(key: string): Promise<string> {
return localforage.getItem<string>(IMAGE_PREFIX + name) const id = IMAGE_PREFIX + key
} return (await db.settings.get(id))?.value
static async removeImage(name: string) {
await localforage.removeItem(IMAGE_PREFIX + name)
} }
} }

View File

@@ -0,0 +1,130 @@
import { Assistant, FileType, FileTypes, Message } from '@renderer/types'
import { GPTTokens } from 'gpt-tokens'
import { flatten, takeRight } from 'lodash'
import { CompletionUsage } from 'openai/resources'
import { getAssistantSettings } from './assistant'
import { filterContextMessages, filterMessages } from './messages'
interface MessageItem {
name?: string
role: 'system' | 'user' | 'assistant'
content: string
}
async function getFileContent(file: FileType) {
if (!file) {
return ''
}
const fileId = file.id + file.ext
if (file.type === FileTypes.IMAGE) {
const data = await window.api.file.base64Image(fileId)
return data.data
}
if (file.type === FileTypes.TEXT) {
return await window.api.file.read(fileId)
}
return ''
}
async function getMessageParam(message: Message): Promise<MessageItem[]> {
const param: MessageItem[] = []
param.push({
role: message.role,
content: message.content
})
if (message.files) {
for (const file of message.files) {
param.push({
role: 'assistant',
content: await getFileContent(file)
})
}
}
return param
}
export function estimateTextTokens(text: string) {
const { usedTokens } = new GPTTokens({
model: 'gpt-4o',
messages: [{ role: 'user', content: text }]
})
return usedTokens - 7
}
export async function estimateMessageUsage(message: Message): Promise<CompletionUsage> {
const { usedTokens, promptUsedTokens, completionUsedTokens } = new GPTTokens({
model: 'gpt-4o',
messages: await getMessageParam(message)
})
const hasImage = message.files?.some((f) => f.type === FileTypes.IMAGE)
return {
prompt_tokens: promptUsedTokens,
completion_tokens: completionUsedTokens,
total_tokens: hasImage ? Math.floor(usedTokens / 80) : usedTokens - 7
}
}
export async function estimateMessagesUsage({
assistant,
messages
}: {
assistant: Assistant
messages: Message[]
}): Promise<CompletionUsage> {
const outputMessage = messages.pop()!
const prompt_tokens = await estimateHistoryTokens(assistant, messages)
const { completion_tokens } = await estimateMessageUsage(outputMessage)
return {
prompt_tokens: await estimateHistoryTokens(assistant, messages),
completion_tokens,
total_tokens: prompt_tokens + completion_tokens
} as CompletionUsage
}
export async function estimateHistoryTokens(assistant: Assistant, msgs: Message[]) {
const { contextCount } = getAssistantSettings(assistant)
const messages = filterMessages(filterContextMessages(takeRight(msgs, contextCount)))
// 有 usage 数据的消息,快速计算总数
const uasageTokens = messages
.filter((m) => m.usage)
.reduce((acc, message) => {
const inputTokens = message.usage?.total_tokens ?? 0
const outputTokens = message.usage!.completion_tokens ?? 0
return acc + (message.role === 'user' ? inputTokens : outputTokens)
}, 0)
// 没有 usage 数据的消息,需要计算每条消息的 token
let allMessages: MessageItem[][] = []
for (const message of messages.filter((m) => !m.usage)) {
const items = await getMessageParam(message)
allMessages = allMessages.concat(items)
}
const { usedTokens } = new GPTTokens({
model: 'gpt-4o',
messages: [
{
role: 'system',
content: assistant.prompt
},
...flatten(allMessages)
]
})
return usedTokens - 7 + uasageTokens
}

View File

@@ -1,6 +1,6 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit' import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import { TopicManager } from '@renderer/hooks/useTopic'
import { getDefaultAssistant, getDefaultTopic } from '@renderer/services/assistant' import { getDefaultAssistant, getDefaultTopic } from '@renderer/services/assistant'
import LocalStorage from '@renderer/services/storage'
import { Assistant, AssistantSettings, Model, Topic } from '@renderer/types' import { Assistant, AssistantSettings, Model, Topic } from '@renderer/types'
import { uniqBy } from 'lodash' import { uniqBy } from 'lodash'
@@ -91,7 +91,7 @@ const assistantsSlice = createSlice({
removeAllTopics: (state, action: PayloadAction<{ assistantId: string }>) => { removeAllTopics: (state, action: PayloadAction<{ assistantId: string }>) => {
state.assistants = state.assistants.map((assistant) => { state.assistants = state.assistants.map((assistant) => {
if (assistant.id === action.payload.assistantId) { if (assistant.id === action.payload.assistantId) {
assistant.topics.forEach((topic) => LocalStorage.removeTopic(topic.id)) assistant.topics.forEach((topic) => TopicManager.removeTopic(topic.id))
return { return {
...assistant, ...assistant,
topics: [getDefaultTopic()] topics: [getDefaultTopic()]

View File

@@ -22,7 +22,7 @@ const persistedReducer = persistReducer(
{ {
key: 'cherry-studio', key: 'cherry-studio',
storage, storage,
version: 24, version: 25,
blacklist: ['runtime'], blacklist: ['runtime'],
migrate migrate
}, },

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