Compare commits

...

97 Commits

Author SHA1 Message Date
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
kangfenmao
885c578582 chore(version): 0.6.8 2024-09-06 15:54:44 +08:00
kangfenmao
e61e4b109a refactor: Remove unused CSS classes and optimize conditional styling
- Removed unused CSS classes and optimized code for conditional styling.
2024-09-06 15:53:58 +08:00
kangfenmao
f3bafbeb52 feat: Update UI components and styling for consistency and readability.
- Updated icon font asset reference URL to reflect a new timestamp.
- Updated icon-fonts file asset.
- Updated markdown styling to adjust margins and padding of pre-formatted text elements.
- Added Windows-specific styling to the Inputbar component.
- Improved the rendering of code blocks with a focus on readability and theming consistency.
- Added new 'plain' attribute to Divider component for 'clear' message type.
- Minor adjustments made to the navigation bar styles and layout.
2024-09-06 15:41:46 +08:00
kangfenmao
e55c0cdcef feat: Update context count logic
- Updated logic for determining context count based on clear messages.
2024-09-06 14:17:22 +08:00
kangfenmao
e73bbf4d6a style: Update toolbar button hover and active states
- Updated styles and icons for hover and active states of toolbar buttons.
2024-09-06 14:12:01 +08:00
kangfenmao
3859289218 style: Update styling and input bar characters.
- Updated styling and characters added to input bar.
2024-09-06 14:07:45 +08:00
kangfenmao
591bb45a4e feat: Improved chat UI with context handling and filtering #43
- Updated default context count from 5 to 6.
- Updated string translations for multiple languages.
- Added functionality to handle new context and update context count in Inputbar component.
- Added support for displaying new chat context divider for 'clear' type messages.
- Added functionality to emit estimated token count with context count when the estimated token count event is triggered.
- Improved filtering and processing of user messages for the AnthropicProvider class.
- Updated message filtering logic with context consideration.
- Improved filtering of user messages to include only context-relevant messages.
- Updated logic to pass messages directly to AI.completions and AI.suggestions API requests instead of filtered messages.
- Added new event names for handling topic sidebar and context switching.
- Improved handling of message filtering and context counting.
- Added new valid value 'clear' to type option in Message type.
2024-09-06 13:54:48 +08:00
kangfenmao
b31f518fca fix: Handle Enter key press event in input field
- Updated handling for Enter key press event in input field to match shortcut settings.
2024-09-06 11:34:55 +08:00
kangfenmao
dfbdb989db feat: Update icon font and navigation buttons
- Updated icon font references and added new icon font glyphs.
- Updated icon font file for improved rendering.
- Updated icon font sizes and hover animations for navigation buttons.
- Removed border styles from styled Container component.
- Removed unused import and updated icon for '/settings/model' menu item.
2024-09-06 10:00:18 +08:00
kangfenmao
f194ebbc20 chore(version): 0.6.7 2024-09-05 23:53:47 +08:00
kangfenmao
ab0e7e1e07 feat: change topics position 2024-09-05 23:53:47 +08:00
kangfenmao
d809f50c0e feat: Update Content-Security-Policy to allow file: frame-src #38
- Updated Content-Security-Policy directive to allow frame-src from file: in the HTML document.
2024-09-05 17:19:17 +08:00
kangfenmao
a48d24de26 refactor: renamed and refactored topic properties and added date-time tracking
- Renamed localforage topic item property from topic object to id.
- Added date-time tracking for assistant topics.
- Incremented the store version to 24.
- Refactored migrate function to add support for local storage and update topics timestamps.
- Added createdAt and updatedAt properties to Topic type.
2024-09-05 16:15:48 +08:00
kangfenmao
0dacc20e74 docs(DragableList): improve types and props documentation for DragDropContext responders 2024-09-05 15:30:26 +08:00
kangfenmao
08df6cb4f8 feat: highlight acitve topic icon 2024-09-05 14:36:19 +08:00
kangfenmao
0676ac8942 feat: quickly edit the asistant on edit title #42 2024-09-05 13:41:47 +08:00
kangfenmao
c257e8f0fe fix: anthropic first message must use the user role #39
{"type":"error","error":{"type":"invalid_request_error","message":"messages: first message must use the "user" role"}}
2024-09-05 13:35:16 +08:00
kangfenmao
521670f683 fix: assistant and topic list style 2024-09-05 00:04:35 +08:00
kangfenmao
87216b5d91 chore(version): 0.6.6 2024-09-04 22:33:15 +08:00
kangfenmao
e6122a3d36 fix: left sidebar icon 2024-09-04 22:31:39 +08:00
kangfenmao
e6e1502308 feat: remove hashtag title 2024-09-04 21:57:23 +08:00
kangfenmao
7f5be3a688 chore(version): 0.6.5 2024-09-04 21:29:56 +08:00
kangfenmao
4dde49a9f0 feat: new chat style 2024-09-04 21:29:16 +08:00
kangfenmao
ce830b692b revert: fold topics 2024-09-04 15:37:39 +08:00
kangfenmao
563472f3a9 wip 2024-09-04 13:26:51 +08:00
kangfenmao
14acd45927 feat: transparent window settings 2024-09-04 11:23:45 +08:00
kangfenmao
9e2c7a08df feat: change assistant sidebar width 2024-09-03 23:37:40 +08:00
kangfenmao
f10c8dc379 chore(version): 0.6.4 2024-09-03 22:14:12 +08:00
kangfenmao
fdd815879a feat: double click to change assistat view 2024-09-03 22:13:25 +08:00
kangfenmao
635f238576 chore(version): 0.6.3 2024-09-03 20:50:46 +08:00
kangfenmao
615e337e3f fix: assistant nav style 2024-09-03 20:50:37 +08:00
kangfenmao
acd5d4b192 feat: change default avatar 2024-09-03 20:39:27 +08:00
kangfenmao
9a41b697c6 fix: inputbar height 2024-09-03 20:11:25 +08:00
kangfenmao
5cb67e00a6 feat: change default provider 2024-09-03 20:11:20 +08:00
kangfenmao
350f13e97c fix: backup and restore i18n 2024-09-03 19:30:21 +08:00
kangfenmao
4d6cbf5073 refactor: provider sdk 2024-09-03 19:00:24 +08:00
kangfenmao
8d7b10d21e refactor: remove modal enabled key 2024-09-03 13:17:55 +08:00
kangfenmao
6753a93c0d fix: use webview replace iframe 2024-09-03 13:17:38 +08:00
kangfenmao
9ee763337d refactor: remove models config enabled 2024-09-03 11:40:46 +08:00
kangfenmao
ace0cb7823 feat: merge assistant and topics 2024-09-03 11:36:57 +08:00
kangfenmao
44e518ef03 refactor: assistant drap and drop 2024-09-02 20:48:31 +08:00
kangfenmao
e28b96b45e feat: expand inputbar height 2024-09-02 15:38:48 +08:00
kangfenmao
11427a980c feat: auto change inputbar height 2024-09-02 14:09:03 +08:00
kangfenmao
cb95562e58 feat: add attachment button 2024-09-01 23:22:21 +08:00
kangfenmao
89bdab58f7 feat: hide entry for local ai 2024-08-28 18:11:35 +08:00
kangfenmao
d42ee59335 fix: https://github.com/electron/notarize/issues/193 2024-08-27 19:42:39 +08:00
kangfenmao
88e7ab211d fix: electron-builder files path 2024-08-27 19:42:32 +08:00
kangfenmao
5347bdfa83 refactor: change env file path 2024-08-27 11:58:19 +08:00
kangfenmao
c8711c5804 feat: add local module 2024-08-27 11:31:05 +08:00
kangfenmao
24cf3bb043 chore(version): 0.6.2 2024-08-26 18:30:05 +08:00
kangfenmao
0531ecf3cf fix: electron builder ignore files 2024-08-26 18:19:01 +08:00
kangfenmao
0cbfd26883 build: remove sentry 2024-08-26 18:06:07 +08:00
kangfenmao
ee398489de build: remove electron-devtools-installer 2024-08-26 18:02:20 +08:00
kangfenmao
71d7c2c738 fix: workspace config 2024-08-26 17:49:19 +08:00
kangfenmao
b98f7298a2 build: add yarn workspace config 2024-08-25 22:12:31 +08:00
kangfenmao
de4f2599be refactor: remove unnecessary logs 2024-08-25 21:37:13 +08:00
kangfenmao
93b32e8e21 feat: update user data path 2024-08-25 18:39:53 +08:00
kangfenmao
e353d0f8ee fix: default assistant name 2024-08-23 21:41:16 +08:00
kangfenmao
dfd42fe9a6 feat: add devv referral code 2024-08-23 20:57:54 +08:00
111 changed files with 2949 additions and 2750 deletions

View File

@@ -6,4 +6,4 @@ indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
trim_trailing_whitespace = true

3
.gitignore vendored
View File

@@ -45,3 +45,6 @@ out
# ENV
.env
.env.*
# Local
local

View File

@@ -0,0 +1,53 @@
diff --git a/lib/check-signature.js b/lib/check-signature.js
index 324568af71bcc4372c9f959131ecd24122848c86..677348e0a138ff608b2ac41f592d813b15ee4956 100644
--- a/lib/check-signature.js
+++ b/lib/check-signature.js
@@ -41,16 +41,12 @@ const spawn_1 = require("./spawn");
const debug_1 = __importDefault(require("debug"));
const d = (0, debug_1.default)('electron-notarize');
const codesignDisplay = (opts) => __awaiter(void 0, void 0, void 0, function* () {
- const result = yield (0, spawn_1.spawn)('codesign', ['-dv', '-vvvv', '--deep', path.basename(opts.appPath)], {
- cwd: path.dirname(opts.appPath),
- });
+ const result = yield (0, spawn_1.spawn)('codesign', ['-dv', '-vvvv', '--deep', opts.appPath]);
return result;
});
const codesign = (opts) => __awaiter(void 0, void 0, void 0, function* () {
d('attempting to check codesign of app:', opts.appPath);
- const result = yield (0, spawn_1.spawn)('codesign', ['-vvv', '--deep', '--strict', path.basename(opts.appPath)], {
- cwd: path.dirname(opts.appPath),
- });
+ const result = yield (0, spawn_1.spawn)('codesign', ['-vvv', '--deep', '--strict', opts.appPath]);
return result;
});
function checkSignatures(opts) {
diff --git a/lib/notarytool.js b/lib/notarytool.js
index 1ab090efb2101fc8bee5553445e0349c54474421..a5ddfd922197449fc56078e4a7e9a2ee5d8d207d 100644
--- a/lib/notarytool.js
+++ b/lib/notarytool.js
@@ -92,9 +92,7 @@ function notarizeAndWaitForNotaryTool(opts) {
else {
filePath = path.resolve(dir, `${path.parse(opts.appPath).name}.zip`);
d('zipping application to:', filePath);
- const zipResult = yield (0, spawn_1.spawn)('ditto', ['-c', '-k', '--sequesterRsrc', '--keepParent', path.basename(opts.appPath), filePath], {
- cwd: path.dirname(opts.appPath),
- });
+ const zipResult = yield (0, spawn_1.spawn)('ditto', ['-c', '-k', '--sequesterRsrc', '--keepParent', opts.appPath, filePath]);
if (zipResult.code !== 0) {
throw new Error(`Failed to zip application, exited with code: ${zipResult.code}\n\n${zipResult.output}`);
}
diff --git a/lib/staple.js b/lib/staple.js
index 47dbd85b2fc279d999b57f47fb8171e1cc674436..f8829e6ac54fcd630a730d12d75acc1591b953b6 100644
--- a/lib/staple.js
+++ b/lib/staple.js
@@ -43,9 +43,7 @@ const d = (0, debug_1.default)('electron-notarize:staple');
function stapleApp(opts) {
return __awaiter(this, void 0, void 0, function* () {
d('attempting to staple app:', opts.appPath);
- const result = yield (0, spawn_1.spawn)('xcrun', ['stapler', 'staple', '-v', path.basename(opts.appPath)], {
- cwd: path.dirname(opts.appPath),
- });
+ const result = yield (0, spawn_1.spawn)('xcrun', ['stapler', 'staple', '-v', opts.appPath]);
if (result.code !== 0) {
throw new Error(`Failed to staple your application with code: ${result.code}\n\n${result.output}`);
}

View File

@@ -1,2 +1,5 @@
nodeLinker: node-modules
enableImmutableInstalls: false
httpTimeout: 300000
nodeLinker: node-modules

View File

@@ -3,12 +3,13 @@ productName: Cherry Studio
directories:
buildResources: build
files:
- '!**/.vscode/*'
- '!src/*'
- '!{.vscode,.yarn,.github}'
- '!electron.vite.config.{js,ts,mjs,cjs}'
- '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
- '!src/*'
- '!local'
asarUnpack:
- resources/**
win:
@@ -56,5 +57,14 @@ electronDownload:
afterSign: scripts/notarize.js
releaseInfo:
releaseNotes: |
增加应用备份和恢复功能
增加更多AI小程序
本次更新:
支持行内公式
支持编辑所有集成的服务商API地址
新增智能体搜索功能(>10个)
修复正则表达式显示错误
修复默认模型参数不生效
修复暗黑模式下分界线不明显问题
近期更新:
智能助理和消息列表合并
优化输入框样式
提升小程序稳定性

View File

@@ -4,7 +4,12 @@ import { resolve } from 'path'
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()]
plugins: [externalizeDepsPlugin()],
resolve: {
alias: {
ollama: resolve('ollama/src')
}
}
},
preload: {
plugins: [externalizeDepsPlugin()]

View File

@@ -1,10 +1,16 @@
{
"name": "cherry-studio",
"version": "0.6.1",
"name": "CherryStudio",
"version": "0.6.13",
"private": true,
"description": "A powerful AI assistant for producer.",
"main": "./out/main/index.js",
"author": "kangfenmao@qq.com",
"homepage": "https://github.com/kangfenmao/cherry-studio",
"workspaces": {
"packages": [
"local"
]
},
"scripts": {
"format": "prettier --write .",
"lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
@@ -25,12 +31,10 @@
"dependencies": {
"@electron-toolkit/preload": "^3.0.0",
"@electron-toolkit/utils": "^3.0.0",
"@sentry/electron": "^5.2.0",
"electron-log": "^5.1.5",
"electron-store": "^8.2.0",
"electron-updater": "^6.1.7",
"electron-window-state": "^5.0.3",
"eslint-plugin-simple-import-sort": "^12.1.1"
"electron-window-state": "^5.0.3"
},
"devDependencies": {
"@anthropic-ai/sdk": "^0.24.3",
@@ -51,15 +55,15 @@
"browser-image-compression": "^2.0.2",
"dayjs": "^1.11.11",
"dotenv-cli": "^7.4.2",
"electron": "^28.2.0",
"electron": "^28.3.3",
"electron-builder": "^24.9.1",
"electron-devtools-installer": "^3.2.0",
"electron-vite": "^2.0.0",
"emittery": "^1.0.3",
"emoji-picker-element": "^1.22.1",
"eslint": "^8.56.0",
"eslint-plugin-react": "^7.34.3",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-unused-imports": "^4.0.0",
"gpt-tokens": "^1.3.6",
"i18next": "^23.11.5",
@@ -76,6 +80,7 @@
"react-router-dom": "6",
"react-spinners": "^0.14.1",
"react-syntax-highlighter": "^15.5.0",
"redux": "^5.0.1",
"redux-persist": "^6.0.0",
"rehype-katex": "^7.0.0",
"remark-gfm": "^4.0.0",
@@ -91,7 +96,8 @@
"react-dom": "^17.0.0 || ^18.0.0"
},
"resolutions": {
"@electron/notarize": "2.3.2"
"@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"
},
"packageManager": "yarn@4.3.1"
}

View File

@@ -1,15 +1,16 @@
import { electronApp, optimizer } from '@electron-toolkit/utils'
import * as Sentry from '@sentry/electron/main'
import { app, BrowserWindow } from 'electron'
import installExtension, { REDUX_DEVTOOLS } from 'electron-devtools-installer'
import { registerIpc } from './ipc'
import { updateUserDataPath } from './utils/upgrade'
import { createMainWindow } from './window'
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.whenReady().then(() => {
app.whenReady().then(async () => {
await updateUserDataPath()
// Set app user model id for windows
electronApp.setAppUserModelId('com.kangfenmao.CherryStudio')
@@ -29,8 +30,6 @@ app.whenReady().then(() => {
const mainWindow = createMainWindow()
registerIpc(mainWindow, app)
installExtension(REDUX_DEVTOOLS)
})
// Quit when all windows are closed, except on macOS. There, it's common
@@ -44,6 +43,3 @@ app.on('window-all-closed', () => {
// In this file you can include the rest of your app"s specific main process
// code. You can also put them in separate files and require them here.
Sentry.init({
dsn: 'https://f0e972deff79c2df3e887e232d8a46a3@o4507610668007424.ingest.us.sentry.io/4507610670563328'
})

View File

@@ -50,7 +50,6 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
// 触发检查更新(此方法用于被渲染线程调用,例如页面点击检查更新按钮来调用此方法)
ipcMain.handle('check-for-update', async () => {
autoUpdater.logger?.info('触发检查更新')
return {
currentVersion: autoUpdater.currentVersion,
update: await autoUpdater.checkForUpdates()

View File

@@ -17,11 +17,6 @@ export default class AppUpdater {
mainWindow.webContents.send('update-error', error)
})
// 检测是否需要更新
autoUpdater.on('checking-for-update', () => {
logger.info('正在检查更新……')
})
autoUpdater.on('update-available', (releaseInfo: UpdateInfo) => {
autoUpdater.logger?.info('检测到新版本,确认是否下载')
mainWindow.webContents.send('update-available', releaseInfo)
@@ -59,7 +54,6 @@ export default class AppUpdater {
// 检测到不需要更新时
autoUpdater.on('update-not-available', () => {
logger.info('现在使用的就是最新版本,不用更新')
mainWindow.webContents.send('update-not-available')
})

77
src/main/utils/upgrade.ts Normal file
View File

@@ -0,0 +1,77 @@
import { spawn } from 'child_process'
import { app, dialog } from 'electron'
import Logger from 'electron-log'
import fs from 'fs'
import path from 'path'
export async function updateUserDataPath() {
const currentPath = app.getPath('userData')
const oldPath = currentPath.replace('CherryStudio', 'cherry-studio')
if (currentPath !== oldPath && fs.existsSync(oldPath)) {
Logger.log('Update userData path')
try {
if (process.platform === 'win32') {
// Windows 系统:创建 bat 文件
const batPath = await createWindowsBatFile(oldPath, currentPath)
await promptRestartAndExecute(batPath)
} else {
// 其他系统:直接更新
fs.rmSync(currentPath, { recursive: true, force: true })
fs.renameSync(oldPath, currentPath)
Logger.log(`Directory renamed: ${currentPath}`)
await promptRestart()
}
} catch (error: any) {
Logger.error('Error updating userData path:', error)
dialog.showErrorBox('错误', `更新用户数据目录时发生错误: ${error.message}`)
}
} else {
Logger.log('userData path does not need to be updated')
}
}
async function createWindowsBatFile(oldPath: string, currentPath: string): Promise<string> {
const batPath = path.join(app.getPath('temp'), 'rename_userdata.bat')
const appPath = app.getPath('exe')
const batContent = `
@echo off
timeout /t 2 /nobreak
rmdir /s /q "${currentPath}"
rename "${oldPath}" "${path.basename(currentPath)}"
start "" "${appPath}"
del "%~f0"
`
fs.writeFileSync(batPath, batContent)
return batPath
}
async function promptRestartAndExecute(batPath: string) {
await dialog.showMessageBox({
type: 'info',
title: '应用需要重启',
message: '用户数据目录将在重启后更新。请重启应用以应用更改。',
buttons: ['手动重启']
})
// 执行 bat 文件
spawn('cmd.exe', ['/c', batPath], {
detached: true,
stdio: 'ignore'
})
app.exit(0)
}
async function promptRestart() {
await dialog.showMessageBox({
type: 'info',
title: '应用需要重启',
message: '用户数据目录已更新。请重启应用以应用更改。',
buttons: ['重启']
})
app.relaunch()
app.exit(0)
}

View File

@@ -1,6 +1,8 @@
import util from 'node:util'
import zlib from 'node:zlib'
import logger from 'electron-log'
// 将 zlib 的 gzip 和 gunzip 方法转换为 Promise 版本
const gzipPromise = util.promisify(zlib.gzip)
const gunzipPromise = util.promisify(zlib.gunzip)
@@ -16,7 +18,7 @@ export async function compress(str) {
const compressedBuffer = await gzipPromise(buffer)
return compressedBuffer
} catch (error) {
console.error('Compression failed:', error)
logger.error('Compression failed:', error)
throw error
}
}
@@ -31,7 +33,7 @@ export async function decompress(compressedBuffer) {
const buffer = await gunzipPromise(compressedBuffer)
return buffer.toString('utf-8')
} catch (error) {
console.error('Decompression failed:', error)
logger.error('Decompression failed:', error)
throw error
}
}

View File

@@ -34,7 +34,8 @@ export function createMainWindow() {
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
webSecurity: false
webSecurity: false,
webviewTag: true
// devTools: !app.isPackaged,
}
})

View File

@@ -2,11 +2,10 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>Cherry Studio</title>
<meta name="viewport" content="initial-scale=1, width=device-width" />
<meta
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: *; frame-src * file:" />
</head>
<body>
<div id="root"></div>

View File

@@ -5,13 +5,13 @@ import { PersistGate } from 'redux-persist/integration/react'
import Sidebar from './components/app/Sidebar'
import TopViewContainer from './components/TopView'
import AntdProvider from './context/AntdProvider'
import { ThemeProvider } from './context/ThemeProvider'
import AgentsPage from './pages/agents/AgentsPage'
import AppsPage from './pages/apps/AppsPage'
import HomePage from './pages/home/HomePage'
import SettingsPage from './pages/settings/SettingsPage'
import TranslatePage from './pages/translate/TranslatePage'
import AntdProvider from './providers/AntdProvider'
import { ThemeProvider } from './providers/ThemeProvider'
function App(): JSX.Element {
return (

View File

@@ -1,6 +1,6 @@
@font-face {
font-family: 'iconfont'; /* Project id 4563475 */
src: url('iconfont.woff2?t=1724204739157') format('woff2');
src: url('iconfont.woff2?t=1725606177995') format('woff2');
}
.iconfont {
@@ -11,6 +11,30 @@
-moz-osx-font-smoothing: grayscale;
}
.icon-a-darkmode:before {
content: '\e6cd';
}
.icon-ai-model:before {
content: '\e827';
}
.icon-ai-model1:before {
content: '\ec09';
}
.icon-gridlines:before {
content: '\e942';
}
.icon-grid-row-2copy:before {
content: '\e681';
}
.icon-inbox:before {
content: '\e869';
}
.icon-business-smart-assistant:before {
content: '\e601';
}
@@ -39,11 +63,11 @@
content: '\e758';
}
.icon-hidesidebarhoriz:before {
.icon-hide-sidebar:before {
content: '\e8eb';
}
.icon-showsidebarhoriz:before {
.icon-show-sidebar:before {
content: '\e944';
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 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

@@ -31,13 +31,13 @@
--color-text: var(--color-text-1);
--color-icon: #ffffff99;
--color-icon-white: #ffffff;
--color-border: #000;
--color-border: #ffffff20;
--color-border-soft: #ffffff20;
--color-error: #f44336;
--color-link: #1677ff;
--color-code-background: #323232;
--color-scrollbar-thumb: rgba(255, 255, 255, 0.15);
--color-scrollbar-thumb-hover: rgba(255, 255, 255, 0.3);
--color-scrollbar-thumb: rgba(255, 255, 255, 0.08);
--color-scrollbar-thumb-hover: rgba(255, 255, 255, 0.15);
--navbar-background-mac: rgba(30, 30, 30, 0.8);
--navbar-background: rgba(30, 30, 30);
@@ -48,9 +48,9 @@
--status-bar-height: 40px;
--input-bar-height: 85px;
--assistants-width: 245px;
--topic-list-width: 260px;
--settings-width: var(--assistants-width);
--assistants-width: 280px;
--topic-list-width: 280px;
--settings-width: 260px;
}
body[theme-mode='light'] {
@@ -86,8 +86,8 @@ body[theme-mode='light'] {
--color-error: #f44336;
--color-link: #1677ff;
--color-code-background: #e3e3e3;
--color-scrollbar-thumb: rgba(0, 0, 0, 0.15);
--color-scrollbar-thumb-hover: rgba(0, 0, 0, 0.3);
--color-scrollbar-thumb: rgba(0, 0, 0, 0.08);
--color-scrollbar-thumb-hover: rgba(0, 0, 0, 0.15);
--navbar-background-mac: rgba(255, 255, 255, 0.75);
--navbar-background: rgba(255, 255, 255);
@@ -204,3 +204,30 @@ body,
background-color: transparent !important;
}
}
.ant-drawer-header {
-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

@@ -72,6 +72,9 @@
li {
margin-bottom: 0.5em;
pre {
margin: 1.5em 0;
}
&::marker {
color: var(--color-text-3);
}
@@ -98,7 +101,8 @@
font-family: 'Courier New', Courier, monospace;
}
p code {
p code,
li code {
background: var(--color-background-mute);
padding: 3px 5px;
border-radius: 5px;
@@ -106,17 +110,23 @@
pre {
white-space: pre-wrap !important;
padding: 1em 0;
border-radius: 5px;
overflow-x: auto;
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 {
margin: 0 !important;
}
code {
background: none;
padding: 0;
border-radius: 0;
code {
background: none;
padding: 0;
border-radius: 0;
}
}
}

View File

@@ -1,7 +1,7 @@
/* 全局初始化滚动条样式 */
::-webkit-scrollbar {
width: 3px;
height: 3px;
width: 2px;
height: 2px;
}
::-webkit-scrollbar-track {

View File

@@ -1,18 +1,39 @@
import { DragDropContext, Draggable, Droppable, DropResult } from '@hello-pangea/dnd'
import {
DragDropContext,
Draggable,
Droppable,
DroppableProps,
DropResult,
OnDragEndResponder,
OnDragStartResponder,
ResponderProvided
} from '@hello-pangea/dnd'
import { droppableReorder } from '@renderer/utils'
import { FC } from 'react'
interface Props<T> {
list: T[]
style?: React.CSSProperties
listStyle?: React.CSSProperties
children: (item: T, index: number) => React.ReactNode
onUpdate: (list: T[]) => void
onDragStart?: () => void
onDragEnd?: () => void
onDragStart?: OnDragStartResponder
onDragEnd?: OnDragEndResponder
droppableProps?: Partial<DroppableProps>
}
const DragableList: FC<Props<any>> = ({ children, list, onDragStart, onUpdate, onDragEnd }) => {
const _onDragEnd = (result: DropResult) => {
onDragEnd?.()
const DragableList: FC<Props<any>> = ({
children,
list,
style,
listStyle,
droppableProps,
onDragStart,
onUpdate,
onDragEnd
}) => {
const _onDragEnd = (result: DropResult, provided: ResponderProvided) => {
onDragEnd?.(result, provided)
if (result.destination) {
const sourceIndex = result.source.index
const destIndex = result.destination.index
@@ -23,17 +44,17 @@ const DragableList: FC<Props<any>> = ({ children, list, onDragStart, onUpdate, o
return (
<DragDropContext onDragStart={onDragStart} onDragEnd={_onDragEnd}>
<Droppable droppableId="droppable">
<Droppable droppableId="droppable" {...droppableProps}>
{(provided) => (
<div {...provided.droppableProps} ref={provided.innerRef}>
<div {...provided.droppableProps} ref={provided.innerRef} style={{ ...style }}>
{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) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
style={{ ...provided.draggableProps.style, marginBottom: 8 }}>
style={{ ...provided.draggableProps.style, marginBottom: 8, ...listStyle }}>
{children(item, index)}
</div>
)}

View File

@@ -1,4 +1,4 @@
import { useTheme } from '@renderer/providers/ThemeProvider'
import { useTheme } from '@renderer/context/ThemeProvider'
import { FC, useEffect, useRef } from 'react'
interface Props {

View File

@@ -1,3 +1,4 @@
/* eslint-disable react/no-unknown-property */
import { CloseOutlined, ExportOutlined, ReloadOutlined } from '@ant-design/icons'
import { isMac, isWindows } from '@renderer/config/constant'
import { useBridge } from '@renderer/hooks/useBridge'
@@ -5,7 +6,8 @@ import store from '@renderer/store'
import { setMinappShow } from '@renderer/store/runtime'
import { MinAppType } from '@renderer/types'
import { Drawer } from 'antd'
import { useRef, useState } from 'react'
import { WebviewTag } from 'electron'
import { useEffect, useRef, useState } from 'react'
import styled from 'styled-components'
import { TopView } from '../TopView'
@@ -17,7 +19,7 @@ interface Props {
const PopupContainer: React.FC<Props> = ({ app, resolve }) => {
const [open, setOpen] = useState(true)
const iframeRef = useRef<HTMLIFrameElement>(null)
const webviewRef = useRef<WebviewTag | null>(null)
useBridge()
@@ -29,8 +31,8 @@ const PopupContainer: React.FC<Props> = ({ app, resolve }) => {
}
const onReload = () => {
if (iframeRef.current) {
iframeRef.current.src = app.url
if (webviewRef.current) {
webviewRef.current.src = app.url
}
}
@@ -59,6 +61,27 @@ const PopupContainer: React.FC<Props> = ({ app, resolve }) => {
)
}
useEffect(() => {
const webview = webviewRef.current
if (webview) {
const handleNewWindow = (event: any) => {
event.preventDefault()
if (webview.loadURL) {
webview.loadURL(event.url)
}
}
webview.addEventListener('new-window', handleNewWindow)
return () => {
webview.removeEventListener('new-window', handleNewWindow)
}
}
return () => {}
}, [])
return (
<Drawer
title={<Title />}
@@ -72,17 +95,17 @@ const PopupContainer: React.FC<Props> = ({ app, resolve }) => {
maskClosable={false}
closeIcon={null}
style={{ marginLeft: 'var(--sidebar-width)' }}>
<Frame src={app.url} ref={iframeRef} />
<webview src={app.url} ref={webviewRef} style={WebviewStyle} allowpopups={'true' as any} />
</Drawer>
)
}
const Frame = styled.iframe`
width: calc(100vw - var(--sidebar-width));
height: calc(100vh - var(--navbar-height));
border: none;
background-color: white;
`
const WebviewStyle: React.CSSProperties = {
width: 'calc(100vw - var(--sidebar-width))',
height: 'calc(100vh - var(--navbar-height))',
backgroundColor: 'white',
display: 'inline-flex'
}
const TitleContainer = styled.div`
display: flex;

View File

@@ -3,6 +3,7 @@ import systemAgents from '@renderer/config/agents.json'
import { useAgents } from '@renderer/hooks/useAgents'
import { useAssistants, useDefaultAssistant } from '@renderer/hooks/useAssistant'
import { covertAgentToAssistant } from '@renderer/services/assistant'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/event'
import { Agent, Assistant } from '@renderer/types'
import { Input, Modal, Tag } from 'antd'
import { useMemo, useState } from 'react'
@@ -25,25 +26,32 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
() => ({
id: defaultAssistant.id,
name: defaultAssistant.name,
emoji: '',
emoji: defaultAssistant.emoji || '',
prompt: defaultAssistant.prompt,
group: 'system'
}),
[defaultAssistant.id, defaultAssistant.name, defaultAssistant.prompt]
[defaultAssistant.emoji, defaultAssistant.id, defaultAssistant.name, defaultAssistant.prompt]
)
const agents = useMemo(() => {
const allAgents = [defaultAgent, ...userAgents, ...systemAgents] as Agent[]
const list = allAgents.filter((agent) => !assistants.map((a) => a.id).includes(agent.id))
const allAgents = [...userAgents, ...systemAgents] as Agent[]
const list = [defaultAgent, ...allAgents.filter((agent) => !assistants.map((a) => a.id).includes(agent.id))]
return searchText
? list.filter((agent) => agent.name.toLowerCase().includes(searchText.trim().toLocaleLowerCase()))
: list
}, [assistants, defaultAgent, searchText, userAgents])
const onCreateAssistant = (agent: Agent) => {
if (assistants.map((a) => a.id).includes(String(agent.id))) return
if (agent.id !== 'default') {
if (assistants.map((a) => a.id).includes(String(agent.id))) {
return
}
}
const assistant = covertAgentToAssistant(agent)
addAssistant(assistant)
setTimeout(() => EventEmitter.emit(EVENT_NAMES.SHOW_ASSISTANTS), 0)
resolve(assistant)
setOpen(false)
}
@@ -59,13 +67,13 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
return (
<Modal
style={{ marginTop: '5vh' }}
centered
title={t('chat.add.assistant.title')}
open={open}
onCancel={onCancel}
afterClose={onClose}
transitionName=""
maskTransitionName=""
transitionName="ant-move-down"
maskTransitionName="ant-fade"
footer={null}>
<Input
placeholder={t('common.search')}

View File

@@ -34,7 +34,15 @@ const AssistantSettingPopupContainer: React.FC<Props> = ({ assistant, resolve })
}
return (
<Modal title={assistant.name} open={open} onOk={onOk} onCancel={handleCancel} afterClose={onClose}>
<Modal
title={assistant.name}
open={open}
onOk={onOk}
onCancel={handleCancel}
afterClose={onClose}
transitionName="ant-move-down"
maskTransitionName="ant-fade"
centered>
<Box mb={8}>{t('common.name')}</Box>
<Input
placeholder={t('common.assistant') + t('common.name')}
@@ -45,7 +53,7 @@ const AssistantSettingPopupContainer: React.FC<Props> = ({ assistant, resolve })
{t('common.prompt')}
</Box>
<TextArea
rows={4}
rows={10}
placeholder={t('common.assistant') + t('common.prompt')}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}

View File

@@ -1,15 +1,18 @@
import { isMac } from '@renderer/config/constant'
import { useSettings } from '@renderer/hooks/useSettings'
import { useRuntime } from '@renderer/hooks/useStore'
import { FC, PropsWithChildren } from 'react'
import styled from 'styled-components'
type Props = PropsWithChildren & JSX.IntrinsicElements['div']
const navbarBackgroundColor = isMac ? 'var(--navbar-background-mac)' : 'var(--navbar-background)'
export const Navbar: FC<Props> = ({ children, ...props }) => {
const { minappShow } = useRuntime()
const backgroundColor = minappShow ? 'var(--navbar-background)' : navbarBackgroundColor
const { windowStyle } = useSettings()
const macTransparentWindow = isMac && windowStyle === 'transparent'
const navbarBgColor = macTransparentWindow ? 'var(--navbar-background-mac)' : 'var(--navbar-background)'
const backgroundColor = minappShow ? 'var(--navbar-background)' : navbarBgColor
return (
<NavbarContainer {...props} style={{ backgroundColor }}>
@@ -39,7 +42,6 @@ const NavbarContainer = styled.div`
margin-left: ${isMac ? 'calc(var(--sidebar-width) * -1)' : 0};
padding-left: ${isMac ? 'var(--sidebar-width)' : 0};
border-bottom: 0.5px solid var(--color-border);
background-color: ${navbarBackgroundColor};
transition: background-color 0.3s ease;
-webkit-app-region: drag;
`
@@ -64,7 +66,7 @@ const NavbarCenterContainer = styled.div`
`
const NavbarRightContainer = styled.div`
min-width: var(--settings-width);
min-width: var(--topic-list-width);
display: flex;
align-items: center;
padding: 0 12px;

View File

@@ -1,49 +1,67 @@
import { TranslationOutlined } from '@ant-design/icons'
import Logo from '@renderer/assets/images/logo.png'
import { isMac } from '@renderer/config/constant'
import { isLocalAi, UserAvatar } from '@renderer/config/env'
import useAvatar from '@renderer/hooks/useAvatar'
import { useRuntime } from '@renderer/hooks/useStore'
import { useSettings } from '@renderer/hooks/useSettings'
import { useRuntime, useShowAssistants } from '@renderer/hooks/useStore'
import { Avatar } from 'antd'
import { FC } from 'react'
import { Link, useLocation } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useLocation, useNavigate } from 'react-router-dom'
import styled from 'styled-components'
import UserPopup from '../Popups/UserPopup'
const sidebarBackgroundColor = isMac ? 'var(--navbar-background-mac)' : 'var(--navbar-background)'
const Sidebar: FC = () => {
const { pathname } = useLocation()
const avatar = useAvatar()
const { minappShow } = useRuntime()
const { toggleShowAssistants } = useShowAssistants()
const { generating } = useRuntime()
const { t } = useTranslation()
const navigate = useNavigate()
const { windowStyle } = useSettings()
const isRoute = (path: string): string => (pathname === path ? 'active' : '')
const onEditUser = () => {
UserPopup.show()
const onEditUser = () => UserPopup.show()
const macTransparentWindow = isMac && windowStyle === 'transparent'
const sidebarBgColor = macTransparentWindow ? 'var(--navbar-background-mac)' : 'var(--navbar-background)'
const to = (path: string) => {
if (generating) {
window.message.warning({ content: t('message.switch.disabled'), key: 'switch-assistant' })
return
}
navigate(path)
}
const onToggleShowAssistants = () => {
pathname === '/' ? toggleShowAssistants() : navigate('/')
}
return (
<Container style={{ backgroundColor: minappShow ? 'var(--navbar-background)' : sidebarBackgroundColor }}>
<AvatarImg src={avatar || Logo} draggable={false} className="nodrag" onClick={onEditUser} />
<Container style={{ backgroundColor: minappShow ? 'var(--navbar-background)' : sidebarBgColor }}>
<AvatarImg src={avatar || UserAvatar} draggable={false} className="nodrag" onClick={onEditUser} />
<MainMenus>
<Menus>
<StyledLink to="/">
<StyledLink onClick={onToggleShowAssistants}>
<Icon className={isRoute('/')}>
<i className="iconfont icon-chat"></i>
</Icon>
</StyledLink>
<StyledLink to="/agents">
<StyledLink onClick={() => to('/agents')}>
<Icon className={isRoute('/agents')}>
<i className="iconfont icon-business-smart-assistant"></i>
</Icon>
</StyledLink>
<StyledLink to="/translate">
<StyledLink onClick={() => to('/translate')}>
<Icon className={isRoute('/translate')}>
<TranslationOutlined />
</Icon>
</StyledLink>
<StyledLink to="/apps">
<StyledLink onClick={() => to('/apps')}>
<Icon className={isRoute('/apps')}>
<i className="iconfont icon-appstore"></i>
</Icon>
@@ -51,7 +69,7 @@ const Sidebar: FC = () => {
</Menus>
</MainMenus>
<Menus>
<StyledLink to="/settings/provider">
<StyledLink onClick={() => to(isLocalAi ? '/settings/assistant' : '/settings/provider')}>
<Icon className={pathname.startsWith('/settings') ? 'active' : ''}>
<i className="iconfont icon-setting"></i>
</Icon>
@@ -72,7 +90,6 @@ const Container = styled.div`
-webkit-app-region: drag !important;
border-right: 0.5px solid var(--color-border);
margin-top: ${isMac ? 'var(--navbar-height)' : 0};
background-color: ${sidebarBackgroundColor};
transition: background-color 0.3s ease;
`
@@ -133,7 +150,7 @@ const Icon = styled.div`
}
`
const StyledLink = styled(Link)`
const StyledLink = styled.div`
text-decoration: none;
-webkit-app-region: none;
&* {

View File

@@ -1,5 +1,5 @@
export const DEFAULT_TEMPERATURE = 0.7
export const DEFAULT_CONEXTCOUNT = 5
export const DEFAULT_CONEXTCOUNT = 6
export const DEFAULT_MAX_TOKENS = 4096
export const FONT_FAMILY =
"Ubuntu, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif"

View File

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

View File

@@ -0,0 +1,85 @@
import AiAssistantAppLogo from '@renderer/assets/images/apps/360-ai.png'
import AiSearchAppLogo from '@renderer/assets/images/apps/ai-search.png'
import BaiduAiAppLogo from '@renderer/assets/images/apps/baidu-ai.png'
import DevvAppLogo from '@renderer/assets/images/apps/devv.png'
import MetasoAppLogo from '@renderer/assets/images/apps/metaso.webp'
import PerplexityAppLogo from '@renderer/assets/images/apps/perplexity.webp'
import SensetimeAppLogo from '@renderer/assets/images/apps/sensetime.png'
import SparkDeskAppLogo from '@renderer/assets/images/apps/sparkdesk.png'
import TiangongAiLogo from '@renderer/assets/images/apps/tiangong.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 { PROVIDER_CONFIG } from '@renderer/config/provider'
import { MinAppType } from '@renderer/types'
const _apps: MinAppType[] = [
{
name: 'AI 助手',
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搜索',
logo: AiSearchAppLogo,
url: 'https://so.360.com/'
},
{
name: '秘塔AI搜索',
logo: MetasoAppLogo,
url: 'https://metaso.cn/'
},
{
name: '天工AI',
logo: TiangongAiLogo,
url: 'https://www.tiangong.cn/'
},
{
name: 'DEVV_',
logo: DevvAppLogo,
url: 'https://devv.ai/'
},
{
name: 'perplexity',
logo: PerplexityAppLogo,
url: 'https://www.perplexity.ai/'
},
{
name: '知乎直答',
logo: ZhihuAppLogo,
url: 'https://zhida.zhihu.com/'
}
]
export function getAllMinApps() {
const list: MinAppType[] = (Object.entries(PROVIDER_CONFIG) as any[])
.filter(([, config]) => config.app)
.map(([key, config]) => ({ id: key, ...config.app }))
.concat(_apps)
return list
}
export function startMinAppById(id: string) {
const app = getAllMinApps().find((app) => app?.id === id)
app && MinApp.start(app)
}

View File

@@ -1,36 +1,66 @@
import { Model } from '@renderer/types'
type SystemModel = Model & { enabled: boolean }
const TEXT_TO_IMAGE_REGEX = /flux|diffusion|stabilityai|sd-turbo|dall|cogview/i
const EMBEDDING_REGEX = /embedding/i
export const SYSTEM_MODELS: Record<string, SystemModel[]> = {
export const SYSTEM_MODELS: Record<string, Model[]> = {
ollama: [],
silicon: [
{
id: 'Qwen/Qwen2-7B-Instruct',
provider: 'silicon',
name: 'Qwen2-7B-Instruct',
group: 'Qwen2'
},
{
id: 'Qwen/Qwen2-72B-Instruct',
provider: 'silicon',
name: 'Qwen2-72B-Instruct',
group: 'Qwen2'
},
{
id: 'THUDM/glm-4-9b-chat',
provider: 'silicon',
name: 'GLM-4-9B-Chat',
group: 'GLM'
},
{
id: 'deepseek-ai/DeepSeek-V2-Chat',
provider: 'silicon',
name: 'DeepSeek-V2-Chat',
group: 'DeepSeek'
},
{
id: 'deepseek-ai/DeepSeek-Coder-V2-Instruct',
provider: 'silicon',
name: 'DeepSeek-Coder-V2-Instruct',
group: 'DeepSeek'
}
],
openai: [
{
id: 'gpt-4o',
provider: 'openai',
name: ' GPT-4o',
group: 'GPT 4o',
enabled: true
group: 'GPT 4o'
},
{
id: 'gpt-4o-mini',
provider: 'openai',
name: ' GPT-4o-mini',
group: 'GPT 4o',
enabled: true
group: 'GPT 4o'
},
{
id: 'gpt-4-turbo',
provider: 'openai',
name: ' GPT-4 Turbo',
group: 'GPT 4',
enabled: true
group: 'GPT 4'
},
{
id: 'gpt-4',
provider: 'openai',
name: ' GPT-4',
group: 'GPT 4',
enabled: true
group: 'GPT 4'
}
],
gemini: [
@@ -38,129 +68,39 @@ export const SYSTEM_MODELS: Record<string, SystemModel[]> = {
id: 'gemini-1.5-flash',
provider: 'gemini',
name: 'Gemini 1.5 Flash',
group: 'Gemini 1.5',
enabled: true
group: 'Gemini 1.5'
},
{
id: 'gemini-1.5-pro-exp-0801',
provider: 'gemini',
name: 'Gemini 1.5 Pro Experimental 0801',
group: 'Gemini 1.5',
enabled: true
group: 'Gemini 1.5'
}
],
silicon: [
anthropic: [
{
id: 'Qwen/Qwen2-7B-Instruct',
provider: 'silicon',
name: 'Qwen2-7B-Instruct',
group: 'Qwen2',
enabled: true
id: 'claude-3-5-sonnet-20240620',
provider: 'anthropic',
name: 'Claude 3.5 Sonnet',
group: 'Claude 3.5'
},
{
id: 'Qwen/Qwen2-1.5B-Instruct',
provider: 'silicon',
name: 'Qwen2-1.5B-Instruct',
group: 'Qwen2',
enabled: false
id: 'claude-3-opus-20240229',
provider: 'anthropic',
name: 'Claude 3 Opus',
group: 'Claude 3'
},
{
id: 'Qwen/Qwen1.5-7B-Chat',
provider: 'silicon',
name: 'Qwen1.5-7B-Chat',
group: 'Qwen1.5',
enabled: false
id: 'claude-3-sonnet-20240229',
provider: 'anthropic',
name: 'Claude 3 Sonnet',
group: 'Claude 3'
},
{
id: 'Qwen/Qwen2-72B-Instruct',
provider: 'silicon',
name: 'Qwen2-72B-Instruct',
group: 'Qwen2',
enabled: true
},
{
id: 'Qwen/Qwen2-57B-A14B-Instruct',
provider: 'silicon',
name: 'Qwen2-57B-A14B-Instruct',
group: 'Qwen2',
enabled: false
},
{
id: 'Qwen/Qwen1.5-110B-Chat',
provider: 'silicon',
name: 'Qwen1.5-110B-Chat',
group: 'Qwen1.5',
enabled: false
},
{
id: 'Qwen/Qwen1.5-32B-Chat',
provider: 'silicon',
name: 'Qwen1.5-32B-Chat',
group: 'Qwen1.5',
enabled: false
},
{
id: 'Qwen/Qwen1.5-14B-Chat',
provider: 'silicon',
name: 'Qwen1.5-14B-Chat',
group: 'Qwen1.5',
enabled: false
},
{
id: 'deepseek-ai/DeepSeek-V2-Chat',
provider: 'silicon',
name: 'DeepSeek-V2-Chat',
group: 'DeepSeek',
enabled: false
},
{
id: 'deepseek-ai/DeepSeek-Coder-V2-Instruct',
provider: 'silicon',
name: 'DeepSeek-Coder-V2-Instruct',
group: 'DeepSeek',
enabled: false
},
{
id: 'deepseek-ai/deepseek-llm-67b-chat',
provider: 'silicon',
name: 'Deepseek-LLM-67B-Chat',
group: 'DeepSeek',
enabled: false
},
{
id: 'THUDM/glm-4-9b-chat',
provider: 'silicon',
name: 'GLM-4-9B-Chat',
group: 'GLM',
enabled: true
},
{
id: 'THUDM/chatglm3-6b',
provider: 'silicon',
name: 'GhatGLM3-6B',
group: 'GLM',
enabled: false
},
{
id: '01-ai/Yi-1.5-9B-Chat-16K',
provider: 'silicon',
name: 'Yi-1.5-9B-Chat-16K',
group: 'Yi',
enabled: false
},
{
id: '01-ai/Yi-1.5-6B-Chat',
provider: 'silicon',
name: 'Yi-1.5-6B-Chat',
group: 'Yi',
enabled: false
},
{
id: '01-ai/Yi-1.5-34B-Chat-16K',
provider: 'silicon',
name: 'Yi-1.5-34B-Chat-16K',
group: 'Yi',
enabled: false
id: 'claude-3-haiku-20240307',
provider: 'anthropic',
name: 'Claude 3 Haiku',
group: 'Claude 3'
}
],
deepseek: [
@@ -168,15 +108,21 @@ export const SYSTEM_MODELS: Record<string, SystemModel[]> = {
id: 'deepseek-chat',
provider: 'deepseek',
name: 'DeepSeek Chat',
group: 'DeepSeek Chat',
enabled: true
group: 'DeepSeek Chat'
},
{
id: 'deepseek-coder',
provider: 'deepseek',
name: 'DeepSeek Coder',
group: 'DeepSeek Coder',
enabled: true
group: 'DeepSeek Coder'
}
],
github: [
{
id: 'gpt-4o',
provider: 'github',
name: 'OpenAI GPT-4o',
group: 'OpenAI'
}
],
yi: [
@@ -184,87 +130,87 @@ export const SYSTEM_MODELS: Record<string, SystemModel[]> = {
id: 'yi-large',
provider: 'yi',
name: 'Yi-Large',
group: 'Yi',
enabled: false
group: 'Yi'
},
{
id: 'yi-large-turbo',
provider: 'yi',
name: 'Yi-Large-Turbo',
group: 'Yi',
enabled: true
group: 'Yi'
},
{
id: 'yi-large-rag',
provider: 'yi',
name: 'Yi-Large-Rag',
group: 'Yi',
enabled: false
group: 'Yi'
},
{
id: 'yi-medium',
provider: 'yi',
name: 'Yi-Medium',
group: 'Yi',
enabled: true
group: 'Yi'
},
{
id: 'yi-medium-200k',
provider: 'yi',
name: 'Yi-Medium-200k',
group: 'Yi',
enabled: false
group: 'Yi'
},
{
id: 'yi-spark',
provider: 'yi',
name: 'Yi-Spark',
group: 'Yi',
enabled: false
group: 'Yi'
}
],
zhipu: [
{
id: 'glm-4-0520',
provider: 'zhipu',
name: 'GLM-4-0520',
group: 'GLM',
enabled: true
},
{
id: 'glm-4',
provider: 'zhipu',
name: 'GLM-4',
group: 'GLM',
enabled: false
group: 'GLM-4'
},
{
id: 'glm-4-airx',
id: 'glm-4-plus',
provider: 'zhipu',
name: 'GLM-4-AirX',
group: 'GLM',
enabled: false
name: 'GLM-4-Plus',
group: 'GLM-4'
},
{
id: 'glm-4-air',
provider: 'zhipu',
name: 'GLM-4-Air',
group: 'GLM',
enabled: true
group: 'GLM-4'
},
{
id: 'glm-4-airx',
provider: 'zhipu',
name: 'GLM-4-AirX',
group: 'GLM-4'
},
{
id: 'glm-4-flash',
provider: 'zhipu',
name: 'GLM-4-Flash',
group: 'GLM-4'
},
{
id: 'glm-4v',
provider: 'zhipu',
name: 'GLM-4V',
group: 'GLM',
enabled: false
name: 'GLM 4V',
group: 'GLM-4v'
},
{
id: 'glm-4v-plus',
provider: 'zhipu',
name: 'GLM-4V-Plus',
group: 'GLM-4v'
},
{
id: 'glm-4-alltools',
provider: 'zhipu',
name: 'GLM-4-AllTools',
group: 'GLM',
enabled: false
group: 'GLM-4-AllTools'
}
],
moonshot: [
@@ -272,22 +218,19 @@ export const SYSTEM_MODELS: Record<string, SystemModel[]> = {
id: 'moonshot-v1-8k',
provider: 'moonshot',
name: 'Moonshot V1 8k',
group: 'Moonshot V1',
enabled: true
group: 'Moonshot V1'
},
{
id: 'moonshot-v1-32k',
provider: 'moonshot',
name: 'Moonshot V1 32k',
group: 'Moonshot V1',
enabled: true
group: 'Moonshot V1'
},
{
id: 'moonshot-v1-128k',
provider: 'moonshot',
name: 'Moonshot V1 128k',
group: 'Moonshot V1',
enabled: true
group: 'Moonshot V1'
}
],
baichuan: [
@@ -295,22 +238,19 @@ export const SYSTEM_MODELS: Record<string, SystemModel[]> = {
id: 'Baichuan4',
provider: 'baichuan',
name: 'Baichuan4',
group: 'Baichuan4',
enabled: true
group: 'Baichuan4'
},
{
id: 'Baichuan3-Turbo',
provider: 'baichuan',
name: 'Baichuan3 Turbo',
group: 'Baichuan3',
enabled: true
group: 'Baichuan3'
},
{
id: 'Baichuan3-Turbo-128k',
provider: 'baichuan',
name: 'Baichuan3 Turbo 128k',
group: 'Baichuan3',
enabled: true
group: 'Baichuan3'
}
],
dashscope: [
@@ -318,22 +258,19 @@ export const SYSTEM_MODELS: Record<string, SystemModel[]> = {
id: 'qwen-turbo',
provider: 'dashscope',
name: 'Qwen Turbo',
group: 'Qwen',
enabled: true
group: 'Qwen'
},
{
id: 'qwen-plus',
provider: 'dashscope',
name: 'Qwen Plus',
group: 'Qwen',
enabled: true
group: 'Qwen'
},
{
id: 'qwen-max',
provider: 'dashscope',
name: 'Qwen Max',
group: 'Qwen',
enabled: true
group: 'Qwen'
}
],
stepfun: [
@@ -341,15 +278,13 @@ export const SYSTEM_MODELS: Record<string, SystemModel[]> = {
id: 'step-1-8k',
provider: 'stepfun',
name: 'Step 1 8K',
group: 'Step 1',
enabled: true
group: 'Step 1'
},
{
id: 'step-1-flash',
provider: 'stepfun',
name: 'Step 1 Flash',
group: 'Step 1',
enabled: true
group: 'Step 1'
}
],
doubao: [],
@@ -358,29 +293,25 @@ export const SYSTEM_MODELS: Record<string, SystemModel[]> = {
id: 'abab6.5s-chat',
provider: 'minimax',
name: 'abab6.5s',
group: 'abab6',
enabled: true
group: 'abab6'
},
{
id: 'abab6.5g-chat',
provider: 'minimax',
name: 'abab6.5g',
group: 'abab6',
enabled: true
group: 'abab6'
},
{
id: 'abab6.5t-chat',
provider: 'minimax',
name: 'abab6.5t',
group: 'abab6',
enabled: true
group: 'abab6'
},
{
id: 'abab5.5s-chat',
provider: 'minimax',
name: 'abab5.5s',
group: 'abab5',
enabled: true
group: 'abab5'
}
],
aihubmix: [
@@ -388,15 +319,13 @@ export const SYSTEM_MODELS: Record<string, SystemModel[]> = {
id: 'gpt-4o-mini',
provider: 'aihubmix',
name: 'GPT-4o Mini',
group: 'GPT-4o',
enabled: true
group: 'GPT-4o'
},
{
id: 'aihubmix-Llama-3-70B-Instruct',
provider: 'aihubmix',
name: 'Llama 3 70B Instruct',
group: 'Llama3',
enabled: true
group: 'Llama3'
}
],
openrouter: [
@@ -404,36 +333,31 @@ export const SYSTEM_MODELS: Record<string, SystemModel[]> = {
id: 'google/gemma-2-9b-it:free',
provider: 'openrouter',
name: 'Google: Gemma 2 9B',
group: 'Gemma',
enabled: true
group: 'Gemma'
},
{
id: 'microsoft/phi-3-mini-128k-instruct:free',
provider: 'openrouter',
name: 'Phi-3 Mini 128K Instruct',
group: 'Phi',
enabled: true
group: 'Phi'
},
{
id: 'microsoft/phi-3-medium-128k-instruct:free',
provider: 'openrouter',
name: 'Phi-3 Medium 128K Instruct',
group: 'Phi',
enabled: true
group: 'Phi'
},
{
id: 'meta-llama/llama-3-8b-instruct:free',
provider: 'openrouter',
name: 'Meta: Llama 3 8B Instruct',
group: 'Llama3',
enabled: true
group: 'Llama3'
},
{
id: 'mistralai/mistral-7b-instruct:free',
provider: 'openrouter',
name: 'Mistral: Mistral 7B Instruct',
group: 'Mistral',
enabled: true
group: 'Mistral'
}
],
groq: [
@@ -441,59 +365,33 @@ export const SYSTEM_MODELS: Record<string, SystemModel[]> = {
id: 'llama3-8b-8192',
provider: 'groq',
name: 'LLaMA3 8B',
group: 'Llama3',
enabled: false
group: 'Llama3'
},
{
id: 'llama3-70b-8192',
provider: 'groq',
name: 'LLaMA3 70B',
group: 'Llama3',
enabled: true
group: 'Llama3'
},
{
id: 'mixtral-8x7b-32768',
provider: 'groq',
name: 'Mixtral 8x7B',
group: 'Mixtral',
enabled: false
group: 'Mixtral'
},
{
id: 'gemma-7b-it',
provider: 'groq',
name: 'Gemma 7B',
group: 'Gemma',
enabled: false
}
],
anthropic: [
{
id: 'claude-3-5-sonnet-20240620',
provider: 'anthropic',
name: 'Claude 3.5 Sonnet',
group: 'Claude 3.5',
enabled: true
},
{
id: 'claude-3-opus-20240229',
provider: 'anthropic',
name: 'Claude 3 Opus',
group: 'Claude 3',
enabled: true
},
{
id: 'claude-3-sonnet-20240229',
provider: 'anthropic',
name: 'Claude 3 Sonnet',
group: 'Claude 3',
enabled: true
},
{
id: 'claude-3-haiku-20240307',
provider: 'anthropic',
name: 'Claude 3 Haiku',
group: 'Claude 3',
enabled: true
group: 'Gemma'
}
]
}
export function isTextToImageModel(model: Model): boolean {
return TEXT_TO_IMAGE_REGEX.test(model.id)
}
export function isEmbeddingModel(model: Model): boolean {
return EMBEDDING_REGEX.test(model.id)
}

View File

@@ -2,9 +2,10 @@ import BaicuanAppLogo from '@renderer/assets/images/apps/baixiaoying.webp'
import KimiAppLogo from '@renderer/assets/images/apps/kimi.jpg'
import YuewenAppLogo from '@renderer/assets/images/apps/yuewen.png'
import BaichuanModelLogo from '@renderer/assets/images/models/baichuan.png'
import ChatGLMModelLogo from '@renderer/assets/images/models/chatglm.jpeg'
import ChatGLMModelLogo from '@renderer/assets/images/models/chatglm.png'
import ChatGPTModelLogo from '@renderer/assets/images/models/chatgpt.jpeg'
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 DoubaoModelLogo from '@renderer/assets/images/models/doubao.png'
import EmbeddingModelLogo from '@renderer/assets/images/models/embedding.png'
@@ -25,6 +26,7 @@ import DashScopeProviderLogo from '@renderer/assets/images/providers/dashscope.p
import DeepSeekProviderLogo from '@renderer/assets/images/providers/deepseek.png'
import DoubaoProviderLogo from '@renderer/assets/images/providers/doubao.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 GroqProviderLogo from '@renderer/assets/images/providers/groq.png'
import MinimaxProviderLogo from '@renderer/assets/images/providers/minimax.png'
@@ -76,6 +78,8 @@ export function getProviderLogo(providerId: string) {
return GraphRagProviderLogo
case 'minimax':
return MinimaxProviderLogo
case 'github':
return GithubProviderLogo
default:
return undefined
}
@@ -106,7 +110,9 @@ export function getModelLogo(modelId: string) {
palm: PalmModelLogo,
step: StepModelLogo,
abab: HailuoModelLogo,
'ep-202': DoubaoModelLogo
'ep-202': DoubaoModelLogo,
cohere: CohereModelLogo,
command: CohereModelLogo
}
for (const key in logoMap) {
@@ -121,8 +127,7 @@ export function getModelLogo(modelId: string) {
export const PROVIDER_CONFIG = {
openai: {
api: {
url: 'https://api.openai.com',
editable: true
url: 'https://api.openai.com'
},
websites: {
official: 'https://openai.com/',
@@ -138,8 +143,7 @@ export const PROVIDER_CONFIG = {
},
gemini: {
api: {
url: 'https://generativelanguage.googleapis.com',
editable: false
url: 'https://generativelanguage.googleapis.com'
},
websites: {
official: 'https://gemini.google.com/',
@@ -155,8 +159,7 @@ export const PROVIDER_CONFIG = {
},
silicon: {
api: {
url: 'https://cloud.siliconflow.cn',
editable: false
url: 'https://cloud.siliconflow.cn'
},
websites: {
official: 'https://www.siliconflow.cn/',
@@ -172,8 +175,7 @@ export const PROVIDER_CONFIG = {
},
deepseek: {
api: {
url: 'https://api.deepseek.com',
editable: false
url: 'https://api.deepseek.com'
},
websites: {
official: 'https://deepseek.com/',
@@ -187,10 +189,25 @@ export const PROVIDER_CONFIG = {
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'
},
app: {
name: 'Github Models',
url: 'https://github.com/marketplace/models/azure-openai/gpt-4o/playground',
logo: GithubProviderLogo
}
},
yi: {
api: {
url: 'https://api.lingyiwanwu.com',
editable: false
url: 'https://api.lingyiwanwu.com'
},
websites: {
official: 'https://platform.lingyiwanwu.com/',
@@ -206,8 +223,7 @@ export const PROVIDER_CONFIG = {
},
zhipu: {
api: {
url: 'https://open.bigmodel.cn/api/paas/v4/',
editable: false
url: 'https://open.bigmodel.cn/api/paas/v4/'
},
websites: {
official: 'https://open.bigmodel.cn/',
@@ -223,8 +239,7 @@ export const PROVIDER_CONFIG = {
},
moonshot: {
api: {
url: 'https://api.moonshot.cn',
editable: false
url: 'https://api.moonshot.cn'
},
websites: {
official: 'https://moonshot.ai/',
@@ -240,8 +255,7 @@ export const PROVIDER_CONFIG = {
},
baichuan: {
api: {
url: 'https://api.baichuan-ai.com',
editable: false
url: 'https://api.baichuan-ai.com'
},
websites: {
official: 'https://www.baichuan-ai.com/',
@@ -257,8 +271,7 @@ export const PROVIDER_CONFIG = {
},
dashscope: {
api: {
url: 'https://dashscope.aliyuncs.com/compatible-mode/v1/',
editable: false
url: 'https://dashscope.aliyuncs.com/compatible-mode/v1/'
},
websites: {
official: 'https://dashscope.aliyun.com/',
@@ -274,8 +287,7 @@ export const PROVIDER_CONFIG = {
},
stepfun: {
api: {
url: 'https://api.stepfun.com',
editable: false
url: 'https://api.stepfun.com'
},
websites: {
official: 'https://platform.stepfun.com/',
@@ -291,8 +303,7 @@ export const PROVIDER_CONFIG = {
},
doubao: {
api: {
url: 'https://ark.cn-beijing.volces.com/api/v3/',
editable: true
url: 'https://ark.cn-beijing.volces.com/api/v3/'
},
websites: {
official: 'https://console.volcengine.com/ark/',
@@ -308,8 +319,7 @@ export const PROVIDER_CONFIG = {
},
minimax: {
api: {
url: 'https://api.minimax.chat/v1/',
editable: true
url: 'https://api.minimax.chat/v1/'
},
websites: {
official: 'https://platform.minimaxi.com/',
@@ -325,14 +335,12 @@ export const PROVIDER_CONFIG = {
},
'graphrag-kylin-mountain': {
api: {
url: '',
editable: true
url: ''
}
},
openrouter: {
api: {
url: 'https://openrouter.ai/api/v1/',
editable: false
url: 'https://openrouter.ai/api/v1/'
},
websites: {
official: 'https://openrouter.ai/',
@@ -343,8 +351,7 @@ export const PROVIDER_CONFIG = {
},
groq: {
api: {
url: 'https://api.groq.com/openai',
editable: false
url: 'https://api.groq.com/openai'
},
websites: {
official: 'https://groq.com/',
@@ -360,8 +367,7 @@ export const PROVIDER_CONFIG = {
},
ollama: {
api: {
url: 'http://localhost:11434/v1/',
editable: true
url: 'http://localhost:11434/v1/'
},
websites: {
official: 'https://ollama.com/',
@@ -371,8 +377,7 @@ export const PROVIDER_CONFIG = {
},
anthropic: {
api: {
url: 'https://api.anthropic.com/',
editable: true
url: 'https://api.anthropic.com/'
},
websites: {
official: 'https://anthropic.com/',
@@ -388,8 +393,7 @@ export const PROVIDER_CONFIG = {
},
aihubmix: {
api: {
url: 'https://aihubmix.com',
editable: false
url: 'https://aihubmix.com'
},
websites: {
official: 'https://aihubmix.com/',

View File

@@ -4,6 +4,14 @@ import type KeyvStorage from '@kangfenmao/keyv-storage'
import { MessageInstance } from 'antd/es/message/interface'
import { HookAPI } from 'antd/es/modal/useModal'
interface ImportMetaEnv {
VITE_RENDERER_INTEGRATED_MODEL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
declare global {
interface Window {
message: MessageInstance

View File

@@ -1,3 +1,4 @@
import { isLocalAi } from '@renderer/config/env'
import i18n from '@renderer/i18n'
import LocalStorage from '@renderer/services/storage'
import { useAppDispatch } from '@renderer/store'
@@ -5,12 +6,14 @@ import { setAvatar } from '@renderer/store/runtime'
import { runAsyncFunction } from '@renderer/utils'
import { useEffect } from 'react'
import { useDefaultModel } from './useAssistant'
import { useSettings } from './useSettings'
export function useAppInit() {
const dispatch = useAppDispatch()
const { proxyUrl } = useSettings()
const { language } = useSettings()
const { setDefaultModel, setTopicNamingModel, setTranslateModel } = useDefaultModel()
useEffect(() => {
runAsyncFunction(async () => {
@@ -33,4 +36,14 @@ export function useAppInit() {
useEffect(() => {
i18n.changeLanguage(language || navigator.language || 'en-US')
}, [language])
useEffect(() => {
if (isLocalAi) {
const model = JSON.parse(import.meta.env.VITE_RENDERER_INTEGRATED_MODEL)
setDefaultModel(model)
setTopicNamingModel(model)
setTranslateModel(model)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
}

View File

@@ -0,0 +1,7 @@
import { useProviders } from './useProvider'
export function useModel(id?: string) {
const { providers } = useProviders()
const allModels = providers.map((p) => p.models).flat()
return allModels.find((m) => m.id === id)
}

View File

@@ -3,6 +3,8 @@ import {
SendMessageShortcut,
setSendMessageShortcut as _setSendMessageShortcut,
setTheme,
setTopicPosition,
setWindowStyle,
ThemeMode
} from '@renderer/store/settings'
@@ -17,6 +19,12 @@ export function useSettings() {
},
setTheme(theme: ThemeMode) {
dispatch(setTheme(theme))
},
setWindowStyle(windowStyle: 'transparent' | 'opaque') {
dispatch(setWindowStyle(windowStyle))
},
setTopicPosition(topicPosition: 'left' | 'right') {
dispatch(setTopicPosition(topicPosition))
}
}
}

View File

@@ -1,17 +1,5 @@
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { setShowRightSidebar, toggleRightSidebar, toggleShowAssistants } from '@renderer/store/settings'
export function useShowRightSidebar() {
const showRightSidebar = useAppSelector((state) => state.settings.showRightSidebar)
const dispatch = useAppDispatch()
return {
rightSidebarShown: showRightSidebar,
toggleRightSidebar: () => dispatch(toggleRightSidebar()),
showRightSidebar: () => dispatch(setShowRightSidebar(true)),
hideRightSidebar: () => dispatch(setShowRightSidebar(false))
}
}
import { setShowTopics, toggleShowAssistants, toggleShowTopics } from '@renderer/store/settings'
export function useShowAssistants() {
const showAssistants = useAppSelector((state) => state.settings.showAssistants)
@@ -23,6 +11,17 @@ export function useShowAssistants() {
}
}
export function useShowTopics() {
const showTopics = useAppSelector((state) => state.settings.showTopics)
const dispatch = useAppDispatch()
return {
showTopics,
setShowTopics: (show: boolean) => dispatch(setShowTopics(show)),
toggleShowTopics: () => dispatch(toggleShowTopics())
}
}
export function useRuntime() {
return useAppSelector((state) => state.runtime)
}

View File

@@ -2,11 +2,16 @@ import { Assistant, Topic } from '@renderer/types'
import { find } from 'lodash'
import { useEffect, useState } from 'react'
import { useAssistant } from './useAssistant'
let _activeTopic: Topic
export function useActiveTopic(assistant: Assistant) {
export function useActiveTopic(_assistant: Assistant) {
const { assistant } = useAssistant(_assistant.id)
const [activeTopic, setActiveTopic] = useState(_activeTopic || assistant?.topics[0])
_activeTopic = activeTopic
useEffect(() => {
// activeTopic not in assistant.topics
if (assistant && !find(assistant.topics, { id: activeTopic?.id })) {

View File

@@ -29,13 +29,17 @@ const resources = {
select: 'Select',
search: 'Search',
default: 'Default',
warning: 'Warning'
warning: 'Warning',
back: 'Back',
chat: 'Chat'
},
button: {
add: 'Add',
added: 'Added',
manage: 'Manage',
select_model: 'Select Model'
select_model: 'Select Model',
'show.all': 'Show All',
collapse: 'Collapse'
},
message: {
copied: 'Copied!',
@@ -57,7 +61,7 @@ const resources = {
},
chat: {
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.topic.name': 'Default Topic',
'topics.title': 'Topics',
@@ -66,17 +70,20 @@ const resources = {
'topics.edit.placeholder': 'Enter new name',
'topics.delete.all.title': 'Delete all topics',
'topics.delete.all.content': 'Are you sure you want to delete all topics?',
'topics.list': 'Topic List',
'input.new_topic': 'New Topic',
'input.topics': ' Topics ',
'input.clear': 'Clear',
'input.new.context': 'Clear Context',
'input.expand': 'Expand',
'input.collapse': 'Collapse',
'input.clear.title': 'Clear all messages?',
'input.clear.content': 'Are you sure to clear all messages?',
'input.clear.content': 'Do you want to clear all messages of the current topic?',
'input.placeholder': 'Type your message here...',
'input.send': 'Send',
'input.pause': 'Pause',
'input.settings': 'Settings',
'input.upload': 'Upload image png、jpg、jpeg',
'input.context_count.tip': 'Context Count',
'input.estimated_tokens.tip': 'Estimated tokens',
'settings.temperature': 'Temperature',
@@ -91,20 +98,22 @@ const resources = {
'settings.set_as_default': 'Apply to default assistant',
'settings.max': 'Max',
'suggestions.title': 'Suggested Questions',
'add.assistant.title': 'Add Assistant'
'add.assistant.title': 'Add Assistant',
'message.new.context': 'New Context',
'assistant.search.placeholder': 'Search'
},
agents: {
title: 'Agents',
my_agents: 'My Agents',
'add.title': 'Add Agent',
'edit.title': 'Edit Agent',
title: 'Assistants',
my_agents: 'My Assistants',
'add.title': 'Add Assistant',
'edit.title': 'Edit Assistant',
'add.name': 'Name',
'add.name.placeholder': 'Enter name',
'add.prompt': 'Prompt',
'add.prompt.placeholder': 'Enter prompt',
'add.button': 'Add',
'manage.title': 'Manage Agents',
'delete.popup.content': 'Are you sure you want to delete this agent?',
'manage.title': 'Manage Assistants',
'delete.popup.content': 'Are you sure you want to delete this assistant?',
'tag.default': 'Default',
'tag.system': 'System',
'tag.user': 'Mine'
@@ -127,7 +136,8 @@ const resources = {
stepfun: 'StepFun',
doubao: 'Doubao',
minimax: 'MiniMax',
'graphrag-kylin-mountain': 'GraphRAG'
'graphrag-kylin-mountain': 'GraphRAG',
github: 'GitHub Models'
},
settings: {
title: 'Settings',
@@ -147,6 +157,8 @@ const resources = {
'general.user_name': 'User Name',
'general.user_name.placeholder': 'Enter your name',
'general.backup.title': 'Data Backup and Recovery',
'general.backup.button': 'Backup',
'general.restore.button': 'Restore',
'general.reset.title': 'Data Reset',
'general.reset.button': 'Reset',
'provider.api_key': 'API Key',
@@ -198,7 +210,13 @@ const resources = {
'theme.dark': 'Dark',
'theme.light': 'Light',
'theme.auto': 'Auto',
'font_size.title': 'Message Font Size'
'theme.window.style.title': 'Window Style',
'theme.window.style.transparent': 'Transparent Window',
'theme.window.style.opaque': 'Opaque Window',
'font_size.title': 'Message Font Size',
'topic.position': 'Topic Position',
'topic.position.left': 'Left',
'topic.position.right': 'Right'
},
translate: {
title: 'Translation',
@@ -266,13 +284,17 @@ const resources = {
select: '选择',
search: '搜索',
default: '默认',
warning: '警告'
warning: '警告',
back: '返回',
chat: '聊天'
},
button: {
add: '添加',
added: '已添加',
manage: '管理',
select_model: '选择模型'
select_model: '选择模型',
'show.all': '显示全部',
collapse: '收起'
},
message: {
copied: '已复制',
@@ -294,7 +316,7 @@ const resources = {
},
chat: {
save: '保存',
'default.name': '🔆 默认助手 - Assistant',
'default.name': '默认助手',
'default.description': '你好,我是默认助手。你可以立刻开始跟我聊天。',
'default.topic.name': '默认话题',
'topics.title': '话题',
@@ -303,17 +325,20 @@ const resources = {
'topics.edit.placeholder': '输入新名称',
'topics.delete.all.title': '删除所有话题',
'topics.delete.all.content': '确定要删除所有话题吗?',
'topics.list': '话题列表',
'input.new_topic': '新话题',
'input.topics': ' 话题 ',
'input.clear': '清除',
'input.clear': '清除会话消息',
'input.new.context': '清除上下文',
'input.expand': '展开',
'input.collapse': '收起',
'input.clear.title': '清除所有消息?',
'input.clear.content': '确定要清除所有消息吗?',
'input.clear.title': '清除消息?',
'input.clear.content': '确定要清除当前会话所有消息吗?',
'input.placeholder': '在这里输入消息...',
'input.send': '发送',
'input.pause': '暂停',
'input.settings': '设置',
'input.upload': '上传图片 png、jpg、jpeg',
'input.context_count.tip': '上下文数',
'input.estimated_tokens.tip': '预估 token 数',
'settings.temperature': '模型温度',
@@ -329,7 +354,9 @@ const resources = {
'settings.set_as_default': '应用到默认助手',
'settings.max': '不限',
'suggestions.title': '建议的问题',
'add.assistant.title': '添加智能体'
'add.assistant.title': '添加智能体',
'message.new.context': '清除上下文',
'assistant.search.placeholder': '搜索'
},
agents: {
title: '智能体',
@@ -365,7 +392,8 @@ const resources = {
stepfun: '阶跃星辰',
doubao: '豆包',
minimax: 'MiniMax',
'graphrag-kylin-mountain': 'GraphRAG'
'graphrag-kylin-mountain': 'GraphRAG',
github: 'GitHub Models'
},
settings: {
title: '设置',
@@ -385,6 +413,8 @@ const resources = {
'general.user_name': '用户名',
'general.user_name.placeholder': '请输入用户名',
'general.backup.title': '数据备份与恢复',
'general.backup.button': '备份',
'general.restore.button': '恢复',
'general.reset.title': '重置数据',
'general.reset.button': '重置',
'provider.api_key': 'API 密钥',
@@ -436,7 +466,13 @@ const resources = {
'theme.dark': '深色主题',
'theme.light': '浅色主题',
'theme.auto': '跟随系统',
'font_size.title': '消息字体大小'
'theme.window.style.title': '窗口样式',
'theme.window.style.transparent': '透明窗口',
'theme.window.style.opaque': '不透明窗口',
'font_size.title': '消息字体大小',
'topic.position': '话题位置',
'topic.position.left': '左侧',
'topic.position.right': '右侧'
},
translate: {
title: '翻译',

View File

@@ -1,27 +1,9 @@
import KeyvStorage from '@kangfenmao/keyv-storage'
import * as Sentry from '@sentry/electron/renderer'
import localforage from 'localforage'
import { APP_NAME } from './config/env'
import { ThemeMode } from './store/settings'
import { isProduction, loadScript } from './utils'
async function initSentry() {
if (await isProduction()) {
Sentry.init({
integrations: [Sentry.browserTracingIntegration(), Sentry.replayIntegration()],
// Set tracesSampleRate to 1.0 to capture 100%
// of transactions for performance monitoring.
// We recommend adjusting this value in production
tracesSampleRate: 1.0,
// Capture Replay for 10% of all sessions,
// plus for 100% of sessions with an error
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1.0
})
}
}
import { loadScript } from './utils'
export async function initMermaid(theme: ThemeMode) {
if (!window.mermaid) {
@@ -41,13 +23,11 @@ function init() {
name: 'CherryAI',
version: 1.0,
storeName: 'cherryai',
description: 'Cherry Studio Storage'
description: `${APP_NAME} Storage`
})
window.keyv = new KeyvStorage()
window.keyv.init()
initSentry()
}
init()

View File

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

View File

@@ -83,14 +83,14 @@ const PopupContainer: React.FC<Props> = ({ agent, resolve }) => {
return (
<Modal
style={{ marginTop: '10vh' }}
title={agent ? t('agents.edit.title') : t('agents.add.title')}
open={open}
onOk={() => formRef.current?.submit()}
onCancel={onCancel}
maskClosable={false}
afterClose={onClose}
okText={agent ? t('common.save') : t('agents.add.button')}>
okText={agent ? t('common.save') : t('agents.add.button')}
centered>
<Form
ref={formRef}
form={form}
@@ -108,7 +108,7 @@ const PopupContainer: React.FC<Props> = ({ agent, resolve }) => {
<Input placeholder={t('agents.add.name.placeholder')} spellCheck={false} allowClear />
</Form.Item>
<Form.Item name="prompt" label={t('agents.add.prompt')} rules={[{ required: true }]}>
<TextArea placeholder={t('agents.add.prompt.placeholder')} spellCheck={false} rows={4} />
<TextArea placeholder={t('agents.add.prompt.placeholder')} spellCheck={false} rows={10} />
</Form.Item>
</Form>
</Modal>

View File

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

View File

@@ -1,5 +1,5 @@
import MinApp from '@renderer/components/MinApp'
import { useTheme } from '@renderer/providers/ThemeProvider'
import { useTheme } from '@renderer/context/ThemeProvider'
import { MinAppType } from '@renderer/types'
import { FC } from 'react'
import styled from 'styled-components'
@@ -12,13 +12,6 @@ const App: FC<Props> = ({ app }) => {
const { theme } = useTheme()
const onClick = () => {
const websiteReg = /claude|chatgpt|groq/i
if (websiteReg.test(app.url)) {
window.api.minApp({ url: app.url, windowOptions: { title: app.name } })
return
}
MinApp.start(app)
}

View File

@@ -1,17 +1,7 @@
import { SearchOutlined } from '@ant-design/icons'
import AiAssistantAppLogo from '@renderer/assets/images/apps/360-ai.png'
import AiSearchAppLogo from '@renderer/assets/images/apps/ai-search.png'
import BaiduAiAppLogo from '@renderer/assets/images/apps/baidu-ai.png'
import DevvAppLogo from '@renderer/assets/images/apps/devv.png'
import MetasoAppLogo from '@renderer/assets/images/apps/metaso.webp'
import SensetimeAppLogo from '@renderer/assets/images/apps/sensetime.png'
import SparkDeskAppLogo from '@renderer/assets/images/apps/sparkdesk.png'
import TiangongAiLogo from '@renderer/assets/images/apps/tiangong.png'
import TencentYuanbaoAppLogo from '@renderer/assets/images/apps/yuanbao.png'
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
import { Center } from '@renderer/components/Layout'
import { PROVIDER_CONFIG } from '@renderer/config/provider'
import { MinAppType } from '@renderer/types'
import { getAllMinApps } from '@renderer/config/minapp'
import { Empty, Input } from 'antd'
import { isEmpty } from 'lodash'
import { FC, useState } from 'react'
@@ -20,63 +10,12 @@ import styled from 'styled-components'
import App from './App'
const _apps: MinAppType[] = [
{
name: 'AI 助手',
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搜索',
logo: AiSearchAppLogo,
url: 'https://so.360.com/'
},
{
name: '秘塔AI搜索',
logo: MetasoAppLogo,
url: 'https://metaso.cn/'
},
{
name: '天工AI',
logo: TiangongAiLogo,
url: 'https://www.tiangong.cn/'
},
{
name: 'DEVV_',
logo: DevvAppLogo,
url: 'https://devv.ai/'
}
]
const list = getAllMinApps()
const AppsPage: FC = () => {
const { t } = useTranslation()
const [search, setSearch] = useState('')
const list: MinAppType[] = (Object.entries(PROVIDER_CONFIG) as any[])
.filter(([, config]) => config.app)
.map(([key, config]) => ({ id: key, ...config.app }))
.concat(_apps)
const apps = search
? list.filter(
(app) => app.name.toLowerCase().includes(search.toLowerCase()) || app.url.includes(search.toLowerCase())

View File

@@ -1,16 +1,18 @@
import { CopyOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons'
import { DragDropContext, Draggable, Droppable, DropResult } from '@hello-pangea/dnd'
import { DeleteOutlined, EditOutlined, MinusCircleOutlined } from '@ant-design/icons'
import DragableList from '@renderer/components/DragableList'
import CopyIcon from '@renderer/components/Icons/CopyIcon'
import AssistantSettingPopup from '@renderer/components/Popups/AssistantSettingPopup'
import { useAssistant, useAssistants } from '@renderer/hooks/useAssistant'
import { getDefaultTopic, syncAsistantToAgent } from '@renderer/services/assistant'
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 { droppableReorder, uuid } from '@renderer/utils'
import { Dropdown } from 'antd'
import { uuid } from '@renderer/utils'
import { Dropdown, Input, InputRef } from 'antd'
import { ItemType } from 'antd/es/menu/interface'
import { last } from 'lodash'
import { FC, useCallback } from 'react'
import { isEmpty, last } from 'lodash'
import { FC, useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@@ -23,8 +25,11 @@ interface Props {
const Assistants: FC<Props> = ({ activeAssistant, setActiveAssistant, onCreateAssistant }) => {
const { assistants, removeAssistant, addAssistant, updateAssistants } = useAssistants()
const generating = useAppSelector((state) => state.runtime.generating)
const { updateAssistant } = useAssistant(activeAssistant.id)
const [search, setSearch] = useState('')
const { updateAssistant, removeAllTopics } = useAssistant(activeAssistant.id)
const searchRef = useRef<InputRef>(null)
const { t } = useTranslation()
const dispatch = useAppDispatch()
const onDelete = useCallback(
(assistant: Assistant) => {
@@ -35,6 +40,15 @@ const Assistants: FC<Props> = ({ activeAssistant, setActiveAssistant, onCreateAs
[assistants, onCreateAssistant, removeAssistant, setActiveAssistant]
)
const onEditAssistant = useCallback(
async (assistant: Assistant) => {
const _assistant = await AssistantSettingPopup.show({ assistant })
updateAssistant(_assistant)
syncAsistantToAgent(_assistant)
},
[updateAssistant]
)
const getMenuItems = useCallback(
(assistant: Assistant) =>
[
@@ -42,22 +56,32 @@ const Assistants: FC<Props> = ({ activeAssistant, setActiveAssistant, onCreateAs
label: t('common.edit'),
key: 'edit',
icon: <EditOutlined />,
async onClick() {
const _assistant = await AssistantSettingPopup.show({ assistant })
updateAssistant(_assistant)
syncAsistantToAgent(_assistant)
}
onClick: () => onEditAssistant(assistant)
},
{
label: t('common.duplicate'),
key: 'duplicate',
icon: <CopyOutlined />,
icon: <CopyIcon />,
onClick: async () => {
const _assistant: Assistant = { ...assistant, id: uuid(), topics: [getDefaultTopic()] }
addAssistant(_assistant)
setActiveAssistant(_assistant)
}
},
{
label: t('chat.topics.delete.all.title'),
key: 'delete-all',
icon: <MinusCircleOutlined />,
onClick: () => {
window.modal.confirm({
title: t('chat.topics.delete.all.title'),
content: t('chat.topics.delete.all.content'),
centered: true,
okButtonProps: { danger: true },
onOk: removeAllTopics
})
}
},
{ type: 'divider' },
{
label: t('common.delete'),
@@ -67,19 +91,7 @@ const Assistants: FC<Props> = ({ activeAssistant, setActiveAssistant, onCreateAs
onClick: () => onDelete(assistant)
}
] as ItemType[],
[addAssistant, onDelete, setActiveAssistant, t, updateAssistant]
)
const onDragEnd = useCallback(
(result: DropResult) => {
if (result.destination) {
const sourceIndex = result.source.index
const destIndex = result.destination.index
const reorderAssistants = droppableReorder<Assistant>(assistants, sourceIndex, destIndex)
updateAssistants(reorderAssistants)
}
},
[assistants, updateAssistants]
[addAssistant, onDelete, onEditAssistant, removeAllTopics, setActiveAssistant, t]
)
const onSwitchAssistant = useCallback(
@@ -90,41 +102,91 @@ const Assistants: FC<Props> = ({ activeAssistant, setActiveAssistant, onCreateAs
key: 'switch-assistant'
})
}
EventEmitter.emit(EVENT_NAMES.SWITCH_TOPIC_SIDEBAR)
setActiveAssistant(assistant)
},
[generating, setActiveAssistant, t]
)
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 (
<Container>
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="droppable">
{(provided) => (
<div {...provided.droppableProps} ref={provided.innerRef}>
{assistants.map((assistant, index) => (
<Draggable key={`draggable_${assistant.id}_${index}`} draggableId={assistant.id} index={index}>
{(provided) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
style={{ ...provided.draggableProps.style, marginBottom: 5 }}>
<Dropdown key={assistant.id} menu={{ items: getMenuItems(assistant) }} trigger={['contextMenu']}>
<AssistantItem
onClick={() => onSwitchAssistant(assistant)}
className={assistant.id === activeAssistant?.id ? 'active' : ''}>
<AssistantName className="name">{assistant.name || t('chat.default.name')}</AssistantName>
</AssistantItem>
</Dropdown>
</div>
)}
</Draggable>
))}
</div>
)}
</Droppable>
</DragDropContext>
{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) }}>
{(assistant) => {
const isCurrent = assistant.id === activeAssistant?.id
return (
<Dropdown key={assistant.id} menu={{ items: getMenuItems(assistant) }} trigger={['contextMenu']}>
<AssistantItem onClick={() => onSwitchAssistant(assistant)} className={isCurrent ? 'active' : ''}>
<AssistantName className="name">{assistant.name || t('chat.default.name')}</AssistantName>
<ArrowRightButton
className={`arrow-button ${isCurrent ? 'active' : ''}`}
onClick={() => EventEmitter.emit(EVENT_NAMES.SWITCH_TOPIC_SIDEBAR)}>
<i className="iconfont icon-gridlines" />
</ArrowRightButton>
{false && <TopicCount className="topics-count">{assistant.topics.length}</TopicCount>}
</AssistantItem>
</Dropdown>
)
}}
</DragableList>
</Container>
)
}
@@ -132,38 +194,39 @@ const Assistants: FC<Props> = ({ activeAssistant, setActiveAssistant, onCreateAs
const Container = styled.div`
display: flex;
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));
padding: 10px;
overflow-y: auto;
padding-top: 10px;
padding-bottom: 10px;
`
const AssistantItem = styled.div`
display: flex;
flex-direction: column;
flex-direction: row;
justify-content: space-between;
padding: 7px 10px;
position: relative;
border-radius: 4px;
margin: 0 10px;
padding-right: 35px;
cursor: pointer;
font-family: Ubuntu;
.anticon {
display: none;
}
&:hover {
background-color: var(--color-background-soft);
.anticon {
display: block;
color: var(--color-text-1);
}
.iconfont {
opacity: 0;
color: var(--color-text-3);
}
&.active {
background-color: var(--color-background-mute);
cursor: pointer;
.name {
font-weight: 500;
}
.topics-count {
display: none;
}
.iconfont {
opacity: 1;
color: var(--color-text-2);
}
}
`
@@ -173,6 +236,55 @@ const AssistantName = styled.div`
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
font-size: 13px;
`
const ArrowRightButton = styled.div`
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
width: 22px;
height: 22px;
min-width: 22px;
min-height: 22px;
border-radius: 4px;
position: absolute;
background-color: var(--color-background);
right: 9px;
top: 6px;
.iconfont {
font-size: 12px;
}
`
const TopicCount = styled.div`
color: var(--color-text-2);
font-size: 10px;
margin-right: 3px;
background-color: var(--color-background-mute);
opacity: 0.8;
width: 20px;
height: 20px;
border-radius: 10px;
display: flex;
flex-direction: row;
justify-content: 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

View File

@@ -1,6 +1,7 @@
import { useAssistant } from '@renderer/hooks/useAssistant'
import { useActiveTopic } from '@renderer/hooks/useTopic'
import { Assistant } from '@renderer/types'
import { useSettings } from '@renderer/hooks/useSettings'
import { useShowTopics } from '@renderer/hooks/useStore'
import { Assistant, Topic } from '@renderer/types'
import { Flex } from 'antd'
import { FC } from 'react'
import styled from 'styled-components'
@@ -11,19 +12,31 @@ import RightSidebar from './RightSidebar'
interface Props {
assistant: Assistant
activeTopic: Topic
setActiveTopic: (topic: Topic) => void
setActiveAssistant: (assistant: Assistant) => void
}
const Chat: FC<Props> = (props) => {
const { assistant } = useAssistant(props.assistant.id)
const { activeTopic, setActiveTopic } = useActiveTopic(assistant)
const { topicPosition } = useSettings()
const { showTopics } = useShowTopics()
return (
<Container id="chat">
<Main vertical flex={1} justify="space-between">
<Messages assistant={assistant} topic={activeTopic} />
<Inputbar assistant={assistant} setActiveTopic={setActiveTopic} />
<Messages assistant={assistant} topic={props.activeTopic} setActiveTopic={props.setActiveTopic} />
<Inputbar assistant={assistant} setActiveTopic={props.setActiveTopic} />
</Main>
<RightSidebar assistant={assistant} activeTopic={activeTopic} setActiveTopic={setActiveTopic} />
{topicPosition === 'right' && showTopics && (
<RightSidebar
activeAssistant={assistant}
activeTopic={props.activeTopic}
setActiveAssistant={props.setActiveAssistant}
setActiveTopic={props.setActiveTopic}
position="right"
/>
)}
</Container>
)
}

View File

@@ -1,42 +0,0 @@
import { NavbarCenter } from '@renderer/components/app/Navbar'
import { isMac } from '@renderer/config/constant'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { useShowAssistants } from '@renderer/hooks/useStore'
import { Assistant } from '@renderer/types'
import { removeLeadingEmoji } from '@renderer/utils'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import SelectModelButton from './components/SelectModelButton'
import { NewButton } from './HomePage'
interface Props {
activeAssistant: Assistant
}
const HomeHeader: FC<Props> = ({ activeAssistant }) => {
const { assistant } = useAssistant(activeAssistant.id)
const { t } = useTranslation()
const { showAssistants, toggleShowAssistants } = useShowAssistants()
return (
<NavbarCenter style={{ paddingLeft: isMac ? 16 : 8 }}>
{!showAssistants && (
<NewButton onClick={toggleShowAssistants} style={{ marginRight: isMac ? 8 : 25 }}>
<i className="iconfont icon-showsidebarhoriz" />
</NewButton>
)}
<AssistantName>{removeLeadingEmoji(assistant?.name) || t('chat.default.name')}</AssistantName>
<SelectModelButton assistant={assistant} />
</NavbarCenter>
)
}
const AssistantName = styled.span`
margin-left: 5px;
margin-right: 10px;
font-family: Ubuntu;
`
export default HomeHeader

View File

@@ -1,77 +1,43 @@
import { Navbar, NavbarLeft, NavbarRight } from '@renderer/components/app/Navbar'
import { isMac, isWindows } from '@renderer/config/constant'
import { useAssistants, useDefaultAssistant } from '@renderer/hooks/useAssistant'
import { useShowAssistants, useShowRightSidebar } from '@renderer/hooks/useStore'
import { useTheme } from '@renderer/providers/ThemeProvider'
import { useAssistants } from '@renderer/hooks/useAssistant'
import { useShowAssistants } from '@renderer/hooks/useStore'
import { useActiveTopic } from '@renderer/hooks/useTopic'
import { Assistant } from '@renderer/types'
import { uuid } from '@renderer/utils'
import { Switch } from 'antd'
import { FC, useState } from 'react'
import styled from 'styled-components'
import AddAssistantPopup from '../../components/Popups/AddAssistantPopup'
import Assistants from './Assistants'
import Chat from './Chat'
import Navigation from './Header'
import Navbar from './Navbar'
import RightSidebar from './RightSidebar'
let _activeAssistant: Assistant
const HomePage: FC = () => {
const { assistants, addAssistant } = useAssistants()
const { assistants } = useAssistants()
const [activeAssistant, setActiveAssistant] = useState(_activeAssistant || assistants[0])
const { rightSidebarShown, toggleRightSidebar } = useShowRightSidebar()
const { showAssistants, toggleShowAssistants } = useShowAssistants()
const { defaultAssistant } = useDefaultAssistant()
const { theme, toggleTheme } = useTheme()
const { showAssistants } = useShowAssistants()
const { activeTopic, setActiveTopic } = useActiveTopic(activeAssistant)
_activeAssistant = activeAssistant
const onCreateDefaultAssistant = () => {
const assistant = { ...defaultAssistant, id: uuid() }
addAssistant(assistant)
setActiveAssistant(assistant)
}
const onCreateAssistant = async () => {
const assistant = await AddAssistantPopup.show()
assistant && setActiveAssistant(assistant)
}
return (
<Container>
<Navbar>
{showAssistants && (
<NavbarLeft style={{ justifyContent: 'space-between', borderRight: 'none', padding: '0 8px' }}>
<NewButton onClick={toggleShowAssistants} style={{ marginLeft: isMac ? 8 : 0 }}>
<i className="iconfont icon-hidesidebarhoriz" />
</NewButton>
<NewButton onClick={onCreateAssistant}>
<i className="iconfont icon-a-addchat"></i>
</NewButton>
</NavbarLeft>
)}
<Navigation activeAssistant={activeAssistant} />
<NavbarRight style={{ justifyContent: 'flex-end', paddingRight: isWindows ? 140 : 12 }}>
<ThemeSwitch
checkedChildren={<i className="iconfont icon-theme icon-dark1" />}
unCheckedChildren={<i className="iconfont icon-theme icon-theme-light" />}
checked={theme === 'dark'}
onChange={toggleTheme}
/>
<NewButton onClick={toggleRightSidebar}>
<i className={`iconfont ${rightSidebarShown ? 'icon-showsidebarhoriz' : 'icon-hidesidebarhoriz'}`} />
</NewButton>
</NavbarRight>
</Navbar>
<Navbar activeAssistant={activeAssistant} setActiveAssistant={setActiveAssistant} activeTopic={activeTopic} />
<ContentContainer>
{showAssistants && (
<Assistants
<RightSidebar
activeAssistant={activeAssistant}
activeTopic={activeTopic}
setActiveAssistant={setActiveAssistant}
onCreateAssistant={onCreateDefaultAssistant}
setActiveTopic={setActiveTopic}
position="left"
/>
)}
<Chat assistant={activeAssistant} />
<Chat
assistant={activeAssistant}
activeTopic={activeTopic}
setActiveTopic={setActiveTopic}
setActiveAssistant={setActiveAssistant}
/>
</ContentContainer>
</Container>
)
@@ -90,40 +56,4 @@ const ContentContainer = styled.div`
background-color: var(--color-background);
`
export const NewButton = styled.div`
-webkit-app-region: none;
border-radius: 4px;
width: 30px;
height: 30px;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
transition: all 0.2s ease-in-out;
color: var(--color-icon);
.icon-a-addchat {
font-size: 20px;
}
.anticon {
font-size: 19px;
}
.icon-showsidebarhoriz,
.icon-hidesidebarhoriz {
font-size: 17px;
}
&:hover {
background-color: var(--color-background-soft);
cursor: pointer;
color: var(--color-icon-white);
}
`
const ThemeSwitch = styled(Switch)`
-webkit-app-region: none;
margin-right: 10px;
.icon-theme {
font-size: 14px;
}
`
export default HomePage

View File

@@ -0,0 +1,31 @@
import { PaperClipOutlined } from '@ant-design/icons'
import { Tooltip, Upload } from 'antd'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
interface Props {
files: File[]
setFiles: (files: File[]) => void
ToolbarButton: any
}
const AttachmentButton: FC<Props> = ({ files, setFiles, ToolbarButton }) => {
const { t } = useTranslation()
return (
<Tooltip placement="top" title={t('chat.input.upload')} arrow>
<Upload
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' }} />
</ToolbarButton>
</Upload>
</Tooltip>
)
}
export default AttachmentButton

View File

@@ -8,25 +8,26 @@ import {
PlusCircleOutlined,
QuestionCircleOutlined
} from '@ant-design/icons'
import { DEFAULT_CONEXTCOUNT } from '@renderer/config/constant'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { useSettings } from '@renderer/hooks/useSettings'
import { useRuntime, useShowTopics } from '@renderer/hooks/useStore'
import { getDefaultTopic } from '@renderer/services/assistant'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/event'
import { estimateInputTokenCount } from '@renderer/services/messages'
import store, { useAppSelector } from '@renderer/store'
import { setGenerating } from '@renderer/store/runtime'
import store, { useAppDispatch, useAppSelector } from '@renderer/store'
import { setGenerating, setSearching } from '@renderer/store/runtime'
import { Assistant, Message, Topic } from '@renderer/types'
import { delay, uuid } from '@renderer/utils'
import { Button, Divider, Popconfirm, Tag, Tooltip } from 'antd'
import { Button, Popconfirm, Tooltip } from 'antd'
import TextArea, { TextAreaRef } from 'antd/es/input/TextArea'
import dayjs from 'dayjs'
import { debounce, isEmpty } from 'lodash'
import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { CSSProperties, FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import SendMessageButton from './SendMessageButton'
import TokenCount from './TokenCount'
interface Props {
assistant: Assistant
@@ -39,12 +40,18 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
const [text, setText] = useState(_text)
const [inputFocus, setInputFocus] = useState(false)
const { addTopic } = useAssistant(assistant.id)
const { sendMessageShortcut, showInputEstimatedTokens, fontSize } = useSettings()
const { sendMessageShortcut, fontSize } = useSettings()
const [expended, setExpend] = useState(false)
const [estimateTokenCount, setEstimateTokenCount] = useState(0)
const [contextCount, setContextCount] = useState(0)
const generating = useAppSelector((state) => state.runtime.generating)
const inputRef = useRef<TextAreaRef>(null)
const textareaRef = useRef<TextAreaRef>(null)
const [files, setFiles] = useState<File[]>([])
const { t } = useTranslation()
const containerRef = useRef(null)
const { showTopics, toggleShowTopics } = useShowTopics()
const { searching } = useRuntime()
const dispatch = useAppDispatch()
_text = text
@@ -67,24 +74,32 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
status: 'success'
}
if (files.length > 0) {
message.files = files
}
EventEmitter.emit(EVENT_NAMES.SEND_MESSAGE, message)
setText('')
setFiles([])
setTimeout(() => setText(''), 500)
setTimeout(() => resizeTextArea(), 0)
setExpend(false)
}, [assistant.id, assistant.topics, generating, text])
}, [assistant.id, assistant.topics, generating, files, text])
const inputTokenCount = useMemo(() => estimateInputTokenCount(text), [text])
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
const isEnterPressed = event.keyCode == 13
if (expended) {
if (event.key === 'Escape') {
return setExpend(false)
}
}
if (sendMessageShortcut === 'Enter' && event.key === 'Enter') {
if (sendMessageShortcut === 'Enter' && isEnterPressed) {
if (event.shiftKey) {
return
}
@@ -92,7 +107,7 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
return event.preventDefault()
}
if (sendMessageShortcut === 'Shift+Enter' && event.key === 'Enter' && event.shiftKey) {
if (sendMessageShortcut === 'Shift+Enter' && isEnterPressed && event.shiftKey) {
sendMessage()
return event.preventDefault()
}
@@ -117,6 +132,40 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
store.dispatch(setGenerating(false))
}
const onNewContext = () => {
if (generating) {
onPause()
return
}
EventEmitter.emit(EVENT_NAMES.NEW_CONTEXT)
}
const resizeTextArea = () => {
const textArea = textareaRef.current?.resizableTextArea?.textArea
if (textArea) {
textArea.style.height = 'auto'
textArea.style.height = textArea?.scrollHeight > 400 ? '400px' : `${textArea?.scrollHeight}px`
}
}
const onToggleExpended = () => {
const isExpended = !expended
setExpend(isExpended)
const textArea = textareaRef.current?.resizableTextArea?.textArea
if (textArea) {
if (isExpended) {
textArea.style.height = '70vh'
} else {
resizeTextArea()
}
}
textareaRef.current?.focus()
}
const onInput = () => !expended && resizeTextArea()
// Command or Ctrl + N create new topic
useEffect(() => {
const onKeydown = (e) => {
@@ -124,7 +173,7 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'n') {
addNewTopic()
EventEmitter.emit(EVENT_NAMES.SHOW_TOPIC_SIDEBAR)
inputRef.current?.focus()
textareaRef.current?.focus()
}
}
}
@@ -137,22 +186,40 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
const unsubscribes = [
EventEmitter.on(EVENT_NAMES.EDIT_MESSAGE, (message: Message) => {
setText(message.content)
inputRef.current?.focus()
textareaRef.current?.focus()
}),
EventEmitter.on(EVENT_NAMES.ESTIMATED_TOKEN_COUNT, _setEstimateTokenCount)
EventEmitter.on(EVENT_NAMES.ESTIMATED_TOKEN_COUNT, ({ tokensCount, contextCount }) => {
_setEstimateTokenCount(tokensCount)
setContextCount(contextCount)
})
]
return () => unsubscribes.forEach((unsub) => unsub())
}, [])
useEffect(() => {
inputRef.current?.focus()
textareaRef.current?.focus()
}, [assistant])
return (
<Container
id="inputbar"
style={{ minHeight: expended ? '60%' : 'var(--input-bar-height)' }}
className={inputFocus ? 'focus' : ''}>
<Container id="inputbar" className={inputFocus ? 'focus' : ''} ref={containerRef}>
<Textarea
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={t('chat.input.placeholder')}
autoFocus
contextMenu="true"
variant="borderless"
rows={1}
ref={textareaRef}
style={{ fontSize }}
styles={{ textarea: TextareaStyle }}
onFocus={() => setInputFocus(true)}
onBlur={() => setInputFocus(false)}
onInput={onInput}
disabled={searching}
onClick={() => searching && dispatch(setSearching(false))}
/>
<Toolbar>
<ToolbarMenu>
<Tooltip placement="top" title={t('chat.input.new_topic')} arrow>
@@ -174,41 +241,38 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
</Popconfirm>
</Tooltip>
<Tooltip placement="top" title={t('chat.input.topics')} arrow>
<ToolbarButton type="text" onClick={() => EventEmitter.emit(EVENT_NAMES.SHOW_TOPIC_SIDEBAR)}>
<ToolbarButton
type="text"
onClick={() => {
!showTopics && toggleShowTopics()
setTimeout(() => EventEmitter.emit(EVENT_NAMES.SHOW_TOPIC_SIDEBAR), 0)
}}>
<HistoryOutlined />
</ToolbarButton>
</Tooltip>
<Tooltip placement="top" title={t('chat.input.settings')} arrow>
<ToolbarButton type="text" onClick={() => EventEmitter.emit(EVENT_NAMES.SHOW_CHAT_SETTINGS)}>
<ToolbarButton
type="text"
onClick={() => {
!showTopics && toggleShowTopics()
setTimeout(() => EventEmitter.emit(EVENT_NAMES.SHOW_CHAT_SETTINGS), 0)
}}>
<ControlOutlined />
</ToolbarButton>
</Tooltip>
{/* <AttachmentButton files={files} setFiles={setFiles} ToolbarButton={ToolbarButton} /> */}
<Tooltip placement="top" title={expended ? t('chat.input.collapse') : t('chat.input.expand')} arrow>
<ToolbarButton type="text" onClick={() => setExpend(!expended)}>
<ToolbarButton type="text" onClick={onToggleExpended}>
{expended ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
</ToolbarButton>
</Tooltip>
{showInputEstimatedTokens && (
<TextCount>
<Tooltip title={t('chat.input.context_count.tip') + ' | ' + t('chat.input.estimated_tokens.tip')}>
<Tag
style={{
cursor: 'pointer',
borderRadius: '6px',
display: 'flex',
alignItems: 'center',
padding: '2px 8px',
borderWidth: 0.5
}}>
<i className="iconfont icon-history" style={{ marginRight: '3px' }} />
{assistant?.settings?.contextCount ?? DEFAULT_CONEXTCOUNT}
<Divider type="vertical" style={{ marginTop: 2, marginLeft: 5, marginRight: 5 }} />{inputTokenCount}
<span style={{ margin: '0 2px' }}>/</span>
{estimateTokenCount}
</Tag>
</Tooltip>
</TextCount>
)}
<TokenCount
estimateTokenCount={estimateTokenCount}
inputTokenCount={inputTokenCount}
contextCount={contextCount}
ToolbarButton={ToolbarButton}
onClick={onNewContext}
/>
</ToolbarMenu>
<ToolbarMenu>
{generating && (
@@ -221,28 +285,16 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
{!generating && <SendMessageButton sendMessage={sendMessage} disabled={generating || !text} />}
</ToolbarMenu>
</Toolbar>
<Textarea
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={t('chat.input.placeholder')}
autoFocus
contextMenu="true"
variant="borderless"
ref={inputRef}
styles={{ textarea: { paddingLeft: 0 } }}
onFocus={() => setInputFocus(true)}
onBlur={() => setInputFocus(false)}
style={{ fontSize }}
/>
</Container>
)
}
const TextareaStyle: CSSProperties = {
paddingLeft: 0,
padding: '10px 15px 8px'
}
const Container = styled.div`
display: flex;
flex-direction: column;
height: var(--input-bar-height);
border: 1px solid var(--color-border-soft);
transition: all 0.3s ease;
position: relative;
@@ -257,11 +309,11 @@ const Textarea = styled(TextArea)`
border-radius: 0;
display: flex;
flex: 1;
margin: 0 15px 5px 15px;
font-family: Ubuntu;
resize: vertical;
overflow: auto;
width: auto;
width: 100%;
box-sizing: border-box;
`
const Toolbar = styled.div`
@@ -269,8 +321,9 @@ const Toolbar = styled.div`
flex-direction: row;
justify-content: space-between;
padding: 0 8px;
padding-top: 3px;
padding-bottom: 0;
margin-bottom: 4px;
height: 36px;
`
const ToolbarMenu = styled.div`
@@ -281,31 +334,30 @@ const ToolbarMenu = styled.div`
`
const ToolbarButton = styled(Button)`
width: 32px;
height: 32px;
font-size: 18px;
width: 30px;
height: 30px;
font-size: 17px;
border-radius: 50%;
transition: all 0.3s ease;
color: var(--color-icon);
&.anticon {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
padding: 0;
&.anticon,
&.iconfont {
transition: all 0.3s ease;
color: var(--color-icon);
}
&:hover {
&:hover,
&.active {
background-color: var(--color-background-soft);
.anticon {
.anticon,
.iconfont {
color: var(--color-text-1);
}
}
`
const TextCount = styled.div`
font-size: 11px;
color: var(--color-text-3);
z-index: 10;
padding: 2px;
border-top-left-radius: 7px;
user-select: none;
`
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 {...props}>
<Popover content={PopoverContent} title="" mouseEnterDelay={0.6}>
<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

@@ -1,7 +1,7 @@
import { CheckOutlined } from '@ant-design/icons'
import CopyIcon from '@renderer/components/Icons/CopyIcon'
import { useTheme } from '@renderer/context/ThemeProvider'
import { initMermaid } from '@renderer/init'
import { useTheme } from '@renderer/providers/ThemeProvider'
import { ThemeMode } from '@renderer/store/settings'
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -37,7 +37,7 @@ const CodeBlock: React.FC<CodeBlockProps> = ({ children, className, ...rest }) =
}
return match ? (
<div>
<>
<CodeHeader>
<CodeLanguage>{'<' + match[1].toUpperCase() + '>'}</CodeLanguage>
{!copied && <CopyIcon className="copy" onClick={onCopy} />}
@@ -48,10 +48,15 @@ const CodeBlock: React.FC<CodeBlockProps> = ({ children, className, ...rest }) =
language={match[1]}
style={theme === ThemeMode.dark ? atomDark : oneLight}
wrapLongLines={true}
customStyle={{ borderTopLeftRadius: 0, borderTopRightRadius: 0, marginTop: 0 }}>
customStyle={{
border: '0.5px solid var(--color-code-background)',
borderTopLeftRadius: 0,
borderTopRightRadius: 0,
marginTop: 0
}}>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
</div>
</>
) : (
<code {...rest} className={className}>
{children}
@@ -67,7 +72,7 @@ const CodeHeader = styled.div`
font-size: 14px;
font-weight: bold;
background-color: var(--color-code-background);
height: 40px;
height: 36px;
padding: 0 10px;
border-top-left-radius: 8px;
border-top-right-radius: 8px;

View File

@@ -1,7 +1,6 @@
import 'katex/dist/katex.min.css'
import { Message } from '@renderer/types'
import { convertMathFormula } from '@renderer/utils'
import { isEmpty } from 'lodash'
import { FC, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
@@ -24,7 +23,7 @@ const Markdown: FC<Props> = ({ message }) => {
const empty = isEmpty(message.content)
const paused = message.status === 'paused'
const content = empty && paused ? t('message.chat.completion.paused') : message.content
return convertMathFormula(content)
return content
}, [message.content, message.status, t])
return useMemo(() => {
@@ -32,7 +31,7 @@ const Markdown: FC<Props> = ({ message }) => {
<ReactMarkdown
className="markdown"
rehypePlugins={[rehypeKatex]}
remarkPlugins={[[remarkMath, { singleDollarTextMath: false }], remarkGfm]}
remarkPlugins={[remarkMath, remarkGfm]}
remarkRehypeOptions={{
footnoteLabel: t('common.footnotes'),
footnoteLabelTagName: 'h4',

View File

@@ -7,16 +7,19 @@ import {
SaveOutlined,
SyncOutlined
} from '@ant-design/icons'
import UserPopup from '@renderer/components/Popups/UserPopup'
import { FONT_FAMILY } from '@renderer/config/constant'
import { startMinAppById } from '@renderer/config/minapp'
import { getModelLogo } from '@renderer/config/provider'
import { useAssistant } from '@renderer/hooks/useAssistant'
import useAvatar from '@renderer/hooks/useAvatar'
import { useModel } from '@renderer/hooks/useModel'
import { useSettings } from '@renderer/hooks/useSettings'
import { useRuntime } from '@renderer/hooks/useStore'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/event'
import { Message, Model } from '@renderer/types'
import { firstLetter, removeLeadingEmoji } from '@renderer/utils'
import { Alert, Avatar, Dropdown, Popconfirm, Tooltip } from 'antd'
import { Alert, Avatar, Divider, Dropdown, Popconfirm, Tooltip } from 'antd'
import dayjs from 'dayjs'
import { upperFirst } from 'lodash'
import { FC, memo, useCallback, useMemo, useState } from 'react'
@@ -25,6 +28,7 @@ import styled from 'styled-components'
import SelectModelDropdown from '../components/SelectModelDropdown'
import Markdown from '../Markdown/Markdown'
import MessageAttachments from './MessageAttachments'
interface Props {
message: Message
@@ -37,7 +41,8 @@ interface Props {
const MessageItem: FC<Props> = ({ message, index, showMenu, onDeleteMessage }) => {
const avatar = useAvatar()
const { t } = useTranslation()
const { assistant, model, setModel } = useAssistant(message.assistantId)
const { assistant, setModel } = useAssistant(message.assistantId)
const model = useModel(message.modelId)
const { userName, showMessageDivider, messageFont, fontSize } = useSettings()
const { generating } = useRuntime()
const [copied, setCopied] = useState(false)
@@ -67,9 +72,9 @@ const MessageItem: FC<Props> = ({ message, index, showMenu, onDeleteMessage }) =
const getUserName = useCallback(() => {
if (message.id === 'assistant') return assistant?.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')
}, [assistant?.name, message.id, message.role, model.id, model.name, t, userName])
}, [assistant?.name, message.id, message.role, model?.id, model?.name, t, userName])
const fontFamily = useMemo(() => {
return messageFont === 'serif' ? FONT_FAMILY.replace('sans-serif', 'serif').replace('Ubuntu, ', '') : FONT_FAMILY
@@ -107,27 +112,51 @@ const MessageItem: FC<Props> = ({ message, index, showMenu, onDeleteMessage }) =
if (message.status === 'error') {
return (
<Alert
message={t('error.chat.response')}
message={<div style={{ fontSize: 14 }}>{t('error.chat.response')}</div>}
description={<Markdown message={message} />}
type="error"
style={{ marginBottom: 15 }}
style={{ marginBottom: 15, padding: 10, fontSize: 12 }}
/>
)
}
return <Markdown message={message} />
}, [message])
return (
<>
<Markdown 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%' }}>
<Avatar
src={avatarSource}
size={35}
style={{ borderRadius: '20%', cursor: 'pointer' }}
onClick={showMiniApp}>
{avatarName}
</Avatar>
) : (
<Avatar src={avatar} size={35} style={{ borderRadius: '20%' }} />
<Avatar
src={avatar}
size={35}
style={{ borderRadius: '20%', cursor: 'pointer' }}
onClick={() => UserPopup.show()}
/>
)}
<UserWrap>
<UserName>{username}</UserName>
@@ -154,7 +183,7 @@ const MessageItem: FC<Props> = ({ message, index, showMenu, onDeleteMessage }) =
</ActionButton>
</Tooltip>
{canRegenerate && (
<SelectModelDropdown model={model} onSelect={onRegenerate} placement="topRight">
<SelectModelDropdown model={model} onSelect={onRegenerate} placement="topLeft">
<Tooltip title={t('common.regenerate')} mouseEnterDelay={0.8}>
<ActionButton>
<SyncOutlined />

View File

@@ -0,0 +1,25 @@
import { Message } from '@renderer/types'
import { Image as AntdImage } from 'antd'
import { FC } from 'react'
import styled from 'styled-components'
interface Props {
message: Message
}
const MessageAttachments: FC<Props> = ({ message }) => {
return <Container>{message.images?.map((image) => <Image src={image} key={image} width="33%" />)}</Container>
}
const Container = styled.div`
display: flex;
flex-direction: row;
gap: 10px;
margin-top: 8px;
`
const Image = styled(AntdImage)`
border-radius: 10px;
`
export default MessageAttachments

View File

@@ -3,7 +3,7 @@ import { useProviderByAssistant } from '@renderer/hooks/useProvider'
import { getTopic } from '@renderer/hooks/useTopic'
import { fetchChatCompletion, fetchMessagesSummary } from '@renderer/services/api'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/event'
import { estimateHistoryTokenCount, filterMessages } from '@renderer/services/messages'
import { estimateHistoryTokenCount, filterMessages, getContextCount } from '@renderer/services/messages'
import LocalStorage from '@renderer/services/storage'
import { Assistant, Message, Model, Topic } from '@renderer/types'
import { getBriefInfo, runAsyncFunction, uuid } from '@renderer/utils'
@@ -20,9 +20,10 @@ import Prompt from './Prompt'
interface Props {
assistant: Assistant
topic: Topic
setActiveTopic: (topic: Topic) => void
}
const Messages: FC<Props> = ({ assistant, topic }) => {
const Messages: FC<Props> = ({ assistant, topic, setActiveTopic }) => {
const [messages, setMessages] = useState<Message[]>([])
const [lastMessage, setLastMessage] = useState<Message | null>(null)
const provider = useProviderByAssistant(assistant)
@@ -33,7 +34,7 @@ const Messages: FC<Props> = ({ assistant, topic }) => {
(message: Message) => {
const _messages = [...messages, message]
setMessages(_messages)
localforage.setItem(`topic:${topic.id}`, { ...topic, messages: _messages })
localforage.setItem(`topic:${topic.id}`, { id: topic.id, messages: _messages })
},
[messages, topic]
)
@@ -42,9 +43,13 @@ const Messages: FC<Props> = ({ assistant, topic }) => {
const _topic = getTopic(assistant, topic.id)
if (_topic && _topic.name === t('chat.default.topic.name') && messages.length >= 2) {
const summaryText = await fetchMessagesSummary({ messages, assistant })
summaryText && updateTopic({ ..._topic, name: summaryText })
if (summaryText) {
const data = { ..._topic, name: summaryText }
setActiveTopic(data)
updateTopic(data)
}
}
}, [assistant, messages, topic, updateTopic])
}, [assistant, messages, setActiveTopic, topic.id, updateTopic])
const onDeleteMessage = useCallback(
(message: Message) => {
@@ -84,6 +89,28 @@ const Messages: FC<Props> = ({ assistant, topic }) => {
setMessages([])
updateTopic({ ...topic, messages: [] })
LocalStorage.clearTopicMessages(topic.id)
}),
EventEmitter.on(EVENT_NAMES.NEW_CONTEXT, () => {
const lastMessage = last(messages)
if (lastMessage && lastMessage.type === 'clear') {
return
}
if (messages.length === 0) {
return
}
onSendMessage({
id: uuid(),
assistantId: assistant.id,
role: 'user',
content: '',
topicId: topic.id,
createdAt: new Date().toISOString(),
status: 'success',
type: 'clear'
} as Message)
})
]
return () => unsubscribes.forEach((unsub) => unsub())
@@ -101,7 +128,10 @@ const Messages: FC<Props> = ({ assistant, topic }) => {
}, [messages])
useEffect(() => {
EventEmitter.emit(EVENT_NAMES.ESTIMATED_TOKEN_COUNT, estimateHistoryTokenCount(assistant, messages))
EventEmitter.emit(EVENT_NAMES.ESTIMATED_TOKEN_COUNT, {
tokensCount: estimateHistoryTokenCount(assistant, messages),
contextCount: getContextCount(assistant, messages)
})
}, [assistant, messages])
return (
@@ -117,6 +147,7 @@ const Messages: FC<Props> = ({ assistant, topic }) => {
}
const Container = styled.div`
position: relative;
display: flex;
flex-direction: column;
overflow-y: auto;

View File

@@ -0,0 +1,132 @@
import { Navbar, NavbarLeft, NavbarRight } from '@renderer/components/app/Navbar'
import { HStack } from '@renderer/components/Layout'
import AddAssistantPopup from '@renderer/components/Popups/AddAssistantPopup'
import AssistantSettingPopup from '@renderer/components/Popups/AssistantSettingPopup'
import { isMac, isWindows } from '@renderer/config/constant'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { useSettings } from '@renderer/hooks/useSettings'
import { useShowAssistants, useShowTopics } from '@renderer/hooks/useStore'
import { syncAsistantToAgent } from '@renderer/services/assistant'
import { Assistant, Topic } from '@renderer/types'
import { Switch } from 'antd'
import { FC, useCallback } from 'react'
import styled from 'styled-components'
import SelectModelButton from './components/SelectModelButton'
interface Props {
activeAssistant: Assistant
activeTopic: Topic
setActiveAssistant: (assistant: Assistant) => void
}
const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant }) => {
const { assistant, updateAssistant } = useAssistant(activeAssistant.id)
const { showAssistants, toggleShowAssistants } = useShowAssistants()
const { theme, toggleTheme } = useTheme()
const { topicPosition } = useSettings()
const { showTopics, toggleShowTopics } = useShowTopics()
const onCreateAssistant = async () => {
const assistant = await AddAssistantPopup.show()
assistant && setActiveAssistant(assistant)
}
const onEditAssistant = useCallback(async () => {
const _assistant = await AssistantSettingPopup.show({ assistant })
updateAssistant(_assistant)
syncAsistantToAgent(_assistant)
}, [assistant, updateAssistant])
return (
<Navbar>
{showAssistants && (
<NavbarLeft style={{ justifyContent: 'space-between', borderRight: 'none', padding: '0 8px' }}>
<NewButton onClick={toggleShowAssistants} style={{ marginLeft: isMac ? 8 : 0 }}>
<i className="iconfont icon-hide-sidebar" />
</NewButton>
<NewButton onClick={onCreateAssistant}>
<i className="iconfont icon-a-addchat" />
</NewButton>
</NavbarLeft>
)}
<NavbarRight style={{ justifyContent: 'space-between', paddingRight: isWindows ? 140 : 12, flex: 1 }}>
<HStack alignItems="center">
{!showAssistants && (
<NewButton
onClick={() => toggleShowAssistants()}
style={{ marginRight: isMac ? 8 : 25, marginLeft: isMac ? 4 : 0 }}>
<i className="iconfont icon-show-sidebar" />
</NewButton>
)}
<TitleText style={{ marginRight: 10, cursor: 'pointer' }} className="nodrag" onClick={onEditAssistant}>
{assistant.name}
</TitleText>
<SelectModelButton assistant={assistant} />
</HStack>
<HStack alignItems="center">
<ThemeSwitch
checkedChildren={<i className="iconfont icon-theme icon-dark1" />}
unCheckedChildren={<i className="iconfont icon-theme icon-theme-light" />}
checked={theme === 'dark'}
onChange={toggleTheme}
/>
{topicPosition === 'right' && (
<NewButton onClick={toggleShowTopics}>
<i className={`iconfont icon-${showTopics ? 'show' : 'hide'}-sidebar`} />
</NewButton>
)}
</HStack>
</NavbarRight>
</Navbar>
)
}
export const NewButton = styled.div`
-webkit-app-region: none;
border-radius: 4px;
height: 30px;
padding: 0 7px;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
transition: all 0.2s ease-in-out;
cursor: pointer;
.iconfont {
font-size: 19px;
color: var(--color-icon);
&.icon-a-addchat {
font-size: 20px;
}
&.icon-a-darkmode {
font-size: 20px;
}
}
.anticon {
color: var(--color-icon);
font-size: 17px;
}
&:hover {
background-color: var(--color-background-mute);
color: var(--color-icon-white);
}
`
const TitleText = styled.span`
margin-left: 5px;
font-family: Ubuntu;
font-size: 13px;
font-weight: 500;
`
const ThemeSwitch = styled(Switch)`
-webkit-app-region: no-drag;
margin-right: 10px;
.icon-theme {
font-size: 14px;
}
`
export default HeaderNavbar

View File

@@ -0,0 +1,142 @@
import { BarsOutlined, SettingOutlined } from '@ant-design/icons'
import { useAssistants, useDefaultAssistant } from '@renderer/hooks/useAssistant'
import { useSettings } from '@renderer/hooks/useSettings'
import { useShowTopics } from '@renderer/hooks/useStore'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/event'
import { Assistant, Topic } from '@renderer/types'
import { uuid } from '@renderer/utils'
import { Segmented, SegmentedProps } from 'antd'
import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import Assistants from './Assistants'
import Settings from './Settings'
import Topics from './Topics'
interface Props {
activeAssistant: Assistant
activeTopic: Topic
setActiveAssistant: (assistant: Assistant) => void
setActiveTopic: (topic: Topic) => void
position: 'left' | 'right'
}
type Tab = 'assistants' | 'topic' | 'settings'
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 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('common.assistant'),
value: 'assistants',
icon: <i className="iconfont icon-business-smart-assistant" />
}
const onCreateDefaultAssistant = () => {
const assistant = { ...defaultAssistant, id: uuid() }
addAssistant(assistant)
setActiveAssistant(assistant)
}
useEffect(() => {
const unsubscribes = [
EventEmitter.on(EVENT_NAMES.SHOW_ASSISTANTS, (): any => {
showTab && setTab('assistants')
}),
EventEmitter.on(EVENT_NAMES.SHOW_TOPIC_SIDEBAR, (): any => {
showTab && setTab('topic')
}),
EventEmitter.on(EVENT_NAMES.SHOW_CHAT_SETTINGS, (): any => {
showTab && setTab('settings')
}),
EventEmitter.on(EVENT_NAMES.SWITCH_TOPIC_SIDEBAR, () => {
showTab && setTab('topic')
if (position === 'left' && topicPosition === 'right') {
toggleShowTopics()
}
})
]
return () => unsubscribes.forEach((unsub) => unsub())
}, [position, showTab, tab, toggleShowTopics, topicPosition])
useEffect(() => {
if (position === 'right' && topicPosition === 'right' && tab === 'assistants') {
setTab('topic')
}
if (position === 'left' && topicPosition === 'right' && tab !== 'assistants') {
setTab('assistants')
}
}, [position, tab, topicPosition])
return (
<Container style={border}>
{showTab && (
<Segmented
value={tab}
className="segmented-tab"
style={{ borderRadius: 0, padding: '10px', gap: 2, borderBottom: borderStyle }}
options={
[
position === 'left' && topicPosition === 'left' ? assistantTab : undefined,
{ 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')}
block
/>
)}
<TabContent>
{tab === 'assistants' && (
<Assistants
activeAssistant={activeAssistant}
setActiveAssistant={setActiveAssistant}
onCreateAssistant={onCreateDefaultAssistant}
/>
)}
{tab === 'topic' && (
<Topics assistant={activeAssistant} activeTopic={activeTopic} setActiveTopic={setActiveTopic} />
)}
{tab === 'settings' && <Settings assistant={activeAssistant} />}
</TabContent>
</Container>
)
}
const Container = styled.div`
display: flex;
flex-direction: column;
width: var(--assistants-width);
height: calc(100vh - var(--navbar-height));
.collapsed {
width: 0;
border-left: none;
}
`
const TabContent = styled.div`
display: flex;
flex: 1;
flex-direction: column;
overflow-y: auto;
overflow-x: hidden;
`
export default RightSidebar

View File

@@ -1,96 +0,0 @@
import { BarsOutlined, SettingOutlined } from '@ant-design/icons'
import { useShowRightSidebar } from '@renderer/hooks/useStore'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/event'
import { Assistant, Topic } from '@renderer/types'
import { Segmented } from 'antd'
import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import SettingsTab from './SettingsTab'
import TopicsTab from './TopicsTab'
interface Props {
assistant: Assistant
activeTopic: Topic
setActiveTopic: (topic: Topic) => void
}
const RightSidebar: FC<Props> = (props) => {
const [tab, setTab] = useState<'topic' | 'settings'>('topic')
const { rightSidebarShown, showRightSidebar, hideRightSidebar } = useShowRightSidebar()
const { t } = useTranslation()
const isTopicTab = tab === 'topic'
const isSettingsTab = tab === 'settings'
useEffect(() => {
const unsubscribes = [
EventEmitter.on(EVENT_NAMES.SHOW_TOPIC_SIDEBAR, (): any => {
if (rightSidebarShown && isTopicTab) {
return hideRightSidebar()
}
if (rightSidebarShown) {
return setTab('topic')
}
showRightSidebar()
setTab('topic')
}),
EventEmitter.on(EVENT_NAMES.SHOW_CHAT_SETTINGS, (): any => {
if (rightSidebarShown && isSettingsTab) {
return hideRightSidebar()
}
if (rightSidebarShown) {
return setTab('settings')
}
showRightSidebar()
setTab('settings')
}),
EventEmitter.on(EVENT_NAMES.SWITCH_TOPIC_SIDEBAR, () => setTab('topic'))
]
return () => unsubscribes.forEach((unsub) => unsub())
}, [hideRightSidebar, isSettingsTab, isTopicTab, rightSidebarShown, showRightSidebar])
if (!rightSidebarShown) {
return null
}
return (
<Container>
<Segmented
value={tab}
style={{ borderRadius: 0, padding: '10px', gap: 5, borderBottom: '0.5px solid var(--color-border)' }}
options={[
{ label: t('common.topics'), value: 'topic', icon: <BarsOutlined /> },
{ label: t('settings.title'), value: 'settings', icon: <SettingOutlined /> }
]}
block
onChange={(value) => setTab(value as 'topic' | 'settings')}
/>
<TabContent>
{tab === 'topic' && <TopicsTab {...props} />}
{tab === 'settings' && <SettingsTab assistant={props.assistant} />}
</TabContent>
</Container>
)
}
const Container = styled.div`
display: flex;
flex-direction: column;
width: var(--topic-list-width);
height: calc(100vh - var(--navbar-height));
border-left: 0.5px solid var(--color-border);
.collapsed {
width: 0;
border-left: none;
}
`
const TabContent = styled.div`
display: flex;
flex: 1;
flex-direction: column;
overflow-y: auto;
`
export default RightSidebar

View File

@@ -87,7 +87,7 @@ const SettingsTab: FC<Props> = (props) => {
return (
<Container>
<SettingSubtitle>
<SettingSubtitle style={{ marginTop: 5 }}>
{t('settings.messages.model.title')}{' '}
<Tooltip title={t('chat.settings.reset')}>
<ReloadOutlined onClick={onReset} style={{ cursor: 'pointer', fontSize: 12, padding: '0 3px' }} />
@@ -104,7 +104,7 @@ const SettingsTab: FC<Props> = (props) => {
<Col span={24}>
<Slider
min={0}
max={1.2}
max={2}
onChange={setTemperature}
onChangeComplete={onTemperatureChange}
value={typeof temperature === 'number' ? temperature : 0}
@@ -187,10 +187,7 @@ const SettingsTab: FC<Props> = (props) => {
<Slider
value={fontSizeValue}
onChange={(value) => setFontSizeValue(value)}
onChangeComplete={(value) => {
dispatch(setFontSize(value))
console.debug('set font size', value)
}}
onChangeComplete={(value) => dispatch(setFontSize(value))}
min={12}
max={18}
step={1}
@@ -234,7 +231,9 @@ const Container = styled.div`
display: flex;
flex: 1;
flex-direction: column;
padding: 0 15px;
overflow: hidden;
padding-bottom: 10px;
padding: 10px 15px;
`
const Label = styled.p`

View File

@@ -1,13 +1,13 @@
import { DeleteOutlined, EditOutlined, OpenAIOutlined } from '@ant-design/icons'
import { DragDropContext, Draggable, Droppable, DropResult } from '@hello-pangea/dnd'
import { CloseOutlined, DeleteOutlined, EditOutlined, OpenAIOutlined } from '@ant-design/icons'
import DragableList from '@renderer/components/DragableList'
import PromptPopup from '@renderer/components/Popups/PromptPopup'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { fetchMessagesSummary } from '@renderer/services/api'
import LocalStorage from '@renderer/services/storage'
import { useAppSelector } from '@renderer/store'
import { Assistant, Topic } from '@renderer/types'
import { droppableReorder } from '@renderer/utils'
import { Dropdown, MenuProps } from 'antd'
import { findIndex } from 'lodash'
import { FC, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@@ -18,11 +18,33 @@ interface Props {
setActiveTopic: (topic: Topic) => void
}
const TopicsTab: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic }) => {
const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic }) => {
const { assistant, removeTopic, updateTopic, updateTopics } = useAssistant(_assistant.id)
const { t } = useTranslation()
const generating = useAppSelector((state) => state.runtime.generating)
const onDeleteTopic = useCallback(
(topic: Topic) => {
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, removeTopic, setActiveTopic]
)
const onSwitchTopic = useCallback(
(topic: Topic) => {
if (generating) {
window.message.warning({ content: t('message.switch.disabled'), key: 'switch-assistant' })
return
}
setActiveTopic(topic)
},
[generating, setActiveTopic, t]
)
const getTopicMenuItems = useCallback(
(topic: Topic) => {
const menus: MenuProps['items'] = [
@@ -64,70 +86,39 @@ const TopicsTab: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTop
danger: true,
key: 'delete',
icon: <DeleteOutlined />,
onClick() {
if (assistant.topics.length === 1) return
removeTopic(topic)
setActiveTopic(assistant.topics[0])
}
onClick: () => onDeleteTopic(topic)
})
}
return menus
},
[assistant, removeTopic, setActiveTopic, t, updateTopic]
)
const onDragEnd = useCallback(
(result: DropResult) => {
if (result.destination) {
const sourceIndex = result.source.index
const destIndex = result.destination.index
updateTopics(droppableReorder(assistant.topics, sourceIndex, destIndex))
}
},
[assistant.topics, updateTopics]
)
const onSwitchTopic = useCallback(
(topic: Topic) => {
if (generating) {
window.message.warning({ content: t('message.switch.disabled'), key: 'switch-assistant' })
return
}
setActiveTopic(topic)
},
[generating, setActiveTopic, t]
[assistant, onDeleteTopic, t, updateTopic]
)
return (
<Container>
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="droppable">
{(provided) => (
<div {...provided.droppableProps} ref={provided.innerRef}>
{assistant.topics.map((topic, index) => (
<Draggable key={`draggable_${topic.id}_${index}`} draggableId={topic.id} index={index}>
{(provided) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
style={{ ...provided.draggableProps.style, marginBottom: 5 }}>
<Dropdown menu={{ items: getTopicMenuItems(topic) }} trigger={['contextMenu']} key={topic.id}>
<TopicListItem
className={topic.id === activeTopic?.id ? 'active' : ''}
onClick={() => onSwitchTopic(topic)}>
{topic.name}
</TopicListItem>
</Dropdown>
</div>
)}
</Draggable>
))}
</div>
)}
</Droppable>
</DragDropContext>
<DragableList list={assistant.topics} onUpdate={updateTopics}>
{(topic) => {
const isActive = topic.id === activeTopic?.id
return (
<Dropdown menu={{ items: getTopicMenuItems(topic) }} trigger={['contextMenu']} key={topic.id}>
<TopicListItem className={isActive ? 'active' : ''} onClick={() => onSwitchTopic(topic)}>
<TopicName>{topic.name}</TopicName>
{assistant.topics.length > 1 && (
<MenuButton
className="menu"
onClick={(e) => {
e.stopPropagation()
onDeleteTopic(topic)
}}>
<CloseOutlined />
</MenuButton>
)}
</TopicListItem>
</Dropdown>
)
}}
</DragableList>
</Container>
)
}
@@ -136,23 +127,65 @@ const Container = styled.div`
display: flex;
flex: 1;
flex-direction: column;
padding: 10px 10px;
padding-top: 10px;
overflow-y: scroll;
max-height: calc(100vh - var(--navbar-height) - 140px);
`
const TopicListItem = styled.div`
padding: 7px 10px;
margin: 0 10px;
cursor: pointer;
border-radius: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-family: Ubuntu;
&:hover {
background-color: var(--color-background-soft);
font-size: 13px;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
position: relative;
.menu {
opacity: 0;
color: var(--color-text-3);
}
&.active {
background-color: var(--color-background-mute);
font-weight: 500;
.menu {
opacity: 1;
background-color: var(--color-background-mute);
&:hover {
color: var(--color-text-2);
}
}
}
`
export default TopicsTab
const TopicName = styled.div`
color: var(--color-text);
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
font-size: 13px;
`
const MenuButton = styled.div`
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
width: 30px;
height: 24px;
min-width: 24px;
min-height: 24px;
border-radius: 4px;
position: absolute;
right: 10px;
top: 5px;
.anticon {
font-size: 12px;
}
`
export default Topics

View File

@@ -1,4 +1,5 @@
import ModelAvatar from '@renderer/components/Avatar/ModelAvatar'
import { isLocalAi } from '@renderer/config/env'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { Assistant } from '@renderer/types'
import { Button } from 'antd'
@@ -17,8 +18,12 @@ const SelectModelButton: FC<Props> = ({ assistant }) => {
const { model, setModel } = useAssistant(assistant.id)
const { t } = useTranslation()
if (isLocalAi) {
return null
}
return (
<SelectModelDropdown model={model} onSelect={setModel}>
<SelectModelDropdown model={model} onSelect={setModel} placement="top">
<DropdownButton size="small" type="default">
<ModelAvatar model={model} size={20} />
<ModelName>{model ? upperFirst(model.name) : t('button.select_model')}</ModelName>

View File

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

View File

@@ -1,8 +1,8 @@
import { GithubOutlined } from '@ant-design/icons'
import { FileProtectOutlined, GlobalOutlined, MailOutlined, SoundOutlined } from '@ant-design/icons'
import Logo from '@renderer/assets/images/logo.png'
import { HStack } from '@renderer/components/Layout'
import MinApp from '@renderer/components/MinApp'
import { APP_NAME, AppLogo } from '@renderer/config/env'
import { runAsyncFunction } from '@renderer/utils'
import { Avatar, Button, Progress, Row, Tag } from 'antd'
import { ProgressInfo } from 'electron-updater'
@@ -44,7 +44,7 @@ const AboutSettings: FC = () => {
const mailto = async () => {
const email = 'kangfenmao@qq.com'
const subject = 'Cherry Studio Feedback'
const subject = `${APP_NAME} Feedback`
const version = (await window.api.getAppInfo()).version
const platform = window.electron.process.platform
const url = `mailto:${email}?subject=${subject}&body=%0A%0AVersion: ${version} | Platform: ${platform}`
@@ -117,10 +117,10 @@ const AboutSettings: FC = () => {
strokeColor="#67ad5b"
/>
)}
<Avatar src={Logo} size={80} style={{ minHeight: 80 }} />
<Avatar src={AppLogo} size={80} style={{ minHeight: 80 }} />
</AvatarWrapper>
<VersionWrapper>
<Title>Cherry Studio</Title>
<Title>{APP_NAME}</Title>
<Description>{t('settings.about.description')}</Description>
<Tag
onClick={() => onOpenWebsite('https://github.com/kangfenmao/cherry-studio/releases')}

View File

@@ -108,18 +108,18 @@ const AssistantSettings: FC = () => {
<Col span={21}>
<Slider
min={0}
max={1.2}
max={2}
onChange={setTemperature}
onChangeComplete={onTemperatureChange}
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}
/>
</Col>
<Col span={3}>
<InputNumber
min={0}
max={1.2}
max={2}
step={0.1}
value={temperature}
onChange={onTemperatureChange}

View File

@@ -1,25 +1,31 @@
import { FolderOpenOutlined, SaveOutlined } from '@ant-design/icons'
import { HStack } from '@renderer/components/Layout'
import useAvatar from '@renderer/hooks/useAvatar'
import { isMac } from '@renderer/config/constant'
import { useSettings } from '@renderer/hooks/useSettings'
import i18n from '@renderer/i18n'
import { backup, reset, restore } from '@renderer/services/backup'
import LocalStorage from '@renderer/services/storage'
import { useAppDispatch } from '@renderer/store'
import { setAvatar } from '@renderer/store/runtime'
import { setLanguage, setUserName, ThemeMode } from '@renderer/store/settings'
import { setProxyUrl as _setProxyUrl } from '@renderer/store/settings'
import { compressImage, isValidProxyUrl } from '@renderer/utils'
import { Avatar, Button, Input, Select, Upload } from 'antd'
import { isValidProxyUrl } from '@renderer/utils'
import { Button, Input, Select } from 'antd'
import { FC, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import { SettingContainer, SettingDivider, SettingRow, SettingRowTitle, SettingTitle } from '.'
const GeneralSettings: FC = () => {
const avatar = useAvatar()
const { language, proxyUrl: storeProxyUrl, userName, theme, setTheme } = useSettings()
const {
language,
proxyUrl: storeProxyUrl,
userName,
theme,
windowStyle,
topicPosition,
setTheme,
setWindowStyle,
setTopicPosition
} = useSettings()
const [proxyUrl, setProxyUrl] = useState<string | undefined>(storeProxyUrl)
const dispatch = useAppDispatch()
const { t } = useTranslation()
@@ -48,7 +54,7 @@ const GeneralSettings: FC = () => {
<SettingRowTitle>{t('common.language')}</SettingRowTitle>
<Select
defaultValue={language || 'en-US'}
style={{ width: 120 }}
style={{ width: 180 }}
onChange={onSelectLanguage}
options={[
{ value: 'zh-CN', label: '中文' },
@@ -61,7 +67,7 @@ const GeneralSettings: FC = () => {
<SettingRowTitle>{t('settings.theme.title')}</SettingRowTitle>
<Select
defaultValue={theme}
style={{ width: 120 }}
style={{ width: 180 }}
onChange={setTheme}
options={[
{ value: ThemeMode.light, label: t('settings.theme.light') },
@@ -71,25 +77,32 @@ const GeneralSettings: FC = () => {
/>
</SettingRow>
<SettingDivider />
{isMac && (
<SettingRow>
<SettingRowTitle>{t('settings.theme.window.style.title')}</SettingRowTitle>
<Select
defaultValue={windowStyle || 'opaque'}
style={{ width: 180 }}
onChange={setWindowStyle}
options={[
{ value: 'transparent', label: t('settings.theme.window.style.transparent') },
{ value: 'opaque', label: t('settings.theme.window.style.opaque') }
]}
/>
</SettingRow>
)}
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('common.avatar')}</SettingRowTitle>
<Upload
customRequest={() => {}}
accept="image/png, image/jpeg"
itemRender={() => null}
maxCount={1}
onChange={async ({ file }) => {
try {
const _file = file.originFileObj as File
const compressedFile = await compressImage(_file)
await LocalStorage.storeImage('avatar', compressedFile)
dispatch(setAvatar(await LocalStorage.getImage('avatar')))
} catch (error: any) {
window.message.error(error.message)
}
}}>
<UserAvatar src={avatar} size="large" />
</Upload>
<SettingRowTitle>{t('settings.topic.position')}</SettingRowTitle>
<Select
defaultValue={topicPosition || 'right'}
style={{ width: 180 }}
onChange={setTopicPosition}
options={[
{ value: 'left', label: t('settings.topic.position.left') },
{ value: 'right', label: t('settings.topic.position.right') }
]}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
@@ -98,7 +111,7 @@ const GeneralSettings: FC = () => {
placeholder={t('settings.general.user_name.placeholder')}
value={userName}
onChange={(e) => dispatch(setUserName(e.target.value))}
style={{ width: 150 }}
style={{ width: 180 }}
maxLength={30}
/>
</SettingRow>
@@ -109,7 +122,7 @@ const GeneralSettings: FC = () => {
placeholder="socks5://127.0.0.1:6153"
value={proxyUrl}
onChange={(e) => setProxyUrl(e.target.value)}
style={{ width: 300 }}
style={{ width: 180 }}
onBlur={() => onSetProxyUrl()}
type="url"
/>
@@ -117,12 +130,12 @@ const GeneralSettings: FC = () => {
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.general.backup.title')}</SettingRowTitle>
<HStack gap="5px">
<HStack gap="5px" justifyContent="space-between">
<Button onClick={backup} icon={<SaveOutlined />}>
{t('settings.general.backup.button')}
</Button>
<Button onClick={restore} icon={<FolderOpenOutlined />}>
{t('settings.general.restore.button')}
</Button>
</HStack>
</SettingRow>
@@ -140,8 +153,4 @@ const GeneralSettings: FC = () => {
)
}
const UserAvatar = styled(Avatar)`
cursor: pointer;
`
export default GeneralSettings

View File

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

View File

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

View File

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

View File

@@ -107,7 +107,8 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
styles={{
content: { padding: 0 },
header: { padding: 22, paddingBottom: 15 }
}}>
}}
centered>
<SearchContainer>
<Search placeholder={t('settings.provider.search_placeholder')} allowClear onSearch={setSearchText} />
</SearchContainer>
@@ -154,7 +155,8 @@ const onShowModelInfo = (model: Model) => {
title: model.name,
content: model?.description,
icon: null,
maskClosable: true
maskClosable: true,
width: 600
})
}

View File

@@ -8,13 +8,13 @@ import {
} from '@ant-design/icons'
import { getModelLogo } from '@renderer/config/provider'
import { PROVIDER_CONFIG } from '@renderer/config/provider'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useProvider } from '@renderer/hooks/useProvider'
import { useTheme } from '@renderer/providers/ThemeProvider'
import { checkApi } from '@renderer/services/api'
import { Provider } from '@renderer/types'
import { Avatar, Button, Card, Divider, Flex, Input, Space, Switch } from 'antd'
import Link from 'antd/es/typography/Link'
import { groupBy } from 'lodash'
import { groupBy, isEmpty } from 'lodash'
import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@@ -72,7 +72,6 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
const docsWebsite = providerConfig?.websites?.docs
const modelsWebsite = providerConfig?.websites?.models
const configedApiHost = providerConfig?.api?.url
const apiEditable = provider.isSystem ? providerConfig?.api?.editable : true
const onReset = () => {
setApiHost(configedApiHost)
@@ -131,9 +130,10 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
placeholder={t('settings.provider.api_host')}
onChange={(e) => setApiHost(e.target.value)}
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>
{provider.id === 'ollama' && <OllamSettings />}
{provider.id === 'graphrag-kylin-mountain' && provider.models.length > 0 && (

View File

@@ -1,5 +1,4 @@
import { PlusOutlined } from '@ant-design/icons'
import { DeleteOutlined, EditOutlined } from '@ant-design/icons'
import { DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons'
import { DragDropContext, Draggable, Droppable, DropResult } from '@hello-pangea/dnd'
import { getProviderLogo } from '@renderer/config/provider'
import { useAllProviders, useProviders } from '@renderer/hooks/useProvider'
@@ -72,6 +71,7 @@ const ProvidersList: FC = () => {
content: t('settings.provider.delete.content'),
okButtonProps: { danger: true },
okText: t('common.delete'),
centered: true,
onOk: () => {
setSelectedProvider(providers.filter((p) => p.isSystem)[0])
removeProvider(provider)

View File

@@ -1,11 +1,6 @@
import {
CloudOutlined,
CodeSandboxOutlined,
InfoCircleOutlined,
MessageOutlined,
SettingOutlined
} from '@ant-design/icons'
import { CloudOutlined, InfoCircleOutlined, MessageOutlined, SettingOutlined } from '@ant-design/icons'
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
import { isLocalAi } from '@renderer/config/env'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { Link, Route, Routes, useLocation } from 'react-router-dom'
@@ -30,18 +25,22 @@ const SettingsPage: FC = () => {
</Navbar>
<ContentContainer>
<SettingMenus>
<MenuItemLink to="/settings/provider">
<MenuItem className={isRoute('/settings/provider')}>
<CloudOutlined />
{t('settings.provider')}
</MenuItem>
</MenuItemLink>
<MenuItemLink to="/settings/model">
<MenuItem className={isRoute('/settings/model')}>
<CodeSandboxOutlined />
{t('settings.model')}
</MenuItem>
</MenuItemLink>
{!isLocalAi && (
<>
<MenuItemLink to="/settings/provider">
<MenuItem className={isRoute('/settings/provider')}>
<CloudOutlined />
{t('settings.provider')}
</MenuItem>
</MenuItemLink>
<MenuItemLink to="/settings/model">
<MenuItem className={isRoute('/settings/model')}>
<i className="iconfont icon-ai-model" />
{t('settings.model')}
</MenuItem>
</MenuItemLink>
</>
)}
<MenuItemLink to="/settings/assistant">
<MenuItem className={isRoute('/settings/assistant')}>
<MessageOutlined />
@@ -91,7 +90,7 @@ const ContentContainer = styled.div`
const SettingMenus = styled.ul`
display: flex;
flex-direction: column;
min-width: var(--assistants-width);
min-width: var(--settings-width);
border-right: 0.5px solid var(--color-border);
padding: 10px;
`
@@ -117,6 +116,11 @@ const MenuItem = styled.li`
font-size: 16px;
opacity: 0.8;
}
.iconfont {
font-size: 18px;
opacity: 0.7;
margin-left: -1px;
}
&:hover {
background: var(--color-background-soft);
}

View File

@@ -2,8 +2,6 @@ import { Divider } from 'antd'
import Link from 'antd/es/typography/Link'
import styled from 'styled-components'
import SettingsPage from './SettingsPage'
export const SettingContainer = styled.div`
display: flex;
flex-direction: column;
@@ -69,5 +67,3 @@ export const SettingHelpLink = styled(Link)`
font-size: 11px;
padding: 0 5px;
`
export default SettingsPage

View File

@@ -1,6 +1,7 @@
import { CheckOutlined, SendOutlined, SettingOutlined, SwapOutlined, WarningOutlined } from '@ant-design/icons'
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
import CopyIcon from '@renderer/components/Icons/CopyIcon'
import { isLocalAi } from '@renderer/config/env'
import { useDefaultModel } from '@renderer/hooks/useAssistant'
import { fetchTranslate } from '@renderer/services/api'
import { getDefaultAssistant } from '@renderer/services/assistant'
@@ -133,6 +134,31 @@ const TranslatePage: FC = () => {
isEmpty(text) && setResult('')
}, [text])
const SettingButton = () => {
if (isLocalAi) {
return null
}
if (translateModel) {
return (
<Link to="/settings/model" style={{ color: 'var(--color-text-2)' }}>
<SettingOutlined />
</Link>
)
}
return (
<Link to="/settings/model" style={{ marginLeft: -10 }}>
<Button
type="link"
style={{ color: 'var(--color-error)', textDecoration: 'underline' }}
icon={<WarningOutlined />}>
{t('translate.error.not_configured')}
</Button>
</Link>
)
}
return (
<Container>
<Navbar>
@@ -165,21 +191,7 @@ const TranslatePage: FC = () => {
</Space>
)}
/>
{translateModel && (
<Link to="/settings/model" style={{ color: 'var(--color-text-2)' }}>
<SettingOutlined />
</Link>
)}
{!translateModel && (
<Link to="/settings/model" style={{ marginLeft: -10 }}>
<Button
type="link"
style={{ color: 'var(--color-error)', textDecoration: 'underline' }}
icon={<WarningOutlined />}>
{t('translate.error.not_configured')}
</Button>
</Link>
)}
<SettingButton />
</MenuContainer>
<TranslateInputWrapper>
<InputContainer>

View File

@@ -0,0 +1,40 @@
import BaseProvider from '@renderer/providers/BaseProvider'
import ProviderFactory from '@renderer/providers/ProviderFactory'
import { Assistant, Message, Provider, Suggestion } from '@renderer/types'
import OpenAI from 'openai'
export default class AiProvider {
private sdk: BaseProvider
constructor(provider: Provider) {
this.sdk = ProviderFactory.create(provider)
}
public async completions(
messages: Message[],
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> {
return this.sdk.translate(message, assistant)
}
public async summaries(messages: Message[], assistant: Assistant): Promise<string | null> {
return this.sdk.summaries(messages, assistant)
}
public async suggestions(messages: Message[], assistant: Assistant): Promise<Suggestion[]> {
return this.sdk.suggestions(messages, assistant)
}
public async check(): Promise<{ valid: boolean; error: Error | null }> {
return this.sdk.check()
}
public async models(): Promise<OpenAI.Models.Model[]> {
return this.sdk.models()
}
}

View File

@@ -0,0 +1,152 @@
import Anthropic from '@anthropic-ai/sdk'
import { MessageCreateParamsNonStreaming, MessageParam } from '@anthropic-ai/sdk/resources'
import { DEFAULT_MAX_TOKENS } from '@renderer/config/constant'
import { getAssistantSettings, getDefaultModel, getTopNamingModel } from '@renderer/services/assistant'
import { EVENT_NAMES } from '@renderer/services/event'
import { filterContextMessages, filterMessages } from '@renderer/services/messages'
import { Assistant, Message, Provider, Suggestion } from '@renderer/types'
import { first, sum, takeRight } from 'lodash'
import OpenAI from 'openai'
import BaseProvider from './BaseProvider'
export default class AnthropicProvider extends BaseProvider {
private sdk: Anthropic
constructor(provider: Provider) {
super(provider)
this.sdk = new Anthropic({ apiKey: provider.apiKey, baseURL: this.getBaseURL() })
}
public async completions(
messages: Message[],
assistant: Assistant,
onChunk: ({ text, usage }: { text?: string; usage?: OpenAI.Completions.CompletionUsage }) => void
) {
const defaultModel = getDefaultModel()
const model = assistant.model || defaultModel
const { contextCount, maxTokens } = getAssistantSettings(assistant)
const userMessages = filterMessages(filterContextMessages(takeRight(messages, contextCount + 2))).map((message) => {
return {
role: message.role,
content: message.content
}
})
if (first(userMessages)?.role === 'assistant') {
userMessages.shift()
}
return new Promise<void>((resolve, reject) => {
const stream = this.sdk.messages
.stream({
model: model.id,
messages: userMessages.filter(Boolean) as MessageParam[],
max_tokens: maxTokens || DEFAULT_MAX_TOKENS,
temperature: assistant?.settings?.temperature,
system: assistant.prompt,
stream: true
})
.on('text', (text) => {
if (window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED)) {
resolve()
return stream.controller.abort()
}
onChunk({ text })
})
.on('finalMessage', (message) => {
onChunk({
text: '',
usage: {
prompt_tokens: message.usage.input_tokens,
completion_tokens: message.usage.output_tokens,
total_tokens: sum(Object.values(message.usage))
}
})
resolve()
})
.on('error', (error) => reject(error))
})
}
public async translate(message: Message, assistant: Assistant) {
const defaultModel = getDefaultModel()
const model = assistant.model || defaultModel
const messages = [
{ role: 'system', content: assistant.prompt },
{ role: 'user', content: message.content }
]
const response = await this.sdk.messages.create({
model: model.id,
messages: messages.filter((m) => m.role === 'user') as MessageParam[],
max_tokens: 4096,
temperature: assistant?.settings?.temperature,
system: assistant.prompt,
stream: false
})
return response.content[0].type === 'text' ? response.content[0].text : ''
}
public async summaries(messages: Message[], assistant: Assistant): Promise<string | null> {
const model = getTopNamingModel() || assistant.model || getDefaultModel()
const userMessages = takeRight(messages, 5).map((message) => ({
role: message.role,
content: message.content
}))
if (first(userMessages)?.role === 'assistant') {
userMessages.shift()
}
const systemMessage = {
role: 'system',
content: '你是一名擅长会话的助理,你需要将用户的会话总结为 10 个字以内的标题,不要使用标点符号和其他特殊符号。'
}
const message = await this.sdk.messages.create({
messages: userMessages as Anthropic.Messages.MessageParam[],
model: model.id,
system: systemMessage.content,
stream: false,
max_tokens: 4096
})
return message.content[0].type === 'text' ? message.content[0].text : null
}
public async suggestions(): Promise<Suggestion[]> {
return []
}
public async check(): Promise<{ valid: boolean; error: Error | null }> {
const model = this.provider.models[0]
const body = {
model: model.id,
messages: [{ role: 'user', content: 'hi' }],
max_tokens: 100,
stream: false
}
try {
const message = await this.sdk.messages.create(body as MessageCreateParamsNonStreaming)
return {
valid: message.content.length > 0,
error: null
}
} catch (error: any) {
return {
valid: false,
error
}
}
}
public async models(): Promise<OpenAI.Models.Model[]> {
return []
}
}

View File

@@ -0,0 +1,33 @@
import { getOllamaKeepAliveTime } from '@renderer/hooks/useOllama'
import { Assistant, Message, Provider, Suggestion } from '@renderer/types'
import OpenAI from 'openai'
export default abstract class BaseProvider {
protected provider: Provider
protected host: string
constructor(provider: Provider) {
this.provider = provider
this.host = this.getBaseURL()
}
public getBaseURL(): string {
const host = this.provider.apiHost
return host.endsWith('/') ? host : `${host}/v1/`
}
public get keepAliveTime() {
return this.provider.id === 'ollama' ? getOllamaKeepAliveTime() : undefined
}
abstract completions(
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 summaries(messages: Message[], assistant: Assistant): Promise<string | null>
abstract suggestions(messages: Message[], assistant: Assistant): Promise<Suggestion[]>
abstract check(): Promise<{ valid: boolean; error: Error | null }>
abstract models(): Promise<OpenAI.Models.Model[]>
}

View File

@@ -0,0 +1,171 @@
import { GoogleGenerativeAI } from '@google/generative-ai'
import { getAssistantSettings, getDefaultModel, getTopNamingModel } from '@renderer/services/assistant'
import { EVENT_NAMES } from '@renderer/services/event'
import { filterContextMessages, filterMessages } from '@renderer/services/messages'
import { Assistant, Message, Provider, Suggestion } from '@renderer/types'
import axios from 'axios'
import { isEmpty, takeRight } from 'lodash'
import OpenAI from 'openai'
import BaseProvider from './BaseProvider'
export default class GeminiProvider extends BaseProvider {
private sdk: GoogleGenerativeAI
constructor(provider: Provider) {
super(provider)
this.sdk = new GoogleGenerativeAI(provider.apiKey)
}
public async completions(
messages: Message[],
assistant: Assistant,
onChunk: ({ text, usage }: { text?: string; usage?: OpenAI.Completions.CompletionUsage }) => void
) {
const defaultModel = getDefaultModel()
const model = assistant.model || defaultModel
const { contextCount, maxTokens } = getAssistantSettings(assistant)
const userMessages = filterMessages(filterContextMessages(takeRight(messages, contextCount + 1))).map((message) => {
return {
role: message.role,
content: message.content
}
})
const geminiModel = this.sdk.getGenerativeModel({
model: model.id,
systemInstruction: assistant.prompt,
generationConfig: {
maxOutputTokens: maxTokens,
temperature: assistant?.settings?.temperature
}
})
const userLastMessage = userMessages.pop()
const chat = geminiModel.startChat({
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) {
if (window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED)) break
onChunk({
text: chunk.text(),
usage: {
prompt_tokens: chunk.usageMetadata?.promptTokenCount || 0,
completion_tokens: chunk.usageMetadata?.candidatesTokenCount || 0,
total_tokens: chunk.usageMetadata?.totalTokenCount || 0
}
})
}
}
async translate(message: Message, assistant: Assistant) {
const defaultModel = getDefaultModel()
const { maxTokens } = getAssistantSettings(assistant)
const model = assistant.model || defaultModel
const geminiModel = this.sdk.getGenerativeModel({
model: model.id,
systemInstruction: assistant.prompt,
generationConfig: {
maxOutputTokens: maxTokens,
temperature: assistant?.settings?.temperature
}
})
const { response } = await geminiModel.generateContent(message.content)
return response.text()
}
public async summaries(messages: Message[], assistant: Assistant): Promise<string | null> {
const model = getTopNamingModel() || assistant.model || getDefaultModel()
const userMessages = takeRight(messages, 5).map((message) => ({
role: message.role,
content: message.content
}))
const systemMessage = {
role: 'system',
content: '你是一名擅长会话的助理,你需要将用户的会话总结为 10 个字以内的标题,不要使用标点符号和其他特殊符号。'
}
const geminiModel = this.sdk.getGenerativeModel({
model: model.id,
systemInstruction: systemMessage.content,
generationConfig: {
temperature: assistant?.settings?.temperature
}
})
const lastUserMessage = userMessages.pop()
const chat = await geminiModel.startChat({
history: userMessages.map((message) => ({
role: message.role === 'user' ? 'user' : 'model',
parts: [{ text: message.content }]
}))
})
const { response } = await chat.sendMessage(lastUserMessage?.content!)
return response.text()
}
public async suggestions(): Promise<Suggestion[]> {
return []
}
public async check(): Promise<{ valid: boolean; error: Error | null }> {
const model = this.provider.models[0]
const body = {
model: model.id,
messages: [{ role: 'user', content: 'hi' }],
max_tokens: 100,
stream: false
}
try {
const geminiModel = this.sdk.getGenerativeModel({ model: body.model })
const result = await geminiModel.generateContent(body.messages[0].content)
return {
valid: !isEmpty(result.response.text()),
error: null
}
} catch (error: any) {
return {
valid: false,
error
}
}
}
public async models(): Promise<OpenAI.Models.Model[]> {
try {
const api = this.provider.apiHost + '/v1beta/models'
const { data } = await axios.get(api, { params: { key: this.provider.apiKey } })
return data.models.map(
(m: any) =>
({
id: m.name.replace('models/', ''),
name: m.displayName,
description: m.description,
object: 'model',
created: Date.now(),
owned_by: 'gemini'
}) as OpenAI.Models.Model
)
} catch (error) {
return []
}
}
}

View File

@@ -0,0 +1,197 @@
import { isLocalAi } from '@renderer/config/env'
import { getAssistantSettings, getDefaultModel, getTopNamingModel } from '@renderer/services/assistant'
import { EVENT_NAMES } from '@renderer/services/event'
import { filterContextMessages, filterMessages } from '@renderer/services/messages'
import { Assistant, Message, Provider, Suggestion } from '@renderer/types'
import { fileToBase64, removeQuotes } from '@renderer/utils'
import { first, takeRight } from 'lodash'
import OpenAI from 'openai'
import {
ChatCompletionContentPart,
ChatCompletionCreateParamsNonStreaming,
ChatCompletionMessageParam
} from 'openai/resources'
import BaseProvider from './BaseProvider'
export default class OpenAIProvider extends BaseProvider {
private sdk: OpenAI
constructor(provider: Provider) {
super(provider)
this.sdk = new OpenAI({
dangerouslyAllowBrowser: true,
apiKey: provider.apiKey,
baseURL: this.getBaseURL()
})
}
private async getMessageContent(message: Message): Promise<string | ChatCompletionContentPart[]> {
const file = first(message.files)
if (!file) {
return message.content
}
if (file.type.includes('image')) {
return [
{ type: 'text', text: message.content },
{
type: 'image_url',
image_url: {
url: await fileToBase64(file)
}
}
]
}
return message.content
}
async completions(
messages: Message[],
assistant: Assistant,
onChunk: ({ text, usage }: { text?: string; usage?: OpenAI.Completions.CompletionUsage }) => void
): Promise<void> {
const defaultModel = getDefaultModel()
const model = assistant.model || defaultModel
const { contextCount, maxTokens } = getAssistantSettings(assistant)
const systemMessage = assistant.prompt ? { role: 'system', content: assistant.prompt } : undefined
const userMessages: ChatCompletionMessageParam[] = []
for (const message of filterMessages(filterContextMessages(takeRight(messages, contextCount + 1)))) {
userMessages.push({
role: message.role,
content: await this.getMessageContent(message)
} as ChatCompletionMessageParam)
}
// @ts-ignore key is not typed
const stream = await this.sdk.chat.completions.create({
model: model.id,
messages: [systemMessage, ...userMessages].filter(Boolean) as ChatCompletionMessageParam[],
stream: true,
temperature: assistant?.settings?.temperature,
max_tokens: maxTokens,
keep_alive: this.keepAliveTime
})
for await (const chunk of stream) {
if (window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED)) break
onChunk({ text: chunk.choices[0]?.delta?.content || '', usage: chunk.usage })
}
}
async translate(message: Message, assistant: Assistant) {
const defaultModel = getDefaultModel()
const model = assistant.model || defaultModel
const messages = [
{ role: 'system', content: assistant.prompt },
{ role: 'user', content: message.content }
]
// @ts-ignore key is not typed
const response = await this.sdk.chat.completions.create({
model: model.id,
messages: messages as ChatCompletionMessageParam[],
stream: false,
keep_alive: this.keepAliveTime
})
return response.choices[0].message?.content || ''
}
public async summaries(messages: Message[], assistant: Assistant): Promise<string | null> {
const model = getTopNamingModel() || assistant.model || getDefaultModel()
const userMessages = takeRight(messages, 5).map((message) => ({
role: message.role,
content: message.content
}))
const systemMessage = {
role: 'system',
content: '你是一名擅长会话的助理,你需要将用户的会话总结为 10 个字以内的标题,不要使用标点符号和其他特殊符号。'
}
// @ts-ignore key is not typed
const response = await this.sdk.chat.completions.create({
model: model.id,
messages: [systemMessage, ...(isLocalAi ? [first(userMessages)] : userMessages)] as ChatCompletionMessageParam[],
stream: false,
max_tokens: 50,
keep_alive: this.keepAliveTime
})
return removeQuotes(response.choices[0].message?.content?.substring(0, 50) || '')
}
async suggestions(messages: Message[], assistant: Assistant): Promise<Suggestion[]> {
const model = assistant.model
if (!model) {
return []
}
const response: any = await this.sdk.request({
method: 'post',
path: '/advice_questions',
body: {
messages: messages.filter((m) => m.role === 'user').map((m) => ({ role: m.role, content: m.content })),
model: model.id,
max_tokens: 0,
temperature: 0,
n: 0
}
})
return response?.questions?.filter(Boolean)?.map((q: any) => ({ content: q })) || []
}
public async check(): Promise<{ valid: boolean; error: Error | null }> {
const model = this.provider.models[0]
const body = {
model: model.id,
messages: [{ role: 'user', content: 'hi' }],
max_tokens: 100,
stream: false
}
try {
const response = await this.sdk.chat.completions.create(body as ChatCompletionCreateParamsNonStreaming)
return {
valid: Boolean(response?.choices[0].message),
error: null
}
} catch (error: any) {
return {
valid: false,
error
}
}
}
public async models(): Promise<OpenAI.Models.Model[]> {
try {
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
} catch (error) {
return []
}
}
}

View File

@@ -0,0 +1,19 @@
import { Provider } from '@renderer/types'
import AnthropicProvider from './AnthropicProvider'
import BaseProvider from './BaseProvider'
import GeminiProvider from './GeminiProvider'
import OpenAIProvider from './OpenAIProvider'
export default class ProviderFactory {
static create(provider: Provider): BaseProvider {
switch (provider.id) {
case 'anthropic':
return new AnthropicProvider(provider)
case 'gemini':
return new GeminiProvider(provider)
default:
return new OpenAIProvider(provider)
}
}
}

View File

@@ -1,343 +0,0 @@
import Anthropic from '@anthropic-ai/sdk'
import { MessageCreateParamsNonStreaming, MessageParam } from '@anthropic-ai/sdk/resources'
import { GoogleGenerativeAI } from '@google/generative-ai'
import { DEFAULT_MAX_TOKENS } from '@renderer/config/constant'
import { getOllamaKeepAliveTime } from '@renderer/hooks/useOllama'
import { Assistant, Message, Provider, Suggestion } from '@renderer/types'
import { removeQuotes } from '@renderer/utils'
import axios from 'axios'
import { isEmpty, sum, takeRight } from 'lodash'
import OpenAI from 'openai'
import { ChatCompletionCreateParamsNonStreaming, ChatCompletionMessageParam } from 'openai/resources'
import { getAssistantSettings, getDefaultModel, getTopNamingModel } from './assistant'
import { EVENT_NAMES } from './event'
export default class ProviderSDK {
provider: Provider
openaiSdk: OpenAI
anthropicSdk: Anthropic
geminiSdk: GoogleGenerativeAI
constructor(provider: Provider) {
this.provider = provider
const host = provider.apiHost
const baseURL = host.endsWith('/') ? host : `${provider.apiHost}/v1/`
this.anthropicSdk = new Anthropic({ apiKey: provider.apiKey, baseURL })
this.openaiSdk = new OpenAI({ dangerouslyAllowBrowser: true, apiKey: provider.apiKey, baseURL })
this.geminiSdk = new GoogleGenerativeAI(provider.apiKey)
}
private get isAnthropic() {
return this.provider.id === 'anthropic'
}
private get isGemini() {
return this.provider.id === 'gemini'
}
private get keepAliveTime() {
return this.provider.id === 'ollama' ? getOllamaKeepAliveTime() : undefined
}
public async completions(
messages: Message[],
assistant: Assistant,
onChunk: ({ text, usage }: { text?: string; usage?: OpenAI.Completions.CompletionUsage }) => void
) {
const defaultModel = getDefaultModel()
const model = assistant.model || defaultModel
const { contextCount, maxTokens } = getAssistantSettings(assistant)
const systemMessage = assistant.prompt ? { role: 'system', content: assistant.prompt } : undefined
const userMessages = takeRight(messages, contextCount + 1).map((message) => ({
role: message.role,
content: message.content
}))
if (this.isAnthropic) {
return new Promise<void>((resolve, reject) => {
const stream = this.anthropicSdk.messages
.stream({
model: model.id,
messages: userMessages.filter(Boolean) as MessageParam[],
max_tokens: maxTokens || DEFAULT_MAX_TOKENS,
temperature: assistant?.settings?.temperature,
system: assistant.prompt,
stream: true
})
.on('text', (text) => {
if (window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED)) {
resolve()
return stream.controller.abort()
}
onChunk({ text })
})
.on('finalMessage', (message) => {
onChunk({
text: '',
usage: {
prompt_tokens: message.usage.input_tokens,
completion_tokens: message.usage.output_tokens,
total_tokens: sum(Object.values(message.usage))
}
})
resolve()
})
.on('error', (error) => reject(error))
})
}
if (this.isGemini) {
const geminiModel = this.geminiSdk.getGenerativeModel({
model: model.id,
systemInstruction: assistant.prompt,
generationConfig: {
maxOutputTokens: maxTokens,
temperature: assistant?.settings?.temperature
}
})
const userLastMessage = userMessages.pop()
const chat = geminiModel.startChat({
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) {
if (window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED)) break
onChunk({
text: chunk.text(),
usage: {
prompt_tokens: chunk.usageMetadata?.promptTokenCount || 0,
completion_tokens: chunk.usageMetadata?.candidatesTokenCount || 0,
total_tokens: chunk.usageMetadata?.totalTokenCount || 0
}
})
}
return
}
// @ts-ignore key is not typed
const stream = await this.openaiSdk.chat.completions.create({
model: model.id,
messages: [systemMessage, ...userMessages].filter(Boolean) as ChatCompletionMessageParam[],
stream: true,
temperature: assistant?.settings?.temperature,
max_tokens: maxTokens,
keep_alive: this.keepAliveTime
})
for await (const chunk of stream) {
if (window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED)) break
onChunk({ text: chunk.choices[0]?.delta?.content || '', usage: chunk.usage })
}
}
public async translate(message: Message, assistant: Assistant) {
const defaultModel = getDefaultModel()
const { maxTokens } = getAssistantSettings(assistant)
const model = assistant.model || defaultModel
const messages = [
{ role: 'system', content: assistant.prompt },
{ role: 'user', content: message.content }
]
if (this.isAnthropic) {
const response = await this.anthropicSdk.messages.create({
model: model.id,
messages: messages.filter((m) => m.role === 'user') as MessageParam[],
max_tokens: 4096,
temperature: assistant?.settings?.temperature,
system: assistant.prompt,
stream: false
})
return response.content[0].type === 'text' ? response.content[0].text : ''
}
if (this.isGemini) {
const geminiModel = this.geminiSdk.getGenerativeModel({
model: model.id,
systemInstruction: assistant.prompt,
generationConfig: {
maxOutputTokens: maxTokens,
temperature: assistant?.settings?.temperature
}
})
const { response } = await geminiModel.generateContent(message.content)
return response.text()
}
// @ts-ignore key is not typed
const response = await this.openaiSdk.chat.completions.create({
model: model.id,
messages: messages as ChatCompletionMessageParam[],
stream: false,
keep_alive: this.keepAliveTime
})
return response.choices[0].message?.content || ''
}
public async summaries(messages: Message[], assistant: Assistant): Promise<string | null> {
const model = getTopNamingModel() || assistant.model || getDefaultModel()
const userMessages = takeRight(messages, 5).map((message) => ({
role: message.role,
content: message.content
}))
const systemMessage = {
role: 'system',
content: '你是一名擅长会话的助理,你需要将用户的会话总结为 10 个字以内的标题,不要使用标点符号和其他特殊符号。'
}
if (this.isAnthropic) {
const message = await this.anthropicSdk.messages.create({
messages: userMessages as Anthropic.Messages.MessageParam[],
model: model.id,
system: systemMessage.content,
stream: false,
max_tokens: 4096
})
return message.content[0].type === 'text' ? message.content[0].text : null
}
if (this.isGemini) {
const geminiModel = this.geminiSdk.getGenerativeModel({
model: model.id,
systemInstruction: systemMessage.content,
generationConfig: {
temperature: assistant?.settings?.temperature
}
})
const lastUserMessage = userMessages.pop()
const chat = await geminiModel.startChat({
history: userMessages.map((message) => ({
role: message.role === 'user' ? 'user' : 'model',
parts: [{ text: message.content }]
}))
})
const { response } = await chat.sendMessage(lastUserMessage?.content!)
return response.text()
}
// @ts-ignore key is not typed
const response = await this.openaiSdk.chat.completions.create({
model: model.id,
messages: [systemMessage, ...userMessages] as ChatCompletionMessageParam[],
stream: false,
max_tokens: 50,
keep_alive: this.keepAliveTime
})
return removeQuotes(response.choices[0].message?.content || '')
}
public async suggestions(messages: Message[], assistant: Assistant): Promise<Suggestion[]> {
const model = assistant.model
if (!model) {
return []
}
const response: any = await this.openaiSdk.request({
method: 'post',
path: '/advice_questions',
body: {
messages: messages.filter((m) => m.role === 'user').map((m) => ({ role: m.role, content: m.content })),
model: model.id,
max_tokens: 0,
temperature: 0,
n: 0
}
})
return response?.questions?.filter(Boolean)?.map((q: any) => ({ content: q })) || []
}
public async check(): Promise<{ valid: boolean; error: Error | null }> {
const model = this.provider.models[0]
const body = {
model: model.id,
messages: [{ role: 'user', content: 'hi' }],
max_tokens: 100,
stream: false
}
try {
if (this.isAnthropic) {
const message = await this.anthropicSdk.messages.create(body as MessageCreateParamsNonStreaming)
return {
valid: message.content.length > 0,
error: null
}
}
if (this.isGemini) {
const geminiModel = this.geminiSdk.getGenerativeModel({ model: body.model })
const result = await geminiModel.generateContent(body.messages[0].content)
return {
valid: !isEmpty(result.response.text()),
error: null
}
}
const response = await this.openaiSdk.chat.completions.create(body as ChatCompletionCreateParamsNonStreaming)
return {
valid: Boolean(response?.choices[0].message),
error: null
}
} catch (error: any) {
return {
valid: false,
error
}
}
}
public async models(): Promise<OpenAI.Models.Model[]> {
try {
if (this.isAnthropic) {
return []
}
if (this.isGemini) {
const api = this.provider.apiHost + '/v1beta/models'
const { data } = await axios.get(api, { params: { key: this.provider.apiKey } })
return data.models.map(
(m: any) =>
({
id: m.name.replace('models/', ''),
name: m.displayName,
description: m.description,
object: 'model',
created: Date.now(),
owned_by: 'gemini'
}) as OpenAI.Models.Model
)
}
const response = await this.openaiSdk.models.list()
return response.data
} catch (error) {
return []
}
}
}

View File

@@ -6,6 +6,7 @@ import { uuid } from '@renderer/utils'
import dayjs from 'dayjs'
import { isEmpty } from 'lodash'
import AiProvider from '../providers/AiProvider'
import {
getAssistantProvider,
getDefaultModel,
@@ -15,7 +16,6 @@ import {
} from './assistant'
import { EVENT_NAMES, EventEmitter } from './event'
import { filterMessages } from './messages'
import ProviderSDK from './ProviderSDK'
export async function fetchChatCompletion({
messages,
@@ -33,7 +33,7 @@ export async function fetchChatCompletion({
const provider = getAssistantProvider(assistant)
const defaultModel = getDefaultModel()
const model = assistant.model || defaultModel
const providerSdk = new ProviderSDK(provider)
const AI = new AiProvider(provider)
store.dispatch(setGenerating(true))
@@ -61,7 +61,7 @@ export async function fetchChatCompletion({
}, 1000)
try {
await providerSdk.completions(filterMessages(messages), assistant, ({ text, usage }) => {
await AI.completions(messages, assistant, ({ text, usage }) => {
message.content = message.content + text || ''
message.usage = usage
onResponse({ ...message, status: 'pending' })
@@ -103,10 +103,10 @@ export async function fetchTranslate({ message, assistant }: { message: Message;
return ''
}
const providerSdk = new ProviderSDK(provider)
const AI = new AiProvider(provider)
try {
return await providerSdk.translate(message, assistant)
return await AI.translate(message, assistant)
} catch (error: any) {
return ''
}
@@ -120,10 +120,10 @@ export async function fetchMessagesSummary({ messages, assistant }: { messages:
return null
}
const providerSdk = new ProviderSDK(provider)
const AI = new AiProvider(provider)
try {
return await providerSdk.summaries(filterMessages(messages), assistant)
return await AI.summaries(filterMessages(messages), assistant)
} catch (error: any) {
return null
}
@@ -136,10 +136,8 @@ export async function fetchSuggestions({
messages: Message[]
assistant: Assistant
}): Promise<Suggestion[]> {
console.debug('fetchSuggestions', messages, assistant)
const provider = getAssistantProvider(assistant)
const providerSdk = new ProviderSDK(provider)
console.debug('fetchSuggestions', provider)
const AI = new AiProvider(provider)
const model = assistant.model
if (!model) {
@@ -155,7 +153,7 @@ export async function fetchSuggestions({
}
try {
return await providerSdk.suggestions(messages, assistant)
return await AI.suggestions(filterMessages(messages), assistant)
} catch (error: any) {
return []
}
@@ -166,9 +164,11 @@ export async function checkApi(provider: Provider) {
const key = 'api-check'
const style = { marginTop: '3vh' }
if (!provider.apiKey) {
window.message.error({ content: i18n.t('message.error.enter.api.key'), key, style })
return false
if (provider.id !== 'ollama') {
if (!provider.apiKey) {
window.message.error({ content: i18n.t('message.error.enter.api.key'), key, style })
return false
}
}
if (!provider.apiHost) {
@@ -181,9 +181,9 @@ export async function checkApi(provider: Provider) {
return false
}
const providerSdk = new ProviderSDK(provider)
const AI = new AiProvider(provider)
const { valid } = await providerSdk.check()
const { valid } = await AI.check()
window.message[valid ? 'success' : 'error']({
key: 'api-check',
@@ -202,10 +202,10 @@ function hasApiKey(provider: Provider) {
}
export async function fetchModels(provider: Provider) {
const providerSdk = new ProviderSDK(provider)
const AI = new AiProvider(provider)
try {
return await providerSdk.models()
return await AI.models()
} catch (error) {
return []
}

View File

@@ -15,9 +15,15 @@ export function getDefaultAssistant(): Assistant {
}
}
export function getDefaultAssistantSettings() {
return store.getState().assistants.defaultAssistant.settings
}
export function getDefaultTopic(): Topic {
return {
id: uuid(),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
name: i18n.t('chat.default.topic.name'),
messages: []
}
@@ -82,8 +88,9 @@ export function covertAgentToAssistant(agent: Agent): Assistant {
return {
...getDefaultAssistant(),
...agent,
id: agent.group === 'system' ? uuid() : String(agent.id),
name: getAssistantNameWithAgent(agent),
id: agent.group === 'system' ? uuid() : String(agent.id)
settings: getDefaultAssistantSettings()
}
}

View File

@@ -63,6 +63,7 @@ export async function reset() {
window.modal.confirm({
title: i18n.t('message.reset.double.confirm.title'),
content: i18n.t('message.reset.double.confirm.content'),
centered: true,
onOk: async () => {
await localStorage.clear()
await localforage.clear()

View File

@@ -12,7 +12,9 @@ export const EVENT_NAMES = {
REGENERATE_MESSAGE: 'REGENERATE_MESSAGE',
CHAT_COMPLETION_PAUSED: 'CHAT_COMPLETION_PAUSED',
ESTIMATED_TOKEN_COUNT: 'ESTIMATED_TOKEN_COUNT',
SHOW_ASSISTANTS: 'SHOW_ASSISTANTS',
SHOW_CHAT_SETTINGS: 'SHOW_CHAT_SETTINGS',
SHOW_TOPIC_SIDEBAR: 'SHOW_TOPIC_SIDEBAR',
SWITCH_TOPIC_SIDEBAR: 'SWITCH_TOPIC_SIDEBAR'
SWITCH_TOPIC_SIDEBAR: 'SWITCH_TOPIC_SIDEBAR',
NEW_CONTEXT: 'NEW_CONTEXT'
}

View File

@@ -1,3 +1,4 @@
import { DEFAULT_CONEXTCOUNT } from '@renderer/config/constant'
import { Assistant, Message } from '@renderer/types'
import { GPTTokens } from 'gpt-tokens'
import { isEmpty, takeRight } from 'lodash'
@@ -5,7 +6,32 @@ import { isEmpty, takeRight } from 'lodash'
import { getAssistantSettings } from './assistant'
export const filterMessages = (messages: Message[]) => {
return messages.filter((message) => message.type !== '@').filter((message) => !isEmpty(message.content.trim()))
return messages
.filter((message) => !['@', 'clear'].includes(message.type!))
.filter((message) => !isEmpty(message.content.trim()))
}
export function filterContextMessages(messages: Message[]): Message[] {
const clearIndex = messages.findLastIndex((message) => message.type === 'clear')
if (clearIndex === -1) {
return messages
}
return messages.slice(clearIndex + 1)
}
export function getContextCount(assistant: Assistant, messages: Message[]) {
const contextCount = assistant?.settings?.contextCount ?? DEFAULT_CONEXTCOUNT
const _messages = takeRight(messages, contextCount)
const clearIndex = _messages.findLastIndex((message) => message.type === 'clear')
const messagesCount = _messages.length
if (clearIndex === -1) {
return contextCount
}
return messagesCount - (clearIndex + 1)
}
export function estimateInputTokenCount(text: string) {
@@ -24,7 +50,7 @@ export function estimateHistoryTokenCount(assistant: Assistant, msgs: Message[])
model: 'gpt-4o',
messages: [
{ role: 'system', content: assistant.prompt },
...filterMessages(takeRight(msgs, contextCount)).map((message) => ({
...filterMessages(filterContextMessages(takeRight(msgs, contextCount))).map((message) => ({
role: message.role,
content: message.content
}))

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

@@ -44,11 +44,14 @@ const assistantsSlice = createSlice({
)
},
addTopic: (state, action: PayloadAction<{ assistantId: string; topic: Topic }>) => {
const topic = action.payload.topic
topic.createdAt = new Date().toISOString()
topic.updatedAt = new Date().toISOString()
state.assistants = state.assistants.map((assistant) =>
assistant.id === action.payload.assistantId
? {
...assistant,
topics: uniqBy([action.payload.topic, ...assistant.topics], 'id')
topics: uniqBy([topic, ...assistant.topics], 'id')
}
: assistant
)
@@ -64,13 +67,13 @@ const assistantsSlice = createSlice({
)
},
updateTopic: (state, action: PayloadAction<{ assistantId: string; topic: Topic }>) => {
const newTopic = action.payload.topic
newTopic.updatedAt = new Date().toISOString()
state.assistants = state.assistants.map((assistant) =>
assistant.id === action.payload.assistantId
? {
...assistant,
topics: assistant.topics.map((topic) =>
topic.id === action.payload.topic.id ? action.payload.topic : topic
)
topics: assistant.topics.map((topic) => (topic.id === newTopic.id ? newTopic : topic))
}
: assistant
)

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