Compare commits

...

95 Commits

Author SHA1 Message Date
Teo
8c66f0e41a fix: 修复选中问题 2025-01-27 12:30:35 +08:00
Teo
fd1629e004 refactor(settings): 重构设置页面,改为弹框 2025-01-27 12:30:35 +08:00
Nanami
790caae2ab feat: Support configurable chunk size and overlap for knowledge base 2025-01-27 12:30:22 +08:00
Nanami
7f7300e6dc feat: Support configurable chunk size and overlap for knowledge base 2025-01-27 12:30:22 +08:00
kangfenmao
4464992873 docs: update Japanese and Chinese README files to include QQ group link 2025-01-24 18:16:51 +08:00
kangfenmao
37d1c250d2 docs: update README files to include Discord link for community support
- Added a Discord link to the English, Japanese, and Chinese README files, encouraging users to join discussions and seek help alongside the existing Telegram group invitation.
- This change enhances community engagement options for Cherry Studio users.
2025-01-24 18:07:29 +08:00
kangfenmao
e9c51579a2 chore(version): 0.9.17 2025-01-24 13:54:04 +08:00
kangfenmao
aec2952780 feat: add delete group message confirm modal 2025-01-24 13:13:00 +08:00
kangfenmao
95a1bdac72 fix: resend message logic 2025-01-24 13:02:57 +08:00
kangfenmao
306cb04ef0 fix: siliconflow image url with query params #844
close #844
2025-01-24 09:31:31 +08:00
kangfenmao
dc9444a9d4 feat(constants): add C# file extension to textExts array #835
- Updated the textExts array in constant.ts to include '.cs' for C# files, enhancing the file type recognition capabilities.
2025-01-23 13:22:17 +08:00
kangfenmao
ad9fefe902 chore(migration): update version and adjust provider type for QwenLM #833
- Incremented version from 60 to 61 in the persisted reducer configuration.
- Updated migration logic to change the provider type for 'qwenlm' from 'openai' to 'qwenlm', ensuring correct identification in the state management.
2025-01-23 13:20:15 +08:00
kangfenmao
e07d4838a9 docs: update README files to encourage community support
- Added a call-to-action in English, Japanese, and Chinese README files inviting users to star the project or sponsor its development.
- Enhanced visibility of community engagement options to foster support for Cherry Studio.
2025-01-23 11:59:15 +08:00
hxp0618
30d070040c fix: apikey and ApiHost incorrectly set to empty 2025-01-23 08:30:07 +08:00
hobee
f335699958 feat: add new minimax model configuration 2025-01-23 08:29:48 +08:00
kangfenmao
b1bc576e3f chore(version): 0.9.16 2025-01-22 16:32:57 +08:00
kangfenmao
a6f086e3be fix: group message bugs 2025-01-22 16:29:05 +08:00
kangfenmao
084da9ebab feat: enhance message model handling and user display
- Updated Message component to fallback to message.model if model retrieval fails, improving robustness.
- Refactored MessageHeader to utilize getModelName for better user name display based on message role, enhancing clarity.
- Introduced getModelName function in ModelService to streamline model name retrieval, improving code modularity and readability.
2025-01-22 15:08:44 +08:00
kangfenmao
57aef23741 feat: enhance agent management and UI in AddAssistantPopup and AgentsPage
- Updated AddAssistantPopup to improve layout and styling, ensuring better overflow handling and text display.
- Refactored AgentsPage to utilize a new utility function for grouping agents, enhancing data management and organization.
- Exported getAgentsFromSystemAgents function for better modularity and reusability across components.
2025-01-22 14:47:35 +08:00
kangfenmao
900b11bdf7 feat: enhance translation functionality in MessageMenubar
- Updated translateText function to accept an optional callback for handling translated text directly within the function.
- Refactored MessageMenubar to utilize the new callback mechanism, improving the flow of translated content handling.
- Enhanced error handling during translation to ensure better user feedback in case of failures.
2025-01-22 14:37:15 +08:00
kangfenmao
8aec8a60b3 feat: add file reading functionality and integrate system agents
- Introduced FileService to handle file reading operations via IPC.
- Implemented a new IPC handler for reading files, enhancing the application's ability to access and manage data.
- Integrated system agents from a JSON file, allowing dynamic loading of agent data into the application.
- Updated the AgentsPage and AddAssistantPopup components to utilize the new system agents, improving user experience and functionality.
- Enhanced application state management by adding resourcesPath to the runtime state, ensuring proper resource handling across components.
2025-01-22 14:35:38 +08:00
kangfenmao
a566b0e91a refactor: unify message model handling across components
- Replaced direct usage of modelId with model object in Message, MessageHeader, MessageMenubar, and TranslatePage components for consistency.
- Introduced getMessageModelId utility function to streamline model retrieval from messages.
- Updated event handling in Messages component to align with new model structure.
- Enhanced code readability and maintainability by reducing redundancy in model handling.
2025-01-22 13:29:21 +08:00
kangfenmao
4d201059ad feat: conditionally render resend button in MessageMenubar
- Updated MessageMenubar to display the resend button only for user messages, enhancing user experience and preventing unnecessary actions for other roles.
- Refactored the children prop of TextEditPopup to include conditional rendering logic based on message role.
2025-01-22 12:26:40 +08:00
kangfenmao
00d91ecf01 feat: enhance message grouping and styling
- Added new styles for message thought containers and group message wrappers to improve UI layout.
- Updated MessageGroup component to dynamically set the selected message index based on message length.
- Introduced a new event for appending messages, enhancing message handling capabilities.
- Refactored MessageMenubar to support the new append message functionality.
- Adjusted multi-model message style setting to 'fold' for better user experience.
- Improved responsiveness of message grid layout for smaller screens.
2025-01-22 12:04:21 +08:00
kangfenmao
462ac39897 feat: streamline language translation options in MessageMenubar
- Replaced hardcoded language translation options with a dynamic mapping from TranslateLanguageOptions.
- Improved maintainability and scalability of the translation feature by utilizing a centralized configuration for language options.
2025-01-22 10:18:19 +08:00
kangfenmao
3fa1e8c842 feat: add FlagOpen logo to model configuration
- Introduced a new image asset for the FlagOpen model in the assets directory.
- Updated the models configuration to include the FlagOpen logo, allowing for its use in the model logo mapping.
2025-01-22 10:05:50 +08:00
kangfenmao
d32a76c087 refactor: improve message rendering and add reasoning content extraction
- Refactored `getMessageBackground` function for better readability.
- Updated `MessageContent` component to use a new `withMessageThought` utility for extracting reasoning content from messages.
- Changed fragment usage to `Fragment` for consistency in JSX.
- Enhanced message handling by separating reasoning content from the main message content.
2025-01-22 09:50:29 +08:00
duanyongcheng77
9e9fd37bda fix: 🐛 fixed bug #779
助手的预设消息保存逻辑的修改
2025-01-21 22:06:52 +08:00
kangfenmao
dd464db594 feat: add group message action bar 2025-01-21 17:58:34 +08:00
Teo
ccac5358f4 chore(version): update version to 60 and add migration for multiModelMessageStyle setting 2025-01-21 15:16:18 +08:00
Teo
e72e324155 refact: 多模型回答优化 2025-01-21 15:16:18 +08:00
kangfenmao
28c18b6651 fix: regenerate message not rewrite reasoning_content 2025-01-21 15:15:55 +08:00
kangfenmao
3d432d810f chore(version): 0.9.15 2025-01-21 14:28:01 +08:00
kangfenmao
21ad28ee62 feat: add deepseek-reasoner model support 2025-01-21 14:28:01 +08:00
kangfenmao
f7db1289e4 feat(miniwindow): add up and down key switch menu #792 2025-01-21 10:11:42 +08:00
Cololi
f5c547cdb2 feat: add deepseek-reasoner & delete deepseek-coder 2025-01-21 10:05:21 +08:00
ousugo
9160cee919 feat: add WebDAV backup hour options and optimize english hour translations 2025-01-21 08:38:08 +08:00
kangfenmao
298bb8be29 feat: update minapp url to 'https://grok.com' #791
close #791
2025-01-20 16:53:33 +08:00
kangfenmao
b800c64fed docs: update readme.md 2025-01-20 15:32:01 +08:00
kangfenmao
504d7b88d4 chore(version): 0.9.14 2025-01-20 13:56:52 +08:00
kangfenmao
713d6dba8f fix: added warning for manual download on failed auto updates, simplified window lifecycle 2025-01-20 13:56:25 +08:00
kangfenmao
a6833d5994 chore(version): 0.9.13 2025-01-20 13:11:26 +08:00
kangfenmao
d850fd315a feat: add onclick event to login icon in footer component 2025-01-20 12:57:26 +08:00
kangfenmao
c04fd62bec feat: extended safety threshold check to include 'thinking-exp' model ids 2025-01-20 12:55:24 +08:00
kangfenmao
f86a274cd3 feat: update contact email address 2025-01-20 12:20:46 +08:00
kangfenmao
798a6e8c3e chore(version): 0.9.12 2025-01-20 11:52:26 +08:00
kangfenmao
749353f460 feat: added copy last message feature and translations 2025-01-20 11:09:57 +08:00
kangfenmao
c510f5dcce feat: added utility function, sorting, and new shortcut 2025-01-20 10:29:44 +08:00
kangfenmao
46b314303c feat: enable pinned functionality for minapps and update 'flowith' configuration 2025-01-20 09:58:47 +08:00
kangfenmao
b01aca9066 fix: prevent unnecessary route changes and trim input field on change 2025-01-20 09:52:58 +08:00
ousugo
725f81c165 fix: conditionally render pin button based on app ID 2025-01-20 09:32:13 +08:00
ousugo
c0e25879e5 feat: add Flowith minapp, resolve #780 2025-01-20 09:31:34 +08:00
MrChen
4c22c404ca feat: add the shortcuts for 'clear' and 'new context' and fix (#786)
* Fix: ESC key to exit the expanded editor

* Add the shortcuts for 'clear' and 'new context' to the input bar
Clear Messages: Ctrl+L
New Context: Ctrl+R
https://github.com/CherryHQ/cherry-studio/issues/740
https://github.com/CherryHQ/cherry-studio/issues/766

* Fix: the paste issue when copying from an email (content was pasted as an image; ensure it is pasted as text). Prioritize the text in the clipboard during pasting.
2025-01-20 09:31:09 +08:00
kangfenmao
63673ec39f chore(version): 0.9.11 2025-01-19 20:50:33 +08:00
kangfenmao
88cc783a95 fix: quick assistant bugs 2025-01-19 20:03:45 +08:00
kangfenmao
9c55b4516c feat: add a startup switch for quick assistant 2025-01-19 19:22:25 +08:00
kangfenmao
aecc5fefcf feat: translate support stream output 2025-01-19 16:56:35 +08:00
kangfenmao
afc2e2f595 feat: auto-scroll to selected menu item on model open 2025-01-19 15:47:19 +08:00
kangfenmao
67b63ee07a refactor: add qwenlm provider 2025-01-19 15:39:48 +08:00
kangfenmao
fd7132cd3a fix: store minapp url use base64 data image 2025-01-19 15:35:17 +08:00
kangfenmao
a7d9700f06 feat: add mini window 2025-01-19 13:59:32 +08:00
ousugo
d9bb552f3f feat: add pinning functionality for MinApp component 2025-01-19 13:59:06 +08:00
ousugo
ad2713c0be fix: fix wrong NVIDIA official website link, fix #771 2025-01-19 13:59:06 +08:00
牡丹凤凰
1e756614f9 Delete .github/workflows/update-lmarena.yml 2025-01-19 13:59:06 +08:00
牡丹凤凰
d457dfa3d3 Update update-lmarena.yml 2025-01-19 13:59:06 +08:00
牡丹凤凰
b24d88dfe3 Create update-lmarena.yml 2025-01-19 13:59:06 +08:00
kangfenmao
b6d598c52e fix: remove default message for webdav backup initiation 2025-01-19 13:59:06 +08:00
kangfenmao
67e1dd56e9 style: increased padding at the bottom of the sidebar component 2025-01-19 13:59:06 +08:00
kangfenmao
8b5dd427d0 fix: WebDAV not automatic backup on app reopened #752 2025-01-19 13:59:06 +08:00
kangfenmao
4f44afeec4 feat: auto focs input textarea #759
close #759
2025-01-19 13:59:06 +08:00
kangfenmao
c46219cd6c feat: improved 'my agents' list rendering 2025-01-19 13:59:06 +08:00
magicdmer
999bd802c4 perf: 优化智能体页面性能和体验 (#756)
* feat: improved model validation and error handling

* refactor: 优化智能体页面下拉流畅度和分类切换效果,让其更加顺畅自然

---------

Co-authored-by: kangfenmao <kangfenmao@qq.com>
Co-authored-by: magicdmer <magicdmer@163.com>
2025-01-19 13:59:06 +08:00
kangfenmao
2300cca070 refactor: improved code organization and reusability 2025-01-19 13:59:06 +08:00
kangfenmao
b4de6292c3 feat: improved model safety settings for geminiprovider class 2025-01-19 13:59:06 +08:00
magicdmer
42908e8834 refactor: (GeminiProvider) optimize safety settings handling
- Extract safety threshold logic into getModelSafetySetting method
- gemini-exp-* models not support 'OFF', must use 'BLOCK_NONE'
2025-01-19 13:59:06 +08:00
kangfenmao
57718dda6f feat: update harmblockthreshold for harm_category_civic_integrity 2025-01-19 13:59:06 +08:00
kangfenmao
c87e88a53a feat: add civic integrity category to harm block settings in GeminiProvider 2025-01-19 13:59:06 +08:00
kangfenmao
5b00c21f15 feat: update safety settings for specific categories #696
Gemini安全设置是否没有完全关闭
2025-01-19 13:59:06 +08:00
kangfenmao
6276890e5b feat: replaced visionicon with modeltags 2025-01-19 13:59:06 +08:00
kangfenmao
a7337ed4b0 feat: add 思维链(CoT) agent 2025-01-19 13:59:06 +08:00
kangfenmao
fe0f6318c9 fix: improved openai provider model id validation logic 2025-01-19 13:59:06 +08:00
magicdmer
75742323ea fix: 修正o1模型无法使用的问题 2025-01-19 13:59:06 +08:00
kangfenmao
f7f8c6f0c6 fix: remove specific unicode characters from removespecialcharacters function 2025-01-19 13:59:06 +08:00
Linjun
e4f4c6cd86 fix issue#762: upon clicking to resend, the conversation content is cleared.
If there is no subsequent message or if the next message is from the user, this message should be resent. delete the old message after processing is complete.
2025-01-19 12:26:55 +08:00
kangfenmao
8eac836e05 feat: improved model validation and error handling 2025-01-16 10:14:32 +08:00
Nanami
a6795289da fix: qwenlm context error 2025-01-15 09:09:01 +08:00
kangfenmao
eff639ddf9 chore(version): 0.9.10 2025-01-15 08:57:05 +08:00
kangfenmao
a046cf32ba fix: artifacts cannot preview 2025-01-14 23:27:54 +08:00
kangfenmao
66bc9cb3f9 refactor: improved type safety and consistency for file handling 2025-01-14 21:02:55 +08:00
kangfenmao
247d1a1846 chore(version): 0.9.9 2025-01-14 20:57:16 +08:00
kangfenmao
0e7fb2b19c refactor: update model group names and sync interval 2025-01-14 20:53:52 +08:00
kangfenmao
8a94bb05ea fix: fix model type logic based on provider properties 2025-01-14 20:32:04 +08:00
Nanami
bc454d4dec feat: add support for qwenlm and image upload (#726)
* feat: add support for qwenlm and image upload

* fix: qwenlm return

* feat: add provider config
2025-01-14 18:59:19 +08:00
Teo
d388aeecfb feat: 添加模型提及功能,支持多个模型一起回答 2025-01-14 17:46:55 +08:00
kangfenmao
3e33ee6cc5 feat: add release workflow behavior control option 2025-01-14 14:55:32 +08:00
124 changed files with 4988 additions and 759 deletions

View File

@@ -82,5 +82,6 @@ jobs:
with:
draft: true
allowUpdates: true
makeLatest: false
artifacts: 'dist/*.exe,dist/*.zip,dist/*.dmg,dist/*.AppImage,dist/*.snap,dist/*.deb,dist/*.rpm,dist/*.tar.gz,dist/latest*.yml,dist/*.blockmap'
token: ${{ secrets.GH_TOKEN }}

View File

@@ -15,3 +15,203 @@ index 50c3c4064af17bc4c7c46554d8f2419b3afceb0e..632c9b2e04d2e0e3bb09ef1cd8f29d25
}
static getInstance() {
return RAGEmbedding.singleton;
diff --git a/src/loaders/local-path-loader.d.ts b/src/loaders/local-path-loader.d.ts
index 48c20e68c469cd309be2dc8f28e44c1bd04a26e9..87002be39e7305a02e2a607b0c0d95cbbc359f9d 100644
--- a/src/loaders/local-path-loader.d.ts
+++ b/src/loaders/local-path-loader.d.ts
@@ -1,19 +1,29 @@
-import { BaseLoader } from '@llm-tools/embedjs-interfaces';
+import { BaseLoader } from "@llm-tools/embedjs-interfaces";
export declare class LocalPathLoader extends BaseLoader<{
- type: 'LocalPathLoader';
+ type: "LocalPathLoader";
}> {
- private readonly debug;
- private readonly path;
- constructor({ path }: {
- path: string;
- });
- getUnfilteredChunks(): AsyncGenerator<{
- metadata: {
- type: "LocalPathLoader";
- originalPath: string;
- source: string;
- };
- pageContent: string;
- }, void, unknown>;
- private recursivelyAddPath;
+ private readonly debug;
+ private readonly path;
+ constructor({
+ path,
+ chunkSize,
+ chunkOverlap,
+ }: {
+ path: string;
+ chunkSize?: number;
+ chunkOverlap?: number;
+ });
+ getUnfilteredChunks(): AsyncGenerator<
+ {
+ metadata: {
+ type: "LocalPathLoader";
+ originalPath: string;
+ source: string;
+ };
+ pageContent: string;
+ },
+ void,
+ unknown
+ >;
+ private recursivelyAddPath;
}
diff --git a/src/loaders/local-path-loader.js b/src/loaders/local-path-loader.js
index 4cf8a6bd1d890244c8ec49d4a05ee3bd58861c79..fd0fe1951c73da315b0c9bf4a8f33effbadb9f8f 100644
--- a/src/loaders/local-path-loader.js
+++ b/src/loaders/local-path-loader.js
@@ -8,8 +8,8 @@ import { BaseLoader } from '@llm-tools/embedjs-interfaces';
export class LocalPathLoader extends BaseLoader {
debug = createDebugMessages('embedjs:loader:LocalPathLoader');
path;
- constructor({ path }) {
- super(`LocalPathLoader_${md5(path)}`, { path });
+ constructor({ path, chunkSize, chunkOverlap}) {
+ super(`LocalPathLoader_${md5(path)}`, { path }, chunkSize ?? 1000, chunkOverlap ?? 0);
this.path = path;
}
async *getUnfilteredChunks() {
@@ -36,10 +36,12 @@ export class LocalPathLoader extends BaseLoader {
const extension = currentPath.split('.').pop().toLowerCase();
if (extension === 'md' || extension === 'mdx')
mime = 'text/markdown';
+ if (extension === 'txt')
+ mime = 'text/plain';
this.debug(`File '${this.path}' mime type updated to 'text/markdown'`);
}
try {
- const loader = await createLoaderFromMimeType(currentPath, mime);
+ const loader = await createLoaderFromMimeType(currentPath, mime, this.chunkSize, this.chunkOverlap);
for await (const result of await loader.getUnfilteredChunks()) {
yield {
pageContent: result.pageContent,
diff --git a/src/util/mime.d.ts b/src/util/mime.d.ts
index 57f56a1b8edc98366af9f84d671676c41c2f01ca..f53856fa9c78afbeee9e085c7ed0b3a131f8ee5a 100644
--- a/src/util/mime.d.ts
+++ b/src/util/mime.d.ts
@@ -1,2 +1,7 @@
-import { BaseLoader } from '@llm-tools/embedjs-interfaces';
-export declare function createLoaderFromMimeType(loaderData: string, mimeType: string): Promise<BaseLoader>;
+import { BaseLoader } from "@llm-tools/embedjs-interfaces";
+export declare function createLoaderFromMimeType(
+ loaderData: string,
+ mimeType: string,
+ chunkSize?: number,
+ chunkOverlap?: number
+): Promise<BaseLoader>;
diff --git a/src/util/mime.js b/src/util/mime.js
index 9af30bd5b8cf42985f547073a4c19756292c33a3..54ae20343131a533ab70236d3060b6accc8f6126 100644
--- a/src/util/mime.js
+++ b/src/util/mime.js
@@ -1,7 +1,9 @@
import mime from 'mime';
import createDebugMessages from 'debug';
import { TextLoader } from '../loaders/text-loader.js';
-export async function createLoaderFromMimeType(loaderData, mimeType) {
+import fs from 'node:fs';
+
+export async function createLoaderFromMimeType(loaderData, mimeType, chunkSize, chunkOverlap) {
createDebugMessages('embedjs:util:createLoaderFromMimeType')(`Incoming mime type '${mimeType}'`);
switch (mimeType) {
case 'application/msword':
@@ -10,7 +12,7 @@ export async function createLoaderFromMimeType(loaderData, mimeType) {
throw new Error('Package `@llm-tools/embedjs-loader-msoffice` needs to be installed to load docx files');
});
createDebugMessages('embedjs:util:createLoaderFromMimeType')('Dynamically imported DocxLoader');
- return new DocxLoader({ filePathOrUrl: loaderData });
+ return new DocxLoader({ filePathOrUrl: loaderData, chunkSize, chunkOverlap });
}
case 'application/vnd.ms-excel':
case 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': {
@@ -18,21 +20,21 @@ export async function createLoaderFromMimeType(loaderData, mimeType) {
throw new Error('Package `@llm-tools/embedjs-loader-msoffice` needs to be installed to load excel files');
});
createDebugMessages('embedjs:util:createLoaderFromMimeType')('Dynamically imported ExcelLoader');
- return new ExcelLoader({ filePathOrUrl: loaderData });
+ return new ExcelLoader({ filePathOrUrl: loaderData, chunkSize, chunkOverlap });
}
case 'application/pdf': {
const { PdfLoader } = await import('@llm-tools/embedjs-loader-pdf').catch(() => {
throw new Error('Package `@llm-tools/embedjs-loader-pdf` needs to be installed to load PDF files');
});
createDebugMessages('embedjs:util:createLoaderFromMimeType')('Dynamically imported PdfLoader');
- return new PdfLoader({ filePathOrUrl: loaderData });
+ return new PdfLoader({ filePathOrUrl: loaderData, chunkSize, chunkOverlap });
}
case 'application/vnd.openxmlformats-officedocument.presentationml.presentation': {
const { PptLoader } = await import('@llm-tools/embedjs-loader-msoffice').catch(() => {
throw new Error('Package `@llm-tools/embedjs-loader-msoffice` needs to be installed to load pptx files');
});
createDebugMessages('embedjs:util:createLoaderFromMimeType')('Dynamically imported PptLoader');
- return new PptLoader({ filePathOrUrl: loaderData });
+ return new PptLoader({ filePathOrUrl: loaderData, chunkSize, chunkOverlap });
}
case 'text/plain': {
const fineType = mime.getType(loaderData);
@@ -42,24 +44,26 @@ export async function createLoaderFromMimeType(loaderData, mimeType) {
throw new Error('Package `@llm-tools/embedjs-loader-csv` needs to be installed to load CSV files');
});
createDebugMessages('embedjs:util:createLoaderFromMimeType')('Dynamically imported CsvLoader');
- return new CsvLoader({ filePathOrUrl: loaderData });
+ return new CsvLoader({ filePathOrUrl: loaderData, chunkSize, chunkOverlap });
+ }
+ else{
+ const content = fs.readFileSync(loaderData, 'utf-8');
+ return new TextLoader({ text: content, chunkSize, chunkOverlap });
}
- else
- return new TextLoader({ text: loaderData });
}
case 'application/csv': {
const { CsvLoader } = await import('@llm-tools/embedjs-loader-csv').catch(() => {
throw new Error('Package `@llm-tools/embedjs-loader-csv` needs to be installed to load CSV files');
});
createDebugMessages('embedjs:util:createLoaderFromMimeType')('Dynamically imported CsvLoader');
- return new CsvLoader({ filePathOrUrl: loaderData });
+ return new CsvLoader({ filePathOrUrl: loaderData, chunkSize, chunkOverlap });
}
case 'text/html': {
const { WebLoader } = await import('@llm-tools/embedjs-loader-web').catch(() => {
throw new Error('Package `@llm-tools/embedjs-loader-web` needs to be installed to load web documents');
});
createDebugMessages('embedjs:util:createLoaderFromMimeType')('Dynamically imported WebLoader');
- return new WebLoader({ urlOrContent: loaderData });
+ return new WebLoader({ urlOrContent: loaderData, chunkSize, chunkOverlap });
}
case 'text/xml': {
const { SitemapLoader } = await import('@llm-tools/embedjs-loader-sitemap').catch(() => {
@@ -67,14 +71,14 @@ export async function createLoaderFromMimeType(loaderData, mimeType) {
});
createDebugMessages('embedjs:util:createLoaderFromMimeType')('Dynamically imported SitemapLoader');
if (await SitemapLoader.test(loaderData)) {
- return new SitemapLoader({ url: loaderData });
+ return new SitemapLoader({ url: loaderData, chunkSize, chunkOverlap });
}
//This is not a Sitemap but is still XML
const { XmlLoader } = await import('@llm-tools/embedjs-loader-xml').catch(() => {
throw new Error('Package `@llm-tools/embedjs-loader-xml` needs to be installed to load XML documents');
});
createDebugMessages('embedjs:util:createLoaderFromMimeType')('Dynamically imported XmlLoader');
- return new XmlLoader({ filePathOrUrl: loaderData });
+ return new XmlLoader({ filePathOrUrl: loaderData, chunkSize, chunkOverlap });
}
case 'text/x-markdown':
case 'text/markdown': {
@@ -82,7 +86,7 @@ export async function createLoaderFromMimeType(loaderData, mimeType) {
throw new Error('Package `@llm-tools/embedjs-loader-markdown` needs to be installed to load markdown files');
});
createDebugMessages('embedjs:util:createLoaderFromMimeType')('Dynamically imported MarkdownLoader');
- return new MarkdownLoader({ filePathOrUrl: loaderData });
+ return new MarkdownLoader({ filePathOrUrl: loaderData, chunkSize, chunkOverlap });
}
case undefined:
throw new Error(`MIME type could not be detected. Please file an issue if you think this is a bug.`);

View File

@@ -9,11 +9,11 @@
# 🍒 Cherry Studio
![](https://github.com/user-attachments/assets/7b4f2f78-5cbe-4be8-9aec-f98d8405a505)
Cherry Studio is a desktop client that supports for multiple LLM providers, available on Windows, Mac and Linux.
👏 Join [Telegram Group](https://t.me/CherryStudioAI)
👏 Join [Telegram Group](https://t.me/CherryStudioAI)[Discord](https://discord.gg/C3xrXWjY) | [QQ Group](https://qm.qq.com/q/pQPuHMjUeQ)
❤️ Like Cherry Studio? Give it a star 🌟 or [Sponsor](docs/sponsor.md) to support the development!
# 🌠 Screenshot
@@ -23,6 +23,8 @@ Cherry Studio is a desktop client that supports for multiple LLM providers, avai
# 🌟 Key Features
![](https://github.com/user-attachments/assets/7b4f2f78-5cbe-4be8-9aec-f98d8405a505)
1. **Diverse LLM Provider Support**:
- ☁️ Major LLM Cloud Services: OpenAI, Gemini, Anthropic, and more

View File

@@ -9,11 +9,11 @@
# 🍒 Cherry Studio
![](https://github.com/user-attachments/assets/7b4f2f78-5cbe-4be8-9aec-f98d8405a505)
Cherry Studioは、複数のLLMプロバイダーをサポートするデスクトップクライアントで、Windows、Mac、Linuxで利用可能です。
👏 [Telegramグループ](https://t.me/CherryStudioAI)に参加しましょう
👏 [Telegram](https://t.me/CherryStudioAI)[Discord](https://discord.gg/C3xrXWjY) | [QQグループ](https://qm.qq.com/q/pQPuHMjUeQ)
❤️ Cherry Studioをお気に入りにしましたか小さな星をつけてください 🌟 または [スポンサー](sponsor.md) をして開発をサポートしてください!❤️
# 🌠 スクリーンショット
@@ -23,6 +23,8 @@ Cherry Studioは、複数のLLMプロバイダーをサポートするデスク
# 🌟 主な機能
![](https://github.com/user-attachments/assets/7b4f2f78-5cbe-4be8-9aec-f98d8405a505)
1. **多様な LLM サービス対応**
- ☁️ 主要な LLM クラウドサービス対応OpenAI、Gemini、Anthropic など

View File

@@ -9,11 +9,11 @@
# 🍒 Cherry Studio
![](https://github.com/user-attachments/assets/995910f3-177a-4d1e-97ea-04e3b009ba36)
Cherry Studio 是一款支持多个大语言模型LLM服务商的桌面客户端兼容 Windows、Mac 和 Linux 系统。
👏 欢迎加入 [Telegram 群组](https://t.me/CherryStudioAI)
👏 欢迎加入 [Telegram 群组](https://t.me/CherryStudioAI)[Discord](https://discord.gg/C3xrXWjY) | [QQ 群](https://qm.qq.com/q/pQPuHMjUeQ)
❤️ 喜欢 Cherry Studio? 点亮小星星 🌟 或 [赞助开发者](sponsor.md)! ❤️
# 🌠 界面
@@ -23,6 +23,8 @@ Cherry Studio 是一款支持多个大语言模型LLM服务商的桌面客
# 🌟 主要特性
![](https://github.com/user-attachments/assets/995910f3-177a-4d1e-97ea-04e3b009ba36)
1. **多样化 LLM 服务支持**
- ☁️ 支持主流 LLM 云服务OpenAI、Gemini、Anthropic、硅基流动等

View File

@@ -80,9 +80,4 @@ afterPack: scripts/after-pack.js
afterSign: scripts/notarize.js
releaseInfo:
releaseNotes: |
支持将小程序固定到侧边栏 @hxp0618
增加 Grok 和 QwenLM 小程序 @ruiwarn
支持下载模型生成的 CSV 文件
知识库增加刷新按钮
Gemini 搜索增加引用来源
修复模型设置参数无法保存的问题
错误修复

View File

@@ -50,7 +50,7 @@ export default defineConfig({
}
},
optimizeDeps: {
exclude: ['chunk-QH6N6I7P.js', 'chunk-PB73W2YU.js', 'chunk-AFE5XGNG.js']
exclude: ['chunk-RK3FTE5R.js']
}
}
})

View File

@@ -1,6 +1,6 @@
{
"name": "CherryStudio",
"version": "0.9.8",
"version": "0.9.17",
"private": true,
"description": "A powerful AI assistant for producer.",
"main": "./out/main/index.js",
@@ -95,6 +95,7 @@
"@types/tinycolor2": "^1",
"@vitejs/plugin-react": "^4.2.1",
"antd": "^5.22.5",
"applescript": "^1.0.0",
"axios": "^1.7.3",
"browser-image-compression": "^2.0.2",
"dayjs": "^1.11.11",

View File

@@ -87,7 +87,8 @@ export const textExts = [
'.gradle', // Gradle 构建文件
'.groovy', // Gradle 构建文件
'.kts', // Kotlin Script 文件
'.java' // Java 代码文件
'.java', // Java 代码文件
'.cs' // C# 代码文件
]
export const ZOOM_SHORTCUTS = [

File diff suppressed because one or more lines are too long

117
resources/textMonitor.swift Normal file
View File

@@ -0,0 +1,117 @@
import Cocoa
import Foundation
class TextSelectionObserver: NSObject {
let workspace = NSWorkspace.shared
var lastSelectedText: String?
override init() {
super.init()
//
let observer = NSWorkspace.shared.notificationCenter
observer.addObserver(
self,
selector: #selector(handleSelectionChange),
name: NSWorkspace.didActivateApplicationNotification,
object: nil
)
//
var axObserver: AXObserver?
let error = AXObserverCreate(getpid(), { observer, element, notification, userData in
let selfPointer = userData!.load(as: TextSelectionObserver.self)
selfPointer.checkSelectedText()
}, &axObserver)
if error == .success, let axObserver = axObserver {
CFRunLoopAddSource(
RunLoop.main.getCFRunLoop(),
AXObserverGetRunLoopSource(axObserver),
.defaultMode
)
//
updateActiveAppObserver(axObserver)
}
}
@objc func handleSelectionChange(_ notification: Notification) {
//
var axObserver: AXObserver?
let error = AXObserverCreate(getpid(), { _, _, _, _ in }, &axObserver)
if error == .success, let axObserver = axObserver {
updateActiveAppObserver(axObserver)
}
}
func updateActiveAppObserver(_ axObserver: AXObserver) {
guard let app = workspace.frontmostApplication else { return }
let pid = app.processIdentifier
let element = AXUIElementCreateApplication(pid)
//
AXObserverAddNotification(
axObserver,
element,
kAXSelectedTextChangedNotification as CFString,
UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque())
)
}
func checkSelectedText() {
if let text = getSelectedText() {
if text.count > 0 && text != lastSelectedText {
print(text)
fflush(stdout)
lastSelectedText = text
}
}
}
func getSelectedText() -> String? {
guard let app = NSWorkspace.shared.frontmostApplication else { return nil }
let pid = app.processIdentifier
let axApp = AXUIElementCreateApplication(pid)
var focusedElement: AnyObject?
// Get focused element
let result = AXUIElementCopyAttributeValue(axApp, kAXFocusedUIElementAttribute as CFString, &focusedElement)
guard result == .success else { return nil }
// Try different approaches to get selected text
var selectedText: AnyObject?
// First try: Direct selected text
var textResult = AXUIElementCopyAttributeValue(focusedElement as! AXUIElement, kAXSelectedTextAttribute as CFString, &selectedText)
// Second try: Selected text in text area
if textResult != .success {
var selectedTextRange: AnyObject?
textResult = AXUIElementCopyAttributeValue(focusedElement as! AXUIElement, kAXSelectedTextRangeAttribute as CFString, &selectedTextRange)
if textResult == .success {
textResult = AXUIElementCopyAttributeValue(focusedElement as! AXUIElement, kAXValueAttribute as CFString, &selectedText)
}
}
// Third try: Get selected text from parent element
if textResult != .success {
var parent: AnyObject?
if AXUIElementCopyAttributeValue(focusedElement as! AXUIElement, kAXParentAttribute as CFString, &parent) == .success {
textResult = AXUIElementCopyAttributeValue(parent as! AXUIElement, kAXSelectedTextAttribute as CFString, &selectedText)
}
}
guard textResult == .success, let text = selectedText as? String else { return nil }
return text
}
}
let observer = TextSelectionObserver()
signal(SIGINT) { _ in
exit(0)
}
RunLoop.main.run()

View File

@@ -10,11 +10,14 @@ import AppUpdater from './services/AppUpdater'
import BackupManager from './services/BackupManager'
import { configManager } from './services/ConfigManager'
import { ExportService } from './services/ExportService'
import FileService from './services/FileService'
import FileStorage from './services/FileStorage'
import { GeminiService } from './services/GeminiService'
import KnowledgeService from './services/KnowledgeService'
import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService'
import { TrayService } from './services/TrayService'
import { windowService } from './services/WindowService'
import { getResourcePath } from './utils'
import { compress, decompress } from './utils/zip'
const fileManager = new FileStorage()
@@ -30,6 +33,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
appPath: app.getAppPath(),
filesPath: path.join(app.getPath('userData'), 'Data', 'Files'),
appDataPath: app.getPath('userData'),
resourcesPath: getResourcePath(),
logsPath: log.transports.file.getFile().path
}))
@@ -52,6 +56,16 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
configManager.setTray(isActive)
})
ipcMain.handle('app:restart-tray', () => TrayService.getInstance().restartTray())
ipcMain.handle('config:set', (_, key: string, value: any) => {
configManager.set(key, value)
})
ipcMain.handle('config:get', (_, key: string) => {
return configManager.get(key)
})
// theme
ipcMain.handle('app:set-theme', (_, theme: ThemeMode) => {
configManager.setTheme(theme)
@@ -117,6 +131,10 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle('file:base64Image', fileManager.base64Image)
ipcMain.handle('file:download', fileManager.downloadFile)
ipcMain.handle('file:copy', fileManager.copyFile)
ipcMain.handle('file:binaryFile', fileManager.binaryFile)
// fs
ipcMain.handle('fs:read', FileService.readFile)
// minapp
ipcMain.handle('minapp', (_, args) => {
@@ -175,4 +193,10 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle('gemini:retrieve-file', GeminiService.retrieveFile)
ipcMain.handle('gemini:list-files', GeminiService.listFiles)
ipcMain.handle('gemini:delete-file', GeminiService.deleteFile)
// mini window
ipcMain.handle('miniwindow:show', () => windowService.showMiniWindow())
ipcMain.handle('miniwindow:hide', () => windowService.hideMiniWindow())
ipcMain.handle('miniwindow:close', () => windowService.closeMiniWindow())
ipcMain.handle('miniwindow:toggle', () => windowService.toggleMiniWindow())
}

View File

@@ -0,0 +1,118 @@
import { debounce, getResourcePath } from '@main/utils'
import { exec } from 'child_process'
import { screen } from 'electron'
import path from 'path'
import { windowService } from './WindowService'
export default class ClipboardMonitor {
private platform: string
private lastText: string
private user32: any
private observer: any
public onTextSelected: (text: string) => void
constructor() {
this.platform = process.platform
this.lastText = ''
this.onTextSelected = debounce((text: string) => this.handleTextSelected(text), 550)
if (this.platform === 'win32') {
this.setupWindows()
} else if (this.platform === 'darwin') {
this.setupMacOS()
}
}
setupMacOS() {
// 使用 Swift 脚本来监听文本选择
const scriptPath = path.join(getResourcePath(), 'textMonitor.swift')
// 启动 Swift 进程来监听文本选择
const process = exec(`swift ${scriptPath}`)
process?.stdout?.on('data', (data: string) => {
console.log('[ClipboardMonitor] MacOS data:', data)
const text = data.toString().trim()
if (text && text !== this.lastText) {
this.lastText = text
this.onTextSelected(text)
}
})
process.on('error', (error) => {
console.error('[ClipboardMonitor] MacOS error:', error)
})
}
setupWindows() {
// 使用 Windows API 监听文本选择事件
const ffi = require('ffi-napi')
const ref = require('ref-napi')
this.user32 = new ffi.Library('user32', {
SetWinEventHook: ['pointer', ['uint32', 'uint32', 'pointer', 'pointer', 'uint32', 'uint32', 'uint32']],
UnhookWinEvent: ['bool', ['pointer']]
})
// 定义事件常量
const EVENT_OBJECT_SELECTION = 0x8006
const WINEVENT_OUTOFCONTEXT = 0x0000
const WINEVENT_SKIPOWNTHREAD = 0x0001
const WINEVENT_SKIPOWNPROCESS = 0x0002
// 创建回调函数
const callback = ffi.Callback('void', ['pointer', 'uint32', 'pointer', 'long', 'long', 'uint32', 'uint32'], () => {
this.getSelectedText()
})
// 设置事件钩子
this.observer = this.user32.SetWinEventHook(
EVENT_OBJECT_SELECTION,
EVENT_OBJECT_SELECTION,
ref.NULL,
callback,
0,
0,
WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNTHREAD | WINEVENT_SKIPOWNPROCESS
)
}
getSelectedText() {
// Get selected text
if (this.platform === 'win32') {
const ref = require('ref-napi')
if (this.user32.OpenClipboard(ref.NULL)) {
// Get clipboard content
const text = this.user32.GetClipboardData(1) // CF_TEXT = 1
this.user32.CloseClipboard()
if (text && text !== this.lastText) {
this.lastText = text
this.onTextSelected(text)
}
}
}
}
private handleTextSelected(text: string) {
if (!text) return
console.debug('[ClipboardMonitor] handleTextSelected', text)
windowService.setLastSelectedText(text)
const mousePosition = screen.getCursorScreenPoint()
windowService.showSelectionMenu({
x: mousePosition.x,
y: mousePosition.y + 10
})
}
dispose() {
if (this.platform === 'win32' && this.observer) {
this.user32.UnhookWinEvent(this.observer)
}
}
}

View File

@@ -30,7 +30,7 @@ export class ConfigManager {
this.store.set('theme', theme)
}
isTray(): boolean {
getTray(): boolean {
return !!this.store.get('tray', true)
}
@@ -83,6 +83,30 @@ export class ConfigManager {
)
this.notifySubscribers('shortcuts', shortcuts)
}
getClickTrayToShowQuickAssistant(): boolean {
return this.store.get('clickTrayToShowQuickAssistant', false) as boolean
}
setClickTrayToShowQuickAssistant(value: boolean) {
this.store.set('clickTrayToShowQuickAssistant', value)
}
getEnableQuickAssistant(): boolean {
return this.store.get('enableQuickAssistant', false) as boolean
}
setEnableQuickAssistant(value: boolean) {
this.store.set('enableQuickAssistant', value)
}
set(key: string, value: any) {
this.store.set(key, value)
}
get(key: string) {
return this.store.get(key)
}
}
export const configManager = new ConfigManager()

View File

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

View File

@@ -263,6 +263,13 @@ class FileStorage {
}
}
public binaryFile = async (_: Electron.IpcMainInvokeEvent, id: string): Promise<{ data: Buffer; mime: string }> => {
const filePath = path.join(this.storageDir, id)
const data = await fs.promises.readFile(filePath)
const mime = `image/${path.extname(filePath).slice(1)}`
return { data, mime }
}
public clear = async (): Promise<void> => {
await fs.promises.rmdir(this.storageDir, { recursive: true })
await this.initStorageDir()
@@ -381,7 +388,7 @@ class FileStorage {
}
// 如果URL中有文件名使用URL中的文件名
const urlFilename = url.split('/').pop()
const urlFilename = url.split('/').pop()?.split('?')[0]
if (urlFilename && urlFilename.includes('.')) {
filename = urlFilename
}

View File

@@ -83,54 +83,103 @@ class KnowledgeService {
if (item.type === 'directory') {
const directory = item.content as string
return await ragApplication.addLoader(new LocalPathLoader({ path: directory }), forceReload)
return await ragApplication.addLoader(
new LocalPathLoader({ path: directory, chunkSize: base.chunkSize, chunkOverlap: base.chunkOverlap }) as any,
forceReload
)
}
if (item.type === 'url') {
const content = item.content as string
if (content.startsWith('http')) {
// @ts-ignore loader type
return await ragApplication.addLoader(new WebLoader({ urlOrContent: content }), forceReload)
return await ragApplication.addLoader(
new WebLoader({ urlOrContent: content, chunkSize: base.chunkSize, chunkOverlap: base.chunkOverlap }) as any,
forceReload
)
}
}
if (item.type === 'sitemap') {
const content = item.content as string
// @ts-ignore loader type
return await ragApplication.addLoader(new SitemapLoader({ url: content }), forceReload)
return await ragApplication.addLoader(
new SitemapLoader({ url: content, chunkSize: base.chunkSize, chunkOverlap: base.chunkOverlap }) as any,
forceReload
)
}
if (item.type === 'note') {
const content = item.content as string
return await ragApplication.addLoader(new TextLoader({ text: content }), forceReload)
return await ragApplication.addLoader(
new TextLoader({ text: content, chunkSize: base.chunkSize, chunkOverlap: base.chunkOverlap }),
forceReload
)
}
if (item.type === 'file') {
const file = item.content as FileType
if (file.ext === '.pdf') {
return await ragApplication.addLoader(new PdfLoader({ filePathOrUrl: file.path }) as any, forceReload)
return await ragApplication.addLoader(
new PdfLoader({
filePathOrUrl: file.path,
chunkSize: base.chunkSize,
chunkOverlap: base.chunkOverlap
}) as any,
forceReload
)
}
if (file.ext === '.docx') {
return await ragApplication.addLoader(new DocxLoader({ filePathOrUrl: file.path }) as any, forceReload)
return await ragApplication.addLoader(
new DocxLoader({
filePathOrUrl: file.path,
chunkSize: base.chunkSize,
chunkOverlap: base.chunkOverlap
}) as any,
forceReload
)
}
if (file.ext === '.pptx') {
return await ragApplication.addLoader(new PptLoader({ filePathOrUrl: file.path }) as any, forceReload)
return await ragApplication.addLoader(
new PptLoader({
filePathOrUrl: file.path,
chunkSize: base.chunkSize,
chunkOverlap: base.chunkOverlap
}) as any,
forceReload
)
}
if (file.ext === '.xlsx') {
return await ragApplication.addLoader(new ExcelLoader({ filePathOrUrl: file.path }) as any, forceReload)
return await ragApplication.addLoader(
new ExcelLoader({
filePathOrUrl: file.path,
chunkSize: base.chunkSize,
chunkOverlap: base.chunkOverlap
}) as any,
forceReload
)
}
if (['.md'].includes(file.ext)) {
return await ragApplication.addLoader(new MarkdownLoader({ filePathOrUrl: file.path }) as any, forceReload)
return await ragApplication.addLoader(
new MarkdownLoader({
filePathOrUrl: file.path,
chunkSize: base.chunkSize,
chunkOverlap: base.chunkOverlap
}) as any,
forceReload
)
}
const fileContent = fs.readFileSync(file.path, 'utf-8')
return await ragApplication.addLoader(new TextLoader({ text: fileContent }), forceReload)
return await ragApplication.addLoader(
new TextLoader({ text: fileContent, chunkSize: base.chunkSize, chunkOverlap: base.chunkOverlap }),
forceReload
)
}
return { entriesAdded: 0, uniqueId: '', loaderType: '' }

View File

@@ -3,8 +3,10 @@ import { BrowserWindow, globalShortcut } from 'electron'
import Logger from 'electron-log'
import { configManager } from './ConfigManager'
import { windowService } from './WindowService'
let showAppAccelerator: string | null = null
let showMiniWindowAccelerator: string | null = null
function getShortcutHandler(shortcut: Shortcut) {
switch (shortcut.key) {
@@ -26,6 +28,10 @@ function getShortcutHandler(shortcut: Shortcut) {
window.focus()
}
}
case 'mini_window':
return () => {
windowService.toggleMiniWindow()
}
default:
return null
}
@@ -73,6 +79,10 @@ export function registerShortcuts(window: BrowserWindow) {
showAppAccelerator = accelerator
}
if (shortcut.key === 'mini_window') {
showMiniWindowAccelerator = accelerator
}
if (shortcut.key.includes('zoom')) {
switch (shortcut.key) {
case 'zoom_in':
@@ -90,7 +100,7 @@ export function registerShortcuts(window: BrowserWindow) {
}
if (shortcut.enabled) {
globalShortcut.register(accelerator, () => handler(window))
globalShortcut.register(formatShortcutKey(shortcut.shortcut), () => handler(window))
}
} catch (error) {
Logger.error(`[ShortcutService] Failed to register shortcut ${shortcut.key}`)
@@ -108,6 +118,11 @@ export function registerShortcuts(window: BrowserWindow) {
const handler = getShortcutHandler({ key: 'show_app' } as Shortcut)
handler && globalShortcut.register(showAppAccelerator, () => handler(window))
}
if (showMiniWindowAccelerator) {
const handler = getShortcutHandler({ key: 'mini_window' } as Shortcut)
handler && globalShortcut.register(showMiniWindowAccelerator, () => handler(window))
}
} catch (error) {
Logger.error('[ShortcutService] Failed to unregister shortcuts')
}
@@ -124,6 +139,7 @@ export function registerShortcuts(window: BrowserWindow) {
export function unregisterAllShortcuts() {
try {
showAppAccelerator = null
showMiniWindowAccelerator = null
globalShortcut.unregisterAll()
} catch (error) {
Logger.error('[ShortcutService] Failed to unregister all shortcuts')

View File

@@ -1,6 +1,6 @@
import { isMac } from '@main/constant'
import { locales } from '@main/utils/locales'
import { app, Menu, nativeImage, nativeTheme, Tray } from 'electron'
import { app, Menu, MenuItemConstructorOptions, nativeImage, nativeTheme, Tray } from 'electron'
import icon from '../../../build/tray_icon.png?asset'
import iconDark from '../../../build/tray_icon_dark.png?asset'
@@ -9,14 +9,22 @@ import { configManager } from './ConfigManager'
import { windowService } from './WindowService'
export class TrayService {
private static instance: TrayService
private tray: Tray | null = null
constructor() {
this.updateTray()
this.watchTrayChanges()
TrayService.instance = this
}
public static getInstance() {
return TrayService.instance
}
private createTray() {
this.destroyTray()
const iconPath = isMac ? (nativeTheme.shouldUseDarkColors ? iconLight : iconDark) : icon
const tray = new Tray(iconPath)
@@ -38,17 +46,25 @@ export class TrayService {
const locale = locales[configManager.getLanguage()]
const { tray: trayLocale } = locale.translation
const contextMenu = Menu.buildFromTemplate([
const enableQuickAssistant = configManager.getEnableQuickAssistant()
const template = [
{
label: trayLocale.show_window,
click: () => windowService.showMainWindow()
},
enableQuickAssistant && {
label: trayLocale.show_mini_window,
click: () => windowService.showMiniWindow()
},
{ type: 'separator' },
{
label: trayLocale.quit,
click: () => this.quit()
}
])
].filter(Boolean) as MenuItemConstructorOptions[]
const contextMenu = Menu.buildFromTemplate(template)
if (process.platform === 'linux') {
this.tray.setContextMenu(contextMenu)
@@ -61,18 +77,30 @@ export class TrayService {
})
this.tray.on('click', () => {
windowService.showMainWindow()
if (enableQuickAssistant && configManager.getClickTrayToShowQuickAssistant()) {
windowService.showMiniWindow()
} else {
windowService.showMainWindow()
}
})
}
private updateTray() {
if (configManager.isTray()) {
const showTray = configManager.getTray()
if (showTray) {
this.createTray()
} else {
this.destroyTray()
}
}
public restartTray() {
if (configManager.getTray()) {
this.destroyTray()
this.createTray()
}
}
private destroyTray() {
if (this.tray) {
this.tray.destroy()

View File

@@ -1,6 +1,6 @@
import { is } from '@electron-toolkit/utils'
import { isLinux, isWin } from '@main/constant'
import { app, BrowserWindow, Menu, MenuItem, shell } from 'electron'
import { app, BrowserWindow, ipcMain, Menu, MenuItem, shell } from 'electron'
import Logger from 'electron-log'
import windowStateKeeper from 'electron-window-state'
import path, { join } from 'path'
@@ -13,8 +13,10 @@ import { configManager } from './ConfigManager'
export class WindowService {
private static instance: WindowService | null = null
private mainWindow: BrowserWindow | null = null
private isQuitting: boolean = false
private miniWindow: BrowserWindow | null = null
private wasFullScreen: boolean = false
private selectionMenuWindow: BrowserWindow | null = null
private lastSelectedText: string = ''
public static getInstance(): WindowService {
if (!WindowService.instance) {
@@ -63,6 +65,7 @@ export class WindowService {
})
this.setupMainWindow(this.mainWindow, mainWindowState)
return this.mainWindow
}
@@ -195,30 +198,25 @@ export class WindowService {
}
private setupWindowLifecycleEvents(mainWindow: BrowserWindow) {
// 监听应用退出事件
app.on('before-quit', () => {
this.isQuitting = true
})
mainWindow.on('close', (event) => {
const notInTray = !configManager.isTray()
// 如果已经触发退出,直接退出
if (app.isQuitting) {
return app.quit()
}
// Windows and Linux
// 没有开启托盘且是Windows或Linux系统直接退出
const notInTray = !configManager.getTray()
if ((isWin || isLinux) && notInTray) {
return app.quit()
}
// Mac
if (!this.isQuitting) {
if (this.wasFullScreen) {
// 如果是全屏状态,直接退出
this.isQuitting = true
app.quit()
} else {
event.preventDefault()
mainWindow.hide()
}
// 如果是全屏状态,直接退出
if (this.wasFullScreen) {
return app.quit()
}
event.preventDefault()
mainWindow.hide()
})
}
@@ -233,6 +231,164 @@ export class WindowService {
this.createMainWindow()
}
}
public showMiniWindow() {
const enableQuickAssistant = configManager.getEnableQuickAssistant()
if (!enableQuickAssistant) {
return
}
if (this.selectionMenuWindow) {
this.selectionMenuWindow.hide()
}
if (this.miniWindow && !this.miniWindow.isDestroyed()) {
if (this.miniWindow.isMinimized()) {
this.miniWindow.restore()
}
this.miniWindow.show()
this.miniWindow.center()
this.miniWindow.focus()
return
}
const isMac = process.platform === 'darwin'
this.miniWindow = new BrowserWindow({
width: 500,
height: 520,
show: true,
autoHideMenuBar: true,
transparent: isMac,
vibrancy: 'under-window',
visualEffectState: 'followWindow',
center: true,
frame: false,
alwaysOnTop: true,
resizable: false,
useContentSize: true,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
webSecurity: false,
webviewTag: true
}
})
this.miniWindow.on('blur', () => {
this.miniWindow?.hide()
})
this.miniWindow.on('closed', () => {
this.miniWindow = null
})
this.miniWindow.on('hide', () => {
this.miniWindow?.webContents.send('hide-mini-window')
})
this.miniWindow.on('show', () => {
this.miniWindow?.webContents.send('show-mini-window')
})
ipcMain.on('miniwindow-reload', () => {
this.miniWindow?.reload()
})
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
this.miniWindow.loadURL(process.env['ELECTRON_RENDERER_URL'] + '#/mini')
} else {
this.miniWindow.loadFile(join(__dirname, '../renderer/index.html'), {
hash: '#/mini'
})
}
}
public hideMiniWindow() {
this.miniWindow?.hide()
}
public closeMiniWindow() {
this.miniWindow?.close()
}
public toggleMiniWindow() {
if (this.miniWindow) {
this.miniWindow.isVisible() ? this.miniWindow.hide() : this.miniWindow.show()
} else {
this.showMiniWindow()
}
}
public showSelectionMenu(bounds: { x: number; y: number }) {
if (this.selectionMenuWindow && !this.selectionMenuWindow.isDestroyed()) {
this.selectionMenuWindow.setPosition(bounds.x, bounds.y)
this.selectionMenuWindow.show()
return
}
const theme = configManager.getTheme()
const isMac = process.platform === 'darwin'
this.selectionMenuWindow = new BrowserWindow({
width: 280,
height: 40,
x: bounds.x,
y: bounds.y,
show: true,
autoHideMenuBar: true,
transparent: true,
frame: false,
alwaysOnTop: false,
skipTaskbar: true,
backgroundColor: isMac ? undefined : theme === 'dark' ? '#181818' : '#FFFFFF',
resizable: false,
vibrancy: 'popover',
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
webSecurity: false
}
})
// 点击其他地方时隐藏窗口
this.selectionMenuWindow.on('blur', () => {
this.selectionMenuWindow?.hide()
this.miniWindow?.webContents.send('selection-action', {
action: 'home',
selectedText: this.lastSelectedText
})
})
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
this.selectionMenuWindow.loadURL(process.env['ELECTRON_RENDERER_URL'] + '/src/windows/menu/menu.html')
} else {
this.selectionMenuWindow.loadFile(join(__dirname, '../renderer/src/windows/menu/menu.html'))
}
this.setupSelectionMenuEvents()
}
private setupSelectionMenuEvents() {
if (!this.selectionMenuWindow) return
ipcMain.removeHandler('selection-menu:action')
ipcMain.handle('selection-menu:action', (_, action) => {
this.selectionMenuWindow?.hide()
this.showMiniWindow()
setTimeout(() => {
this.miniWindow?.webContents.send('selection-action', {
action,
selectedText: this.lastSelectedText
})
}, 100)
})
}
public setLastSelectedText(text: string) {
this.lastSelectedText = text
}
}
export const windowService = WindowService.getInstance()

View File

@@ -22,3 +22,23 @@ export function getInstanceName(baseURL: string) {
return ''
}
}
export function debounce(func: (...args: any[]) => void, wait: number, immediate: boolean = false) {
let timeout: NodeJS.Timeout | null = null
return function (...args: any[]) {
if (timeout) clearTimeout(timeout)
if (immediate) {
func(...args)
} else {
timeout = setTimeout(() => func(...args), wait)
}
}
}
export function dumpPersistState() {
const persistState = JSON.parse(localStorage.getItem('persist:cherry-studio') || '{}')
for (const key in persistState) {
persistState[key] = JSON.parse(persistState[key])
}
return JSON.stringify(persistState)
}

View File

@@ -18,6 +18,7 @@ declare global {
setProxy: (proxy: string | undefined) => void
setLanguage: (theme: LanguageVarious) => void
setTray: (isActive: boolean) => void
restartTray: () => void
setTheme: (theme: 'light' | 'dark') => void
minApp: (options: { url: string; windowOptions?: Electron.BrowserWindowConstructorOptions }) => void
reload: () => void
@@ -53,6 +54,10 @@ declare global {
base64Image: (fileId: string) => Promise<{ mime: string; base64: string; data: string }>
download: (url: string) => Promise<FileType | null>
copy: (fileId: string, destPath: string) => Promise<void>
binaryFile: (fileId: string) => Promise<{ data: Buffer; mime: string }>
}
fs: {
read: (path: string) => Promise<string>
}
export: {
toWord: (markdown: string, fileName: string) => Promise<void>
@@ -88,6 +93,19 @@ declare global {
listFiles: (apiKey: string) => Promise<ListFilesResponse>
deleteFile: (apiKey: string, fileId: string) => Promise<void>
}
selectionMenu: {
action: (action: string) => Promise<void>
}
config: {
set: (key: string, value: any) => Promise<void>
get: (key: string) => Promise<any>
}
miniWindow: {
show: () => Promise<void>
hide: () => Promise<void>
close: () => Promise<void>
toggle: () => Promise<void>
}
}
}
}

View File

@@ -10,6 +10,7 @@ const api = {
checkForUpdate: () => ipcRenderer.invoke('app:check-for-update'),
setLanguage: (lang: string) => ipcRenderer.invoke('app:set-language', lang),
setTray: (isActive: boolean) => ipcRenderer.invoke('app:set-tray', isActive),
restartTray: () => ipcRenderer.invoke('app:restart-tray'),
setTheme: (theme: 'light' | 'dark') => ipcRenderer.invoke('app:set-theme', theme),
openWebsite: (url: string) => ipcRenderer.invoke('open:website', url),
minApp: (url: string) => ipcRenderer.invoke('minapp', url),
@@ -43,7 +44,11 @@ const api = {
saveImage: (name: string, data: string) => ipcRenderer.invoke('file:saveImage', name, data),
base64Image: (fileId: string) => ipcRenderer.invoke('file:base64Image', fileId),
download: (url: string) => ipcRenderer.invoke('file:download', url),
copy: (fileId: string, destPath: string) => ipcRenderer.invoke('file:copy', fileId, destPath)
copy: (fileId: string, destPath: string) => ipcRenderer.invoke('file:copy', fileId, destPath),
binaryFile: (fileId: string) => ipcRenderer.invoke('file:binaryFile', fileId)
},
fs: {
read: (path: string) => ipcRenderer.invoke('fs:read', path)
},
export: {
toWord: (markdown: string, fileName: string) => ipcRenderer.invoke('export:word', markdown, fileName)
@@ -81,6 +86,19 @@ const api = {
retrieveFile: (file: FileType, apiKey: string) => ipcRenderer.invoke('gemini:retrieve-file', file, apiKey),
listFiles: (apiKey: string) => ipcRenderer.invoke('gemini:list-files', apiKey),
deleteFile: (apiKey: string, fileId: string) => ipcRenderer.invoke('gemini:delete-file', apiKey, fileId)
},
selectionMenu: {
action: (action: string) => ipcRenderer.invoke('selection-menu:action', action)
},
config: {
set: (key: string, value: any) => ipcRenderer.invoke('config:set', key, value),
get: (key: string) => ipcRenderer.invoke('config:get', key)
},
miniWindow: {
show: () => ipcRenderer.invoke('miniwindow:show'),
hide: () => ipcRenderer.invoke('miniwindow:hide'),
close: () => ipcRenderer.invoke('miniwindow:close'),
toggle: () => ipcRenderer.invoke('miniwindow:toggle')
}
}

View File

@@ -17,10 +17,10 @@
position: fixed;
width: 100vw;
height: 100vh;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
display: none;
}
#spinner img {
@@ -35,6 +35,7 @@
<div id="spinner">
<img src="/src/assets/images/logo.png" />
</div>
<script type="module" src="/src/init.ts"></script>
<script type="module" src="/src/main.tsx"></script>
</body>

View File

@@ -16,7 +16,6 @@ import FilesPage from './pages/files/FilesPage'
import HomePage from './pages/home/HomePage'
import KnowledgePage from './pages/knowledge/KnowledgePage'
import PaintingsPage from './pages/paintings/PaintingsPage'
import SettingsPage from './pages/settings/SettingsPage'
import TranslatePage from './pages/translate/TranslatePage'
function App(): JSX.Element {
@@ -37,7 +36,6 @@ function App(): JSX.Element {
<Route path="/files" element={<FilesPage />} />
<Route path="/knowledge" element={<KnowledgePage />} />
<Route path="/apps" element={<AppsPage />} />
<Route path="/settings/*" element={<SettingsPage />} />
</Routes>
</HashRouter>
</TopViewContainer>

View File

@@ -0,0 +1,4 @@
<svg width="464" height="464" viewBox="0 0 464 464" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="464" height="464" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M243 127C235.268 127 229 133.268 229 141V322C229 329.732 235.268 336 243 336H283C290.732 336 297 329.732 297 322V141C297 133.268 290.732 127 283 127H243ZM167.562 128C163.762 128 160.317 129.518 157.805 131.978C157.787 131.995 157.759 131.977 157.767 131.954C157.775 131.93 157.743 131.913 157.727 131.933L157.311 132.486C156.679 133.171 156.115 133.92 155.629 134.722C154.303 136.486 153.139 138.365 152.152 140.338L88.8745 266.857L85.2894 274.899C85.2249 275.037 85.1626 275.177 85.1027 275.318L84.7141 276.189C84.7086 276.201 84.7223 276.213 84.7339 276.206C84.745 276.2 84.7583 276.211 84.7541 276.223C84.2654 277.639 84 279.16 84 280.742L84 322.399C84 330.067 90.2354 336.284 97.9271 336.284H139.708C147.4 336.284 153.635 330.067 153.635 322.399V266.857L153.636 252.97C153.636 222.295 178.577 197.428 209.344 197.428C217.035 197.428 223.271 191.211 223.271 183.542V141.886C223.271 134.217 217.035 128 209.344 128H167.562ZM304.5 301.57C304.5 282.398 320.088 266.856 339.318 266.856C358.547 266.856 374.135 282.398 374.135 301.57C374.135 320.742 358.547 336.284 339.318 336.284C320.088 336.284 304.5 320.742 304.5 301.57Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

View File

@@ -24,6 +24,7 @@
--color-background: var(--color-black);
--color-background-soft: var(--color-black-soft);
--color-background-mute: var(--color-black-mute);
--color-background-opacity: rgba(34, 34, 34, 0.7);
--color-primary: #00b96b;
--color-primary-soft: #00b96b99;
@@ -87,6 +88,7 @@ body[theme-mode='light'] {
--color-background: var(--color-white);
--color-background-soft: var(--color-white-soft);
--color-background-mute: var(--color-white-mute);
--color-background-opacity: rgba(255, 255, 255, 0.7);
--color-primary: #00b96b;
--color-primary-soft: #00b96b99;
@@ -232,6 +234,9 @@ body,
border-radius: 8px;
padding: 10px 15px 0 15px;
}
.message-thought-container {
margin-top: 8px;
}
.message-user {
color: var(--chat-text-user);
.markdown,
@@ -244,6 +249,13 @@ body,
background-color: var(--color-white-soft);
}
}
.group-message-wrapper {
background-color: var(--color-background);
.message-content-container {
width: 100%;
border: 1px solid var(--color-background-mute);
}
}
code {
color: var(--color-text);
}

View File

@@ -1,7 +1,9 @@
/* eslint-disable react/no-unknown-property */
import { CloseOutlined, ExportOutlined, ReloadOutlined } from '@ant-design/icons'
import { CloseOutlined, ExportOutlined, PushpinOutlined, ReloadOutlined } from '@ant-design/icons'
import { isMac, isWindows } from '@renderer/config/constant'
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
import { useBridge } from '@renderer/hooks/useBridge'
import { useMinapps } from '@renderer/hooks/useMinapps'
import store from '@renderer/store'
import { setMinappShow } from '@renderer/store/runtime'
import { MinAppType } from '@renderer/types'
@@ -20,6 +22,8 @@ interface Props {
}
const PopupContainer: React.FC<Props> = ({ app, resolve }) => {
const { pinned, updatePinnedMinapps } = useMinapps()
const isPinned = pinned.some((p) => p.id === app.id)
const [open, setOpen] = useState(true)
const [opened, setOpened] = useState(false)
const [isReady, setIsReady] = useState(false)
@@ -28,6 +32,7 @@ const PopupContainer: React.FC<Props> = ({ app, resolve }) => {
useBridge()
const canOpenExternalLink = app.url.startsWith('http://') || app.url.startsWith('https://')
const canPinned = DEFAULT_MIN_APPS.some((i) => i.id === app?.id)
const onClose = async (_delay = 0.3) => {
setOpen(false)
@@ -47,6 +52,11 @@ const PopupContainer: React.FC<Props> = ({ app, resolve }) => {
window.api.openWebsite(app.url)
}
const onTogglePin = () => {
const newPinned = isPinned ? pinned.filter((item) => item.id !== app.id) : [...pinned, app]
updatePinnedMinapps(newPinned)
}
const Title = () => {
return (
<TitleContainer style={{ justifyContent: 'space-between' }}>
@@ -55,6 +65,11 @@ const PopupContainer: React.FC<Props> = ({ app, resolve }) => {
<Button onClick={onReload}>
<ReloadOutlined />
</Button>
{canPinned && (
<Button onClick={onTogglePin} className={isPinned ? 'pinned' : ''}>
<PushpinOutlined style={{ fontSize: 16 }} />
</Button>
)}
{canOpenExternalLink && (
<Button onClick={onOpenLink}>
<ExportOutlined />
@@ -140,7 +155,7 @@ const TitleContainer = styled.div`
display: flex;
flex-direction: row;
align-items: center;
padding-left: ${isMac ? '20px' : '15px'};
padding-left: ${isMac ? '20px' : '10px'};
padding-right: 10px;
position: absolute;
top: 0;
@@ -188,6 +203,10 @@ const Button = styled.div`
color: var(--color-text-1);
background-color: var(--color-background-mute);
}
&.pinned {
color: var(--color-primary);
background-color: var(--color-primary-bg);
}
`
const EmptyView = styled.div`
@@ -207,7 +226,7 @@ export default class MinApp {
static app: MinAppType | null = null
static async start(app: MinAppType) {
if (MinApp.app?.id === app.id) {
if (app?.id && MinApp.app?.id === app?.id) {
return
}

View File

@@ -1,8 +1,8 @@
import { SearchOutlined } from '@ant-design/icons'
import { TopView } from '@renderer/components/TopView'
import systemAgents from '@renderer/config/agents.json'
import { useAgents } from '@renderer/hooks/useAgents'
import { useAssistants, useDefaultAssistant } from '@renderer/hooks/useAssistant'
import { useSystemAgents } from '@renderer/pages/agents'
import { createAssistantFromAgent } from '@renderer/services/AssistantService'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { Agent, Assistant } from '@renderer/types'
@@ -28,6 +28,7 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
const { defaultAssistant } = useDefaultAssistant()
const { assistants, addAssistant } = useAssistants()
const inputRef = useRef<InputRef>(null)
const systemAgents = useSystemAgents()
const agents = useMemo(() => {
const allAgents = [...userAgents, ...systemAgents] as Agent[]
@@ -48,7 +49,7 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
return [newAgent, ...filtered]
}
return filtered
}, [assistants, defaultAssistant, searchText, userAgents])
}, [assistants, defaultAssistant, searchText, systemAgents, userAgents])
const onCreateAssistant = async (agent: Agent) => {
let assistant: Assistant
@@ -120,7 +121,11 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
key={agent.id}
onClick={() => onCreateAssistant(agent)}
className={agent.id === 'default' ? 'default' : ''}>
<HStack alignItems="center" gap={5}>
<HStack
alignItems="center"
gap={5}
style={{ overflow: 'hidden', maxWidth: '100%' }}
className="text-nowrap">
{agent.emoji} {agent.name}
</HStack>
{agent.id === 'default' && <Tag color="green">{t('agents.tag.system')}</Tag>}
@@ -149,6 +154,7 @@ const AgentItem = styled.div`
user-select: none;
margin-bottom: 8px;
cursor: pointer;
overflow: hidden;
&.default {
background-color: var(--color-background-mute);
}

View File

@@ -1,7 +1,6 @@
import { PushpinOutlined, SearchOutlined } from '@ant-design/icons'
import VisionIcon from '@renderer/components/Icons/VisionIcon'
import { TopView } from '@renderer/components/TopView'
import { getModelLogo, isEmbeddingModel, isVisionModel } from '@renderer/config/models'
import { getModelLogo, isEmbeddingModel } from '@renderer/config/models'
import db from '@renderer/databases'
import { useProviders } from '@renderer/hooks/useProvider'
import { getModelUniqId } from '@renderer/services/ModelService'
@@ -33,6 +32,7 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
const inputRef = useRef<InputRef>(null)
const { providers } = useProviders()
const [pinnedModels, setPinnedModels] = useState<string[]>([])
const scrollContainerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const loadPinnedModels = async () => {
@@ -118,7 +118,7 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
key: getModelUniqId(m) + '_pinned',
label: (
<ModelItem>
{m?.name} {isVisionModel(m) && <VisionIcon />}
{m?.name} <ModelTags model={m} />
<PinIcon
onClick={(e) => {
e.stopPropagation()
@@ -163,6 +163,17 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
open && setTimeout(() => inputRef.current?.focus(), 0)
}, [open])
useEffect(() => {
if (open && model) {
setTimeout(() => {
const selectedElement = document.querySelector('.ant-menu-item-selected')
if (selectedElement && scrollContainerRef.current) {
selectedElement.scrollIntoView({ block: 'center', behavior: 'auto' })
}
}, 100) // Small delay to ensure menu is rendered
}
}, [open, model])
return (
<Modal
centered
@@ -200,7 +211,7 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
/>
</HStack>
<Divider style={{ margin: 0, borderBlockStartWidth: 0.5 }} />
<Scrollbar style={{ height: '50vh' }}>
<Scrollbar style={{ height: '50vh' }} ref={scrollContainerRef}>
<Container>
{filteredItems.length > 0 ? (
<StyledMenu

View File

@@ -0,0 +1,66 @@
import SettingsPage, { SettingsTab } from '@renderer/pages/settings/SettingsPage'
import { Modal } from 'antd'
import { FC, useState } from 'react'
import styled, { createGlobalStyle } from 'styled-components'
interface Props {
actionButton?: React.ReactNode
activeTab?: SettingsTab
}
const SettingsPopup: FC<Props> = (props) => {
const [open, setOpen] = useState(false)
const [activeTab, setActiveTab] = useState<SettingsTab | undefined>(props.activeTab)
const onOpen = () => {
if (props.activeTab) {
setActiveTab(props.activeTab)
}
setOpen(true)
}
const onCancel = () => {
setOpen(false)
}
return (
<>
<div onClick={onOpen}>{props.actionButton}</div>
<GlobalStyle />
<StyledModal
transitionName="ant-move-down"
width="80vw"
title={null}
open={open}
onCancel={onCancel}
footer={null}>
<SettingsPage activeTab={activeTab} onTabChange={setActiveTab} />
</StyledModal>
</>
)
}
const GlobalStyle = createGlobalStyle`
.ant-modal-mask {
backdrop-filter: blur(10px);
background-color: transparent !important;
}
`
const StyledModal = styled(Modal)`
min-width: 900px;
max-width: 1300px;
padding-bottom: 0;
.ant-modal-content {
padding: 0;
overflow: hidden;
border-radius: 12px;
}
.ant-modal-close {
top: 4px;
right: 4px;
}
`
export default SettingsPopup

View File

@@ -1,10 +1,10 @@
import { FileSearchOutlined, FolderOutlined, PictureOutlined, TranslationOutlined } from '@ant-design/icons'
import { isMac } from '@renderer/config/constant'
import { isLocalAi, UserAvatar } from '@renderer/config/env'
import { UserAvatar } from '@renderer/config/env'
import { useTheme } from '@renderer/context/ThemeProvider'
import useAvatar from '@renderer/hooks/useAvatar'
import { useMinapps } from '@renderer/hooks/useMinapps'
import { modelGenerating, useRuntime } from '@renderer/hooks/useRuntime'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings'
import type { MenuProps } from 'antd'
import { Tooltip } from 'antd'
@@ -18,14 +18,13 @@ import styled from 'styled-components'
import DragableList from '../DragableList'
import MinAppIcon from '../Icons/MinAppIcon'
import MinApp from '../MinApp'
import SettingsPopup from '../Popups/SettingsPopup'
import UserPopup from '../Popups/UserPopup'
const Sidebar: FC = () => {
const { pathname } = useLocation()
const avatar = useAvatar()
const { minappShow } = useRuntime()
const { t } = useTranslation()
const navigate = useNavigate()
const { windowStyle, sidebarIcons } = useSettings()
const { theme, toggleTheme } = useTheme()
const { pinned } = useMinapps()
@@ -37,11 +36,6 @@ const Sidebar: FC = () => {
const showPinnedApps = pinned.length > 0 && sidebarIcons.visible.includes('minapp')
const to = async (path: string) => {
await modelGenerating()
navigate(path)
}
return (
<Container
id="app-sidebar"
@@ -73,13 +67,15 @@ const Sidebar: FC = () => {
)}
</Icon>
</Tooltip>
<Tooltip title={t('settings.title')} mouseEnterDelay={0.8} placement="right">
<StyledLink onClick={() => to(isLocalAi ? '/settings/assistant' : '/settings/provider')}>
<Icon className={pathname.startsWith('/settings') ? 'active' : ''}>
<i className="iconfont icon-setting" />
</Icon>
</StyledLink>
</Tooltip>
<SettingsPopup
actionButton={
<Tooltip title={t('settings.title')} mouseEnterDelay={0.8} placement="right">
<Icon>
<i className="iconfont icon-setting" />
</Icon>
</Tooltip>
}
/>
</Menus>
</Container>
)
@@ -166,6 +162,7 @@ const Container = styled.div`
flex-direction: column;
align-items: center;
padding: 8px 0;
padding-bottom: 12px;
width: var(--sidebar-width);
min-width: var(--sidebar-width);
height: ${isMac ? 'calc(100vh - var(--navbar-height))' : '100vh'};

View File

@@ -1,38 +1,39 @@
import BaiduAiAppLogo from '@renderer/assets/images/apps/baidu-ai.png'
import BaicuanAppLogo from '@renderer/assets/images/apps/baixiaoying.webp'
import BoltAppLogo from '@renderer/assets/images/apps/bolt.svg'
import DevvAppLogo from '@renderer/assets/images/apps/devv.png'
import DoubaoAppLogo from '@renderer/assets/images/apps/doubao.png'
import DuckDuckGoAppLogo from '@renderer/assets/images/apps/duckduckgo.webp'
import FeloAppLogo from '@renderer/assets/images/apps/felo.png'
import GeminiAppLogo from '@renderer/assets/images/apps/gemini.png'
import GensparkLogo from '@renderer/assets/images/apps/genspark.jpg'
import GithubCopilotLogo from '@renderer/assets/images/apps/github-copilot.webp'
import GrokAppLogo from '@renderer/assets/images/apps/grok.png'
import HikaLogo from '@renderer/assets/images/apps/hika.webp'
import HuggingChatLogo from '@renderer/assets/images/apps/huggingchat.svg'
import KimiAppLogo from '@renderer/assets/images/apps/kimi.jpg'
import MetasoAppLogo from '@renderer/assets/images/apps/metaso.webp'
import NamiAiSearchLogo from '@renderer/assets/images/apps/nm.webp'
import PerplexityAppLogo from '@renderer/assets/images/apps/perplexity.webp'
import PoeAppLogo from '@renderer/assets/images/apps/poe.webp'
import ZhipuProviderLogo from '@renderer/assets/images/apps/qingyan.png'
import QwenlmAppLogo from '@renderer/assets/images/apps/qwenlm.webp'
import SensetimeAppLogo from '@renderer/assets/images/apps/sensetime.png'
import SparkDeskAppLogo from '@renderer/assets/images/apps/sparkdesk.png'
import ThinkAnyLogo from '@renderer/assets/images/apps/thinkany.webp'
import TiangongAiLogo from '@renderer/assets/images/apps/tiangong.png'
import WanZhiAppLogo from '@renderer/assets/images/apps/wanzhi.jpg'
import TencentYuanbaoAppLogo from '@renderer/assets/images/apps/yuanbao.png'
import YuewenAppLogo from '@renderer/assets/images/apps/yuewen.png'
import ZhihuAppLogo from '@renderer/assets/images/apps/zhihu.png'
import ClaudeAppLogo from '@renderer/assets/images/models/claude.png'
import HailuoModelLogo from '@renderer/assets/images/models/hailuo.png'
import QwenModelLogo from '@renderer/assets/images/models/qwen.png'
import DeepSeekProviderLogo from '@renderer/assets/images/providers/deepseek.png'
import GroqProviderLogo from '@renderer/assets/images/providers/groq.png'
import OpenAiProviderLogo from '@renderer/assets/images/providers/openai.png'
import SiliconFlowProviderLogo from '@renderer/assets/images/providers/silicon.png'
import BaiduAiAppLogo from '@renderer/assets/images/apps/baidu-ai.png?url'
import BaicuanAppLogo from '@renderer/assets/images/apps/baixiaoying.webp?url'
import BoltAppLogo from '@renderer/assets/images/apps/bolt.svg?url'
import DevvAppLogo from '@renderer/assets/images/apps/devv.png?url'
import DoubaoAppLogo from '@renderer/assets/images/apps/doubao.png?url'
import DuckDuckGoAppLogo from '@renderer/assets/images/apps/duckduckgo.webp?url'
import FeloAppLogo from '@renderer/assets/images/apps/felo.png?url'
import FlowithAppLogo from '@renderer/assets/images/apps/flowith.svg?url'
import GeminiAppLogo from '@renderer/assets/images/apps/gemini.png?url'
import GensparkLogo from '@renderer/assets/images/apps/genspark.jpg?url'
import GithubCopilotLogo from '@renderer/assets/images/apps/github-copilot.webp?url'
import GrokAppLogo from '@renderer/assets/images/apps/grok.png?url'
import HikaLogo from '@renderer/assets/images/apps/hika.webp?url'
import HuggingChatLogo from '@renderer/assets/images/apps/huggingchat.svg?url'
import KimiAppLogo from '@renderer/assets/images/apps/kimi.jpg?url'
import MetasoAppLogo from '@renderer/assets/images/apps/metaso.webp?url'
import NamiAiSearchLogo from '@renderer/assets/images/apps/nm.webp?url'
import PerplexityAppLogo from '@renderer/assets/images/apps/perplexity.webp?url'
import PoeAppLogo from '@renderer/assets/images/apps/poe.webp?url'
import ZhipuProviderLogo from '@renderer/assets/images/apps/qingyan.png?url'
import QwenlmAppLogo from '@renderer/assets/images/apps/qwenlm.webp?url'
import SensetimeAppLogo from '@renderer/assets/images/apps/sensetime.png?url'
import SparkDeskAppLogo from '@renderer/assets/images/apps/sparkdesk.png?url'
import ThinkAnyLogo from '@renderer/assets/images/apps/thinkany.webp?url'
import TiangongAiLogo from '@renderer/assets/images/apps/tiangong.png?url'
import WanZhiAppLogo from '@renderer/assets/images/apps/wanzhi.jpg?url'
import TencentYuanbaoAppLogo from '@renderer/assets/images/apps/yuanbao.png?url'
import YuewenAppLogo from '@renderer/assets/images/apps/yuewen.png?url'
import ZhihuAppLogo from '@renderer/assets/images/apps/zhihu.png?url'
import ClaudeAppLogo from '@renderer/assets/images/models/claude.png?url'
import HailuoModelLogo from '@renderer/assets/images/models/hailuo.png?url'
import QwenModelLogo from '@renderer/assets/images/models/qwen.png?url'
import DeepSeekProviderLogo from '@renderer/assets/images/providers/deepseek.png?url'
import GroqProviderLogo from '@renderer/assets/images/providers/groq.png?url'
import OpenAiProviderLogo from '@renderer/assets/images/providers/openai.png?url'
import SiliconFlowProviderLogo from '@renderer/assets/images/providers/silicon.png?url'
import MinApp from '@renderer/components/MinApp'
import { MinAppType } from '@renderer/types'
@@ -252,7 +253,7 @@ export const DEFAULT_MIN_APPS: MinAppType[] = [
id: 'grok',
name: 'Grok',
logo: GrokAppLogo,
url: 'https://x.com/i/grok',
url: 'https://grok.com',
bodered: true
},
{
@@ -260,6 +261,13 @@ export const DEFAULT_MIN_APPS: MinAppType[] = [
name: 'QwenLM',
logo: QwenlmAppLogo,
url: 'https://qwenlm.ai/'
},
{
id: 'flowith',
name: 'Flowith',
logo: FlowithAppLogo,
url: 'https://www.flowith.io/',
bodered: true
}
]

View File

@@ -10,6 +10,7 @@ import AisingaporeModelLogo from '@renderer/assets/images/models/aisingapore.png
import AisingaporeModelLogoDark from '@renderer/assets/images/models/aisingapore_dark.png'
import BaichuanModelLogo from '@renderer/assets/images/models/baichuan.png'
import BaichuanModelLogoDark from '@renderer/assets/images/models/baichuan_dark.png'
import BgeModelLogo from '@renderer/assets/images/models/bge.webp'
import BigcodeModelLogo from '@renderer/assets/images/models/bigcode.webp'
import BigcodeModelLogoDark from '@renderer/assets/images/models/bigcode_dark.webp'
import ChatGLMModelLogo from '@renderer/assets/images/models/chatglm.png'
@@ -251,7 +252,8 @@ export function getModelLogo(modelId: string) {
rakutenai: isLight ? RakutenaiModelLogo : RakutenaiModelLogoDark,
ibm: isLight ? IbmModelLogo : IbmModelLogoDark,
'google/': isLight ? GoogleModelLogo : GoogleModelLogoDark,
hugging: isLight ? HuggingfaceModelLogo : HuggingfaceModelLogoDark
hugging: isLight ? HuggingfaceModelLogo : HuggingfaceModelLogoDark,
'bge-': BgeModelLogo
}
for (const key in logoMap) {
@@ -264,6 +266,56 @@ export function getModelLogo(modelId: string) {
}
export const SYSTEM_MODELS: Record<string, Model[]> = {
qwenlm: [
{
id: 'qwen-plus-latest',
provider: 'qwenlm',
name: 'Qwen2.5-Plus',
group: 'Qwen 2.5'
},
{
id: 'qvq-72b-preview',
provider: 'qwenlm',
name: 'QVQ-72B-Preview',
group: 'QVQ'
},
{
id: 'qwq-32b-preview',
provider: 'qwenlm',
name: 'QwQ-32B-Preview',
group: 'QVQ'
},
{
id: 'qwen2.5-coder-32b-instruct',
provider: 'qwenlm',
name: 'Qwen2.5-Coder-32B-Instruct',
group: 'Qwen 2.5'
},
{
id: 'qwen-vl-max-latest',
provider: 'qwenlm',
name: 'Qwen2-VL-Max',
group: 'Qwen 2'
},
{
id: 'qwen-turbo-latest',
provider: 'qwenlm',
name: 'Qwen2.5-Turbo',
group: 'Qwen 2.5'
},
{
id: 'qwen2.5-72b-instruct',
provider: 'qwenlm',
name: 'Qwen2.5-72B-Instruct',
group: 'Qwen 2.5'
},
{
id: 'qwen2.5-32b-instruct',
provider: 'qwenlm',
name: 'Qwen2.5-32B-Instruct',
group: 'Qwen 2.5'
}
],
aihubmix: [
{
id: 'gpt-4o',
@@ -391,10 +443,10 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
group: 'DeepSeek Chat'
},
{
id: 'deepseek-coder',
id: 'deepseek-reasoner',
provider: 'deepseek',
name: 'DeepSeek Coder',
group: 'DeepSeek Coder'
name: 'DeepSeek Reasoner',
group: 'DeepSeek Reasoner'
}
],
together: [
@@ -708,6 +760,12 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
provider: 'minimax',
name: 'abab5.5s',
group: 'abab5'
},
{
id: 'minimax-text-01',
provider: 'minimax',
name: 'minimax-01',
group: 'minimax-01'
}
],
hyperbolic: [
@@ -1055,16 +1113,16 @@ export function isWebSearchModel(model: Model): boolean {
const provider = getProviderByModel(model)
if (provider.type === 'openai') {
if (!provider) {
return false
}
if (provider?.type === 'openai') {
if (model?.id?.includes('gemini-2.0-flash-exp')) {
return true
}
}
if (!provider) {
return false
}
if (provider.id === 'gemini' || provider?.type === 'gemini') {
return model?.id === 'gemini-2.0-flash-exp'
}

View File

@@ -23,6 +23,7 @@ import OcoolAiProviderLogo from '@renderer/assets/images/providers/ocoolai.png'
import OllamaProviderLogo from '@renderer/assets/images/providers/ollama.png'
import OpenAiProviderLogo from '@renderer/assets/images/providers/openai.png'
import OpenRouterProviderLogo from '@renderer/assets/images/providers/openrouter.png'
import QwenLMProviderLogo from '@renderer/assets/images/providers/qwenlm.png'
import SiliconFlowProviderLogo from '@renderer/assets/images/providers/silicon.png'
import StepProviderLogo from '@renderer/assets/images/providers/step.png'
import TogetherProviderLogo from '@renderer/assets/images/providers/together.png'
@@ -91,6 +92,8 @@ export function getProviderLogo(providerId: string) {
return MistralProviderLogo
case 'jina':
return JinaProviderLogo
case 'qwenlm':
return QwenLMProviderLogo
default:
return undefined
}
@@ -402,7 +405,7 @@ export const PROVIDER_CONFIG = {
url: 'https://integrate.api.nvidia.com'
},
websites: {
official: 'https://ai.360.com/',
official: 'https://build.nvidia.com/explore/discover',
apiKey: 'https://build.nvidia.com/meta/llama-3_1-405b-instruct',
docs: 'https://docs.api.nvidia.com/nim/reference/llm-apis',
models: 'https://build.nvidia.com/nim'
@@ -418,5 +421,16 @@ export const PROVIDER_CONFIG = {
docs: 'https://learn.microsoft.com/en-us/azure/ai-services/openai/',
models: 'https://learn.microsoft.com/en-us/azure/ai-services/openai/concepts/models'
}
},
qwenlm: {
api: {
url: 'https://chat.qwenlm.ai/api/'
},
websites: {
official: 'https://chat.qwenlm.ai',
apiKey: 'https://chat.qwenlm.ai',
docs: 'https://chat.qwenlm.ai',
models: 'https://chat.qwenlm.ai'
}
}
}

View File

@@ -0,0 +1,59 @@
import i18n from '@renderer/i18n'
export const TranslateLanguageOptions = [
{
value: 'english',
label: i18n.t('languages.english'),
emoji: '🇬🇧'
},
{
value: 'chinese',
label: i18n.t('languages.chinese'),
emoji: '🇨🇳'
},
{
value: 'chinese-traditional',
label: i18n.t('languages.chinese-traditional'),
emoji: '🇭🇰'
},
{
value: 'japanese',
label: i18n.t('languages.japanese'),
emoji: '🇯🇵'
},
{
value: 'korean',
label: i18n.t('languages.korean'),
emoji: '🇰🇷'
},
{
value: 'russian',
label: i18n.t('languages.russian'),
emoji: '🇷🇺'
},
{
value: 'spanish',
label: i18n.t('languages.spanish'),
emoji: '🇪🇸'
},
{
value: 'french',
label: i18n.t('languages.french'),
emoji: '🇫🇷'
},
{
value: 'italian',
label: i18n.t('languages.italian'),
emoji: '🇮🇹'
},
{
value: 'portuguese',
label: i18n.t('languages.portuguese'),
emoji: '🇵🇹'
},
{
value: 'arabic',
label: i18n.t('languages.arabic'),
emoji: '🇸🇦'
}
]

View File

@@ -1,6 +1,7 @@
import { isMac } from '@renderer/config/constant'
import { useSettings } from '@renderer/hooks/useSettings'
import { ThemeMode } from '@renderer/types'
import { isMiniWindow } from '@renderer/utils'
import React, { createContext, PropsWithChildren, useContext, useEffect, useState } from 'react'
interface ThemeContextType {
@@ -13,7 +14,11 @@ const ThemeContext = createContext<ThemeContextType>({
toggleTheme: () => {}
})
export const ThemeProvider: React.FC<PropsWithChildren> = ({ children }) => {
interface ThemeProviderProps extends PropsWithChildren {
defaultTheme?: ThemeMode
}
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children, defaultTheme }) => {
const { theme, setTheme } = useSettings()
const [_theme, _setTheme] = useState(theme)
@@ -22,7 +27,7 @@ export const ThemeProvider: React.FC<PropsWithChildren> = ({ children }) => {
}
useEffect((): any => {
if (theme === ThemeMode.auto) {
if (theme === ThemeMode.auto || defaultTheme === ThemeMode.auto) {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
_setTheme(mediaQuery.matches ? ThemeMode.dark : ThemeMode.light)
const handleChange = (e: MediaQueryListEvent) => _setTheme(e.matches ? ThemeMode.dark : ThemeMode.light)
@@ -31,11 +36,13 @@ export const ThemeProvider: React.FC<PropsWithChildren> = ({ children }) => {
} else {
_setTheme(theme)
}
}, [theme])
}, [defaultTheme, theme])
useEffect(() => {
document.body.setAttribute('theme-mode', _theme)
window.api?.setTheme(_theme === ThemeMode.dark ? 'dark' : 'light')
if (!isMiniWindow()) {
window.api?.setTheme(_theme === ThemeMode.dark ? 'dark' : 'light')
}
}, [_theme])
useEffect(() => {

View File

@@ -3,7 +3,7 @@ import { isLocalAi } from '@renderer/config/env'
import db from '@renderer/databases'
import i18n from '@renderer/i18n'
import { useAppDispatch } from '@renderer/store'
import { setAvatar, setFilesPath, setUpdateState } from '@renderer/store/runtime'
import { setAvatar, setFilesPath, setResourcesPath, setUpdateState } from '@renderer/store/runtime'
import { delay, runAsyncFunction } from '@renderer/utils'
import { useLiveQuery } from 'dexie-react-hooks'
import { useEffect } from 'react'
@@ -71,6 +71,7 @@ export function useAppInit() {
// set files path
window.api.getAppInfo().then((info) => {
dispatch(setFilesPath(info.filesPath))
dispatch(setResourcesPath(info.resourcesPath))
})
}, [dispatch])

View File

@@ -1,3 +1,4 @@
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
import { RootState, useAppDispatch, useAppSelector } from '@renderer/store'
import { setDisabledMinApps, setMinApps, setPinnedMinApps } from '@renderer/store/minapps'
import { MinAppType } from '@renderer/types'
@@ -7,9 +8,9 @@ export const useMinapps = () => {
const dispatch = useAppDispatch()
return {
minapps: enabled,
disabled,
pinned,
minapps: enabled.map((app) => DEFAULT_MIN_APPS.find((item) => item.id === app.id) || app),
disabled: disabled.map((app) => DEFAULT_MIN_APPS.find((item) => item.id === app.id) || app),
pinned: pinned.map((app) => DEFAULT_MIN_APPS.find((item) => item.id === app.id) || app),
updateMinapps: (minapps: MinAppType[]) => {
dispatch(setMinApps(minapps))
},

View File

@@ -22,6 +22,7 @@ export function useSettings() {
},
setTray(isActive: boolean) {
dispatch(setTray(isActive))
window.api.setTray(isActive)
},
setTheme(theme: ThemeMode) {
dispatch(setTheme(theme))

View File

@@ -1,5 +1,6 @@
import { isMac, isWindows } from '@renderer/config/constant'
import { useAppSelector } from '@renderer/store'
import { orderBy } from 'lodash'
import { useCallback } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
@@ -58,7 +59,7 @@ export const useShortcut = (
export function useShortcuts() {
const shortcuts = useAppSelector((state) => state.shortcuts.shortcuts)
return { shortcuts }
return { shortcuts: orderBy(shortcuts, 'system', 'desc') }
}
export function useShortcutDisplay(key: string) {

View File

@@ -9,7 +9,10 @@ export default function useUpdateHandler() {
const { t } = useTranslation()
useEffect(() => {
if (!window.electron) return
const ipcRenderer = window.electron.ipcRenderer
const removers = [
ipcRenderer.on('update-not-available', () => {
dispatch(setUpdateState({ checking: false }))

View File

@@ -64,14 +64,14 @@
"default.description": "Hello, I'm Default Assistant. You can start chatting with me right away",
"default.name": "⭐️ Default Assistant",
"default.topic.name": "Default Topic",
"input.clear": "Clear",
"input.clear": "Clear {{Command}}",
"input.clear.content": "Do you want to clear all messages of the current topic?",
"input.clear.title": "Clear all messages?",
"input.collapse": "Collapse",
"input.context_count.tip": "Context Count",
"input.estimated_tokens.tip": "Estimated tokens",
"input.expand": "Expand",
"input.new.context": "Clear Context",
"input.new.context": "Clear Context {{Command}}",
"input.new_topic": "New Topic {{Command}}",
"input.pause": "Pause",
"input.placeholder": "Type your message here...",
@@ -86,6 +86,7 @@
"message.new.branch.created": "New Branch Created",
"message.regenerate.model": "Switch Model",
"message.new.context": "New Context",
"message.useful": "Helpful",
"save": "Save",
"settings.code_collapsible": "Code block collapsible",
"settings.context_count": "Context",
@@ -113,7 +114,9 @@
"topics.move_to": "Move to",
"topics.title": "Topics",
"translate": "Translate",
"resend": "Resend"
"resend": "Resend",
"thinking": "Thinking",
"deeply_thought": "Deeply thought ({{secounds}} seconds)"
},
"common": {
"and": "and",
@@ -242,6 +245,7 @@
"error.enter.api.key": "Please enter your API key first",
"error.enter.model": "Please select a model first",
"error.enter.name": "Please enter the name of the knowledge base",
"error.chunk_overlap_too_large": "Chunk overlap cannot be greater than chunk size",
"error.invalid.proxy.url": "Invalid proxy URL",
"error.invalid.webdav": "Invalid WebDAV settings",
"message.code_style": "Code style",
@@ -250,6 +254,10 @@
"message.style": "Message style",
"message.style.bubble": "Bubble",
"message.style.plain": "Plain",
"message.multi_model_style": "Multi-model answer style",
"message.multi_model_style.horizontal": "Horizontal",
"message.multi_model_style.vertical": "Vertical",
"message.multi_model_style.fold": "Fold",
"reset.confirm.content": "Are you sure you want to clear all data?",
"reset.double.confirm.content": "All data will be lost, do you want to continue?",
"reset.double.confirm.title": "DATA LOST !!!",
@@ -262,7 +270,9 @@
"upgrade.success.title": "Upgrade successfully",
"regenerate.confirm": "Regenerating will replace current message",
"copy.success": "Copied!",
"error.get_embedding_dimensions": "Failed to get embedding dimensions"
"error.get_embedding_dimensions": "Failed to get embedding dimensions",
"group.delete.title": "Delete Group Message",
"group.delete.content": "Deleting a group message will delete the user's question and all assistant's answers"
},
"minapp": {
"title": "MinApp",
@@ -326,7 +336,8 @@
"together": "Together",
"yi": "Yi",
"zhinao": "360AI",
"zhipu": "ZHIPU AI"
"zhipu": "ZHIPU AI",
"qwenlm": "QwenLM"
},
"settings": {
"about": "About & Feedback",
@@ -374,7 +385,10 @@
"webdav.path": "WebDAV Path",
"webdav.path.placeholder": "/backup",
"webdav.autoSync": "Auto Backup",
"webdav.minute": "Minute",
"webdav.minutes": "Minutes",
"webdav.hour": "Hour",
"webdav.hours": "Hours",
"webdav.restore.button": "Restore from WebDAV",
"webdav.title": "WebDAV",
"webdav.user": "WebDAV User",
@@ -384,6 +398,12 @@
"webdav.syncError": "Backup Error",
"webdav.lastSync": "Last Backup"
},
"quickAssistant": {
"title": "Quick Assistant",
"click_tray_to_show": "Click the tray icon to start",
"enable_quick_assistant": "Enable Quick Assistant",
"use_shortcut_to_show": "Right-click the tray icon or use shortcuts to start"
},
"display.title": "Display Settings",
"font_size.title": "Message font size",
"general": "General Settings",
@@ -518,7 +538,10 @@
"toggle_show_assistants": "Toggle Assistants",
"toggle_show_topics": "Toggle Topics",
"copy_last_message": "Copy Last Message",
"search_message": "Search Message"
"search_message": "Search Message",
"mini_window": "Quick Assistant",
"clear_topic": "Clear Messages",
"toggle_new_context": "Clear Context"
},
"theme.auto": "Auto",
"theme.dark": "Dark",
@@ -551,7 +574,8 @@
},
"tray": {
"quit": "Quit",
"show_window": "Show Window"
"show_window": "Show Window",
"show_mini_window": "Quick Assistant"
},
"words": {
"knowledgeGraph": "Knowledge Graph",
@@ -602,7 +626,10 @@
"model_info": "Model Info",
"not_support": "Knowledge base database engine updated, the knowledge base will no longer be supported, please create a new knowledge base",
"no_provider": "Knowledge base model provider is not set, the knowledge base will no longer be supported, please create a new knowledge base",
"source": "Source"
"source": "Source",
"chunk_size": "Chunk Size",
"chunk_overlap": "Chunk Overlap",
"not_set": "Not Set"
},
"models": {
"pinned": "Pinned",
@@ -633,7 +660,32 @@
}
},
"prompts": {
"summarize": "You are an assistant who is good at conversation. You need to summarize the user's conversation into a title of 10 characters or less, ensuring it matches the user's primary language without using punctuation or other special symbols."
"title": "You are an assistant who is good at conversation. You need to summarize the user's conversation into a title of 10 characters or less, ensuring it matches the user's primary language without using punctuation or other special symbols.",
"explanation": "Explain this concept to me",
"summarize": "Summarize this text"
},
"miniwindow": {
"feature": {
"chat": "Answer this question",
"translate": "Text translation",
"summary": "Content summary",
"explanation": "Explanation"
},
"clipboard": {
"empty": "Clipboard is empty"
},
"input": {
"placeholder": {
"title": "What do you want to do with this text?",
"empty": "Ask {{model}} for help..."
}
},
"footer": {
"esc": "Press ESC {{action}}",
"esc_close": "close the window",
"esc_back": "back",
"copy_last_message": "Press C to copy"
}
}
}
}

View File

@@ -64,14 +64,14 @@
"default.description": "こんにちは、私はデフォルトのアシスタントです。すぐにチャットを始められます。",
"default.name": "⭐️ デフォルトアシスタント",
"default.topic.name": "デフォルトトピック",
"input.clear": "クリア",
"input.clear": "クリア {{Command}}",
"input.clear.content": "現在のトピックのすべてのメッセージをクリアしますか?",
"input.clear.title": "すべてのメッセージをクリアしますか?",
"input.collapse": "折りたたむ",
"input.context_count.tip": "コンテキスト数",
"input.estimated_tokens.tip": "推定トークン数",
"input.expand": "展開",
"input.new.context": "コンテキストをクリア",
"input.new.context": "コンテキストをクリア {{Command}}",
"input.new_topic": "新しいトピック {{Command}}",
"input.pause": "一時停止",
"input.placeholder": "ここにメッセージを入力...",
@@ -86,6 +86,7 @@
"message.new.branch.created": "新しいブランチが作成されました",
"message.regenerate.model": "モデルを切り替え",
"message.new.context": "新しいコンテキスト",
"message.useful": "役立つ",
"save": "保存",
"settings.code_collapsible": "コードブロックを折りたたむ",
"settings.context_count": "コンテキスト",
@@ -113,7 +114,9 @@
"topics.move_to": "移動先",
"topics.title": "トピック",
"translate": "翻訳",
"resend": "再送信"
"resend": "再送信",
"thinking": "思考中...",
"deeply_thought": "深く考えています({{secounds}} 秒)"
},
"common": {
"and": "と",
@@ -241,6 +244,7 @@
"error.enter.api.host": "APIホストを入力してください",
"error.enter.api.key": "APIキーを入力してください",
"error.enter.model": "モデルを選択してください",
"error.chunk_overlap_too_large": "チャンクの重なりは、チャンクサイズを超えることはできません",
"error.invalid.proxy.url": "無効なプロキシURL",
"error.invalid.webdav": "無効なWebDAV設定",
"message.code_style": "コードスタイル",
@@ -249,6 +253,10 @@
"message.style": "メッセージスタイル",
"message.style.bubble": "バブル",
"message.style.plain": "プレーン",
"message.multi_model_style": "複数モデル回答スタイル",
"message.multi_model_style.horizontal": "水平",
"message.multi_model_style.vertical": "垂直",
"message.multi_model_style.fold": "折りたたむ",
"reset.confirm.content": "すべてのデータをリセットしてもよろしいですか?",
"reset.double.confirm.content": "すべてのデータが失われます。続行しますか?",
"reset.double.confirm.title": "データが失われます!!!",
@@ -260,7 +268,10 @@
"upgrade.success.content": "アップグレードを完了するためにアプリケーションを再起動してください",
"upgrade.success.title": "アップグレードに成功しました",
"regenerate.confirm": "再生成すると現在のメッセージが置き換えられます",
"copy.success": "コピーしました!"
"copy.success": "コピーしました!",
"error.get_embedding_dimensions": "埋込み次元を取得できませんでした",
"group.delete.title": "分組メッセージを削除",
"group.delete.content": "分組メッセージを削除するとユーザーの質問と助け手の回答がすべて削除されます"
},
"minapp": {
"title": "ミニアプリ",
@@ -324,7 +335,8 @@
"together": "Together",
"yi": "零一万物",
"zhinao": "360智脳",
"zhipu": "智譜AI"
"zhipu": "智譜AI",
"qwenlm": "QwenLM"
},
"settings": {
"about": "について",
@@ -373,6 +385,7 @@
"webdav.path.placeholder": "/backup",
"webdav.autoSync": "自動バックアップ",
"webdav.minutes": "分",
"webdav.hours": "時間",
"webdav.restore.button": "WebDAVから復元",
"webdav.title": "WebDAV",
"webdav.user": "WebDAVユーザー",
@@ -382,6 +395,12 @@
"webdav.syncError": "バックアップエラー",
"webdav.lastSync": "最終同期"
},
"quickAssistant": {
"title": "クイックアシスタント",
"click_tray_to_show": "トレイアイコンをクリックして起動",
"enable_quick_assistant": "クイックアシスタントを有効にする",
"use_shortcut_to_show": "トレイアイコンを右クリックするか、ショートカットキーで起動できます"
},
"display.title": "表示設定",
"font_size.title": "メッセージのフォントサイズ",
"general": "一般設定",
@@ -503,7 +522,10 @@
"toggle_show_assistants": "アシスタントの表示を切り替え",
"toggle_show_topics": "トピックの表示を切り替え",
"copy_last_message": "最後のメッセージをコピー",
"search_message": "メッセージを検索"
"search_message": "メッセージを検索",
"mini_window": "クイックアシスタント",
"clear_topic": "メッセージを消去",
"toggle_new_context": "コンテキストをクリア"
},
"theme.auto": "自動",
"theme.dark": "ダークテーマ",
@@ -536,7 +558,8 @@
},
"tray": {
"quit": "終了",
"show_window": "ウィンドウを表示"
"show_window": "ウィンドウを表示",
"show_mini_window": "クイックアシスタント"
},
"words": {
"knowledgeGraph": "ナレッジグラフ",
@@ -587,7 +610,10 @@
"model_info": "モデル情報",
"not_support": "ナレッジベースデータベースエンジンが更新されました。このナレッジベースはもうサポートされていません。新しいナレッジベースを作成してください",
"no_provider": "ナレッジベースモデルプロバイダーが設定されていません。ナレッジベースはもうサポートされていません。新しいナレッジベースを作成してください",
"source": "ソース"
"source": "ソース",
"chunk_size": "チャンクサイズ",
"chunk_overlap": "チャンクの重なり",
"not_set": "未設定"
},
"models": {
"pinned": "固定済み",
@@ -618,7 +644,32 @@
}
},
"prompts": {
"summarize": "あなたは会話を得意とするアシスタントです。ユーザーの会話を10文字以内のタイトルに要約し、ユーザーの主言語と一致していることを確認してください。句読点や特殊記号は使用しないでください。"
"title": "あなたは会話を得意とするアシスタントです。ユーザーの会話を10文字以内のタイトルに要約し、ユーザーの主言語と一致していることを確認してください。句読点や特殊記号は使用しないでください。",
"explanation": "この概念を説明してください",
"summarize": "このテキストを要約してください"
},
"miniwindow": {
"feature": {
"chat": "この質問に回答",
"translate": "テキスト翻訳",
"summary": "内容要約",
"explanation": "説明"
},
"clipboard": {
"empty": "クリップボードが空です"
},
"input": {
"placeholder": {
"title": "下のテキストに対して何をしますか?",
"empty": "{{model}} に質問してください..."
}
},
"footer": {
"esc": "ESC キーを押して{{action}}",
"esc_close": "ウィンドウを閉じる",
"esc_back": "戻る",
"copy_last_message": "C キーを押してコピー"
}
}
}
}

View File

@@ -64,14 +64,14 @@
"default.description": "Привет, я Ассистент по умолчанию. Вы можете начать общаться со мной прямо сейчас",
"default.name": "⭐️ Ассистент по умолчанию",
"default.topic.name": "Топик по умолчанию",
"input.clear": "Очистить",
"input.clear": "Очистить {{Command}}",
"input.clear.content": "Хотите очистить все сообщения текущего топика?",
"input.clear.title": "Очистить все сообщения?",
"input.collapse": "Свернуть",
"input.context_count.tip": "Количество контекстов",
"input.estimated_tokens.tip": "Затраты токенов",
"input.expand": "Развернуть",
"input.new.context": "Очистить контекст",
"input.new.context": "Очистить контекст {{Command}}",
"input.new_topic": "Новый топик {{Command}}",
"input.pause": "Остановить",
"input.placeholder": "Введите ваше сообщение здесь...",
@@ -86,6 +86,7 @@
"message.new.branch.created": "Новая ветка создана",
"message.regenerate.model": "Переключить модель",
"message.new.context": "Новый контекст",
"message.useful": "Полезно",
"save": "Сохранить",
"settings.code_collapsible": "Блок кода свернут",
"settings.context_count": "Контекст",
@@ -113,7 +114,9 @@
"topics.move_to": "Переместить в",
"topics.title": "Топики",
"translate": "Перевести",
"resend": "Переотправить"
"resend": "Переотправить",
"thinking": "Мыслим",
"deeply_thought": "Мыслим ({{secounds}} секунд)"
},
"common": {
"and": "и",
@@ -242,6 +245,7 @@
"error.enter.api.key": "Пожалуйста, введите ваш API ключ",
"error.enter.model": "Пожалуйста, выберите модель",
"error.enter.name": "Пожалуйста, введите название базы знаний",
"error.chunk_overlap_too_large": "Перекрытие фрагментов не может быть больше размера фрагмента.",
"error.invalid.proxy.url": "Неверный URL прокси",
"error.invalid.webdav": "Неверные настройки WebDAV",
"message.code_style": "Стиль кода",
@@ -250,6 +254,10 @@
"message.style": "Стиль сообщения",
"message.style.bubble": "Пузырь",
"message.style.plain": "Простой",
"message.multi_model_style": "Стиль ответов от нескольких моделей",
"message.multi_model_style.horizontal": "Горизонтальный",
"message.multi_model_style.vertical": "Вертикальный",
"message.multi_model_style.fold": "Свернуть",
"reset.confirm.content": "Вы уверены, что хотите очистить все данные?",
"reset.double.confirm.content": "Все данные будут утеряны, хотите продолжить?",
"reset.double.confirm.title": "ДАННЫЕ БУДУТ УТЕРЯНЫ !!!",
@@ -262,7 +270,9 @@
"upgrade.success.title": "Обновление успешно",
"regenerate.confirm": "Перегенерация заменит текущее сообщение",
"copy.success": "Скопировано!",
"error.get_embedding_dimensions": "Не удалось получить размерность встраивания"
"error.get_embedding_dimensions": "Не удалось получить размерность встраивания",
"group.delete.title": "Удалить группу сообщений",
"group.delete.content": "Удаление группы сообщений удалит пользовательский вопрос и все ответы помощника"
},
"minapp": {
"title": "Встроенные приложения",
@@ -326,7 +336,8 @@
"together": "Together",
"yi": "Yi",
"zhinao": "360AI",
"zhipu": "ZHIPU AI"
"zhipu": "ZHIPU AI",
"qwenlm": "QwenLM"
},
"settings": {
"about": "О программе и обратная связь",
@@ -375,6 +386,7 @@
"webdav.path.placeholder": "/backup",
"webdav.autoSync": "Автоматическое резервное копирование",
"webdav.minutes": "минут",
"webdav.hours": "часов",
"webdav.restore.button": "Восстановление с WebDAV",
"webdav.title": "WebDAV",
"webdav.user": "Пользователь WebDAV",
@@ -384,6 +396,12 @@
"webdav.syncError": "Ошибка резервного копирования",
"webdav.lastSync": "Последняя синхронизация"
},
"quickAssistant": {
"title": "Быстрый помощник",
"click_tray_to_show": "Нажмите на иконку трея для запуска",
"enable_quick_assistant": "Включить быстрый помощник",
"use_shortcut_to_show": "Нажмите на иконку трея или используйте горячие клавиши для запуска"
},
"display.title": "Настройки отображения",
"font_size.title": "Размер шрифта сообщений",
"general": "Общие настройки",
@@ -517,7 +535,10 @@
"toggle_show_assistants": "Переключить отображение ассистентов",
"toggle_show_topics": "Переключить отображение топиков",
"copy_last_message": "Копировать последнее сообщение",
"search_message": "Поиск сообщения"
"search_message": "Поиск сообщения",
"mini_window": "Быстрый помощник",
"clear_topic": "Очистить все сообщения",
"toggle_new_context": "Очистить контекст"
},
"theme.auto": "Автоматически",
"theme.dark": "Темная",
@@ -550,7 +571,8 @@
},
"tray": {
"quit": "Выйти",
"show_window": "Показать окно"
"show_window": "Показать окно",
"show_mini_window": "Быстрый помощник"
},
"words": {
"knowledgeGraph": "Граф знаний",
@@ -601,7 +623,10 @@
"model_info": "Модель информации",
"not_support": "База знаний базы данных движок обновлен, база знаний больше не поддерживается, пожалуйста, создайте новую базу знаний",
"no_provider": "База знаний модель поставщика не настроена, база знаний больше не поддерживается, пожалуйста, создайте новую базу знаний",
"source": "Источник"
"source": "Источник",
"chunk_size": "Размер фрагмента",
"chunk_overlap": "Перекрытие фрагмента",
"not_set": "Не установлено"
},
"models": {
"pinned": "Закреплено",
@@ -632,7 +657,32 @@
}
},
"prompts": {
"summarize": "Вы - эксперт в общении, который суммирует разговоры пользователя в 10-символьном заголовке, совпадающем с языком пользователя, без использования знаков препинания и других специальных символов"
"title": "Вы - эксперт в общении, который суммирует разговоры пользователя в 10-символьном заголовке, совпадающем с языком пользователя, без использования знаков препинания и других специальных символов",
"explanation": "Объясните мне этот концепт",
"summarize": "Суммируйте этот текст"
},
"miniwindow": {
"feature": {
"chat": "Ответить на этот вопрос",
"translate": "Текст перевод",
"summary": "Содержание",
"explanation": "Объяснение"
},
"clipboard": {
"empty": "Буфер обмена пуст"
},
"input": {
"placeholder": {
"title": "Что вы хотите сделать с этим текстом?",
"empty": "Задайте вопрос {{model}}..."
}
},
"footer": {
"esc": "Нажмите ESC {{action}}",
"esc_close": "закрытия окна",
"esc_back": "возвращения",
"copy_last_message": "Нажмите C для копирования"
}
}
}
}

View File

@@ -64,14 +64,14 @@
"default.description": "你好,我是默认助手。你可以立刻开始跟我聊天。",
"default.name": "⭐️ 默认助手",
"default.topic.name": "默认话题",
"input.clear": "清空消息",
"input.clear": "清空消息 {{Command}}",
"input.clear.content": "确定要清除当前会话所有消息吗?",
"input.clear.title": "清空消息",
"input.collapse": "收起",
"input.context_count.tip": "上下文数",
"input.estimated_tokens.tip": "预估 token 数",
"input.expand": "展开",
"input.new.context": "清除上下文",
"input.new.context": "清除上下文 {{Command}}",
"input.new_topic": "新话题 {{Command}}",
"input.pause": "暂停",
"input.placeholder": "在这里输入消息...",
@@ -86,6 +86,7 @@
"message.new.branch.created": "新分支已创建",
"message.regenerate.model": "切换模型",
"message.new.context": "清除上下文",
"message.useful": "有用",
"save": "保存",
"settings.code_collapsible": "代码块可折叠",
"settings.context_count": "上下文数",
@@ -113,7 +114,9 @@
"topics.move_to": "移动到",
"topics.title": "话题",
"translate": "翻译",
"resend": "重新发送"
"resend": "重新发送",
"thinking": "思考中",
"deeply_thought": "已深度思考(用时 {{secounds}} 秒)"
},
"common": {
"and": "和",
@@ -243,6 +246,7 @@
"error.enter.api.key": "请输入您的 API 密钥",
"error.enter.model": "请选择一个模型",
"error.enter.name": "请输入知识库名称",
"error.chunk_overlap_too_large": "分段重叠不能大于分段大小",
"error.invalid.proxy.url": "无效的代理地址",
"error.invalid.webdav": "无效的 WebDAV 设置",
"message.code_style": "代码风格",
@@ -251,6 +255,10 @@
"message.style": "消息样式",
"message.style.bubble": "气泡",
"message.style.plain": "简洁",
"message.multi_model_style": "多模型回答样式",
"message.multi_model_style.horizontal": "水平",
"message.multi_model_style.vertical": "垂直",
"message.multi_model_style.fold": "折叠",
"reset.confirm.content": "确定要重置所有数据吗?",
"reset.double.confirm.content": "你的全部数据都会丢失,如果没有备份数据,将无法恢复,确定要继续吗?",
"reset.double.confirm.title": "数据丢失!!!",
@@ -263,7 +271,9 @@
"upgrade.success.title": "升级成功",
"regenerate.confirm": "重新生成会覆盖当前消息",
"copy.success": "复制成功",
"error.get_embedding_dimensions": "获取嵌入维度失败"
"error.get_embedding_dimensions": "获取嵌入维度失败",
"group.delete.title": "删除分组消息",
"group.delete.content": "删除分组消息会删除用户提问和所有助手的回答"
},
"minapp": {
"title": "小程序",
@@ -327,7 +337,8 @@
"together": "Together",
"yi": "零一万物",
"zhinao": "360智脑",
"zhipu": "智谱AI"
"zhipu": "智谱AI",
"qwenlm": "QwenLM"
},
"settings": {
"about": "关于我们",
@@ -376,6 +387,7 @@
"webdav.path.placeholder": "/backup",
"webdav.autoSync": "自动备份",
"webdav.minutes": "分钟",
"webdav.hours": "小时",
"webdav.restore.button": "从 WebDAV 恢复",
"webdav.title": "WebDAV",
"webdav.user": "WebDAV 用户名",
@@ -385,6 +397,12 @@
"webdav.syncError": "备份错误",
"webdav.lastSync": "上次备份时间"
},
"quickAssistant": {
"title": "快捷助手",
"click_tray_to_show": "点击托盘图标启动",
"enable_quick_assistant": "启用快捷助手",
"use_shortcut_to_show": "右键点击托盘图标或使用快捷键启动"
},
"display.title": "显示设置",
"font_size.title": "消息字体大小",
"general": "常规设置",
@@ -506,7 +524,10 @@
"toggle_show_assistants": "切换助手显示",
"toggle_show_topics": "切换话题显示",
"copy_last_message": "复制上一条消息",
"search_message": "搜索消息"
"search_message": "搜索消息",
"mini_window": "快捷助手",
"clear_topic": "清空消息",
"toggle_new_context": "清除上下文"
},
"theme.auto": "跟随系统",
"theme.dark": "深色主题",
@@ -539,7 +560,8 @@
},
"tray": {
"quit": "退出",
"show_window": "显示窗口"
"show_window": "显示窗口",
"show_mini_window": "快捷助手"
},
"words": {
"knowledgeGraph": "知识图谱",
@@ -590,7 +612,10 @@
"model_info": "模型信息",
"not_support": "知识库数据库引擎已更新,该知识库将不再支持,请重新创建知识库",
"no_provider": "知识库模型服务商丢失,该知识库将不再支持,请重新创建知识库",
"source": "来源"
"source": "来源",
"chunk_size": "分段大小",
"chunk_overlap": "重叠大小",
"not_set": "未设置"
},
"models": {
"pinned": "已固定",
@@ -621,7 +646,32 @@
}
},
"prompts": {
"summarize": "你是一名擅长会话的助理,你需要将用户的会话总结为 10 个字以内的标题,标题语言与用户的首要语言一致,不要使用标点符号和其他特殊符号"
"title": "你是一名擅长会话的助理,你需要将用户的会话总结为 10 个字以内的标题,标题语言与用户的首要语言一致,不要使用标点符号和其他特殊符号",
"explanation": "帮我解释一下这个概念",
"summarize": "帮我总结一下这段话"
},
"miniwindow": {
"feature": {
"chat": "回答此问题",
"translate": "文本翻译",
"summary": "内容总结",
"explanation": "解释说明"
},
"clipboard": {
"empty": "剪贴板为空"
},
"input": {
"placeholder": {
"title": "你想对下方文字做什么",
"empty": "询问 {{model}} 获取帮助..."
}
},
"footer": {
"esc": "按 ESC {{action}}",
"esc_close": "关闭窗口",
"esc_back": "返回",
"copy_last_message": "按 C 键复制"
}
}
}
}

View File

@@ -64,14 +64,14 @@
"default.description": "你好,我是預設助手。你可以立即開始與我聊天。",
"default.name": "⭐️ 預設助手",
"default.topic.name": "預設話題",
"input.clear": "清除",
"input.clear": "清除 {{Command}}",
"input.clear.content": "您想要清除當前話題的所有訊息嗎?",
"input.clear.title": "清除所有訊息?",
"input.collapse": "收起",
"input.context_count.tip": "上下文數量",
"input.estimated_tokens.tip": "預估 Token 數",
"input.expand": "展開",
"input.new.context": "清除上下文",
"input.new.context": "清除上下文 {{Command}}",
"input.new_topic": "新話題 {{Command}}",
"input.pause": "暫停",
"input.placeholder": "在此輸入您的訊息...",
@@ -86,6 +86,7 @@
"message.new.branch.created": "新分支已建立",
"message.regenerate.model": "切換模型",
"message.new.context": "新上下文",
"message.useful": "有用",
"save": "保存",
"settings.code_collapsible": "代码块可折叠",
"settings.context_count": "上下文",
@@ -113,7 +114,9 @@
"topics.move_to": "移動到",
"topics.title": "話題",
"translate": "翻譯",
"resend": "重新發送"
"resend": "重新發送",
"thinking": "思考中",
"deeply_thought": "已深度思考(用時 {{secounds}} 秒)"
},
"common": {
"and": "與",
@@ -242,6 +245,7 @@
"error.enter.api.key": "請先輸入您的 API 密鑰",
"error.enter.model": "請先選擇一個模型",
"error.enter.name": "請先輸入知識庫名稱",
"error.chunk_overlap_too_large": "分段重疊不能大於分段大小",
"error.invalid.proxy.url": "無效的代理 URL",
"error.invalid.webdav": "無效的 WebDAV 設定",
"message.code_style": "程式碼風格",
@@ -250,6 +254,10 @@
"message.style": "消息樣式",
"message.style.bubble": "氣泡",
"message.style.plain": "簡潔",
"message.multi_model_style": "多模型回答樣式",
"message.multi_model_style.horizontal": "水平",
"message.multi_model_style.vertical": "垂直",
"message.multi_model_style.fold": "折疊",
"reset.confirm.content": "確定要清除所有資料嗎?",
"reset.double.confirm.content": "所有資料將會被清除,您確定要繼續嗎?",
"reset.double.confirm.title": "資料將會丟失!!!",
@@ -262,7 +270,9 @@
"upgrade.success.title": "升級成功",
"regenerate.confirm": "重新生成會覆蓋當前訊息",
"copy.success": "複製成功",
"error.get_embedding_dimensions": "獲取嵌入維度失敗"
"error.get_embedding_dimensions": "獲取嵌入維度失敗",
"group.delete.title": "刪除分組消息",
"group.delete.content": "刪除分組消息會刪除用戶提問和所有助手的回答"
},
"minapp": {
"title": "小程序",
@@ -326,7 +336,8 @@
"together": "Together",
"yi": "零一萬物",
"zhinao": "360智腦",
"zhipu": "智譜AI"
"zhipu": "智譜AI",
"qwenlm": "QwenLM"
},
"settings": {
"about": "關於與回饋",
@@ -375,6 +386,7 @@
"webdav.path.placeholder": "/backup",
"webdav.autoSync": "自動備份",
"webdav.minutes": "分鐘",
"webdav.hours": "小時",
"webdav.restore.button": "從 WebDAV 恢復",
"webdav.title": "WebDAV",
"webdav.user": "WebDAV 使用者名稱",
@@ -384,6 +396,12 @@
"webdav.syncError": "備份錯誤",
"webdav.lastSync": "上次同步時間"
},
"quickAssistant": {
"title": "快捷助手",
"click_tray_to_show": "點擊托盤圖標啟動",
"enable_quick_assistant": "啟用快捷助手",
"use_shortcut_to_show": "右鍵點擊托盤圖標或使用快捷鍵啟動"
},
"display.title": "顯示設定",
"font_size.title": "訊息字體大小",
"general": "一般設定",
@@ -505,7 +523,10 @@
"toggle_show_assistants": "切換助手顯示",
"toggle_show_topics": "切換話題顯示",
"copy_last_message": "複製上一条消息",
"search_message": "搜索消息"
"search_message": "搜索消息",
"mini_window": "快捷助手",
"clear_topic": "清除所有訊息",
"toggle_new_context": "清除上下文"
},
"theme.auto": "自動",
"theme.dark": "深色主題",
@@ -538,7 +559,8 @@
},
"tray": {
"quit": "退出",
"show_window": "顯示視窗"
"show_window": "顯示視窗",
"show_mini_window": "快捷助手"
},
"words": {
"knowledgeGraph": "知識圖譜",
@@ -589,7 +611,10 @@
"model_info": "模型信息",
"not_support": "知識庫數據庫引擎已更新,該知識庫將不再支持,請重新創建知識庫",
"no_provider": "知識庫模型提供商遺失,該知識庫將不再支持,請重新創建知識庫",
"source": "來源"
"source": "來源",
"chunk_size": "分段大小",
"chunk_overlap": "重疊大小",
"not_set": "未設置"
},
"models": {
"pinned": "已固定",
@@ -620,7 +645,32 @@
}
},
"prompts": {
"summarize": "你是一名擅長會話的助理,你需要將用戶的會話總結為 10 個字以內的標題,標題語言與用戶的首要語言一致,不要使用標點符號和其他特殊符號"
"title": "你是一名擅長會話的助理,你需要將用戶的會話總結為 10 個字以內的標題,標題語言與用戶的首要語言一致,不要使用標點符號和其他特殊符號",
"explanation": "幫我解釋一下這個概念",
"summarize": "幫我總結一下這段話"
},
"miniwindow": {
"feature": {
"chat": "回答此問題",
"translate": "文本翻譯",
"summary": "內容總結",
"explanation": "解釋說明"
},
"clipboard": {
"empty": "剪貼板為空"
},
"input": {
"placeholder": {
"title": "你想對下方文字做什麼",
"empty": "詢問 {{model}} 獲取幫助..."
}
},
"footer": {
"esc": "按 ESC {{action}}",
"esc_close": "關閉窗口",
"esc_back": "返回",
"copy_last_message": "按 C 鍵複製"
}
}
}
}

View File

@@ -3,17 +3,27 @@ import KeyvStorage from '@kangfenmao/keyv-storage'
import { startAutoSync } from './services/BackupService'
import store from './store'
function initSpinner() {
const spinner = document.getElementById('spinner')
if (spinner && window.location.hash !== '#/mini') {
spinner.style.display = 'flex'
}
}
function initKeyv() {
window.keyv = new KeyvStorage()
window.keyv.init()
}
function initAutoSync() {
const { webdavAutoSync } = store.getState().settings
if (webdavAutoSync) {
startAutoSync()
}
setTimeout(() => {
const { webdavAutoSync } = store.getState().settings
if (webdavAutoSync) {
startAutoSync()
}
}, 2000)
}
initSpinner()
initKeyv()
initAutoSync()

View File

@@ -1,8 +1,13 @@
import './assets/styles/index.scss'
import './init'
import ReactDOM from 'react-dom/client'
import App from './App'
import MiniApp from './windows/mini/App'
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(<App />)
if (location.hash === '#/mini') {
document.getElementById('spinner')?.remove()
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(<MiniApp />)
} else {
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(<App />)
}

View File

@@ -1,7 +1,6 @@
import { SearchOutlined } from '@ant-design/icons'
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
import Scrollbar from '@renderer/components/Scrollbar'
import SystemAgents from '@renderer/config/agents.json'
import { createAssistantFromAgent } from '@renderer/services/AssistantService'
import { Agent } from '@renderer/types'
import { uuid } from '@renderer/utils'
@@ -12,35 +11,26 @@ import { useTranslation } from 'react-i18next'
import ReactMarkdown from 'react-markdown'
import styled from 'styled-components'
import { getAgentsFromSystemAgents, useSystemAgents } from '.'
import { groupTranslations } from './agentGroupTranslations'
import AgentCard from './components/AgentCard'
import MyAgents from './components/MyAgents'
const { Title } = Typography
const getAgentsFromSystemAgents = () => {
const agents: Agent[] = []
for (let i = 0; i < SystemAgents.length; i++) {
for (let j = 0; j < SystemAgents[i].group.length; j++) {
const agent = { ...SystemAgents[i], group: SystemAgents[i].group[j], topics: [], type: 'agent' } as Agent
agents.push(agent)
}
}
return agents
}
let _agentGroups: Record<string, Agent[]> = {}
const AgentsPage: FC = () => {
const [search, setSearch] = useState('')
const [searchInput, setSearchInput] = useState('')
const systemAgents = useSystemAgents()
const agentGroups = useMemo(() => {
if (Object.keys(_agentGroups).length === 0) {
_agentGroups = groupBy(getAgentsFromSystemAgents(), 'group')
_agentGroups = groupBy(getAgentsFromSystemAgents(systemAgents), 'group')
}
return _agentGroups
}, [])
}, [systemAgents])
const { t, i18n } = useTranslation()
@@ -102,7 +92,7 @@ const AgentsPage: FC = () => {
[t]
)
const getAgentFromSystemAgent = (agent: (typeof SystemAgents)[number]) => {
const getAgentFromSystemAgent = (agent: (typeof systemAgents)[number]) => {
return {
...omit(agent, 'group'),
name: agent.name,
@@ -120,12 +110,31 @@ const AgentsPage: FC = () => {
[i18n.language]
)
const renderAgentList = useCallback(
(agents: Agent[]) => {
return (
<Row gutter={[20, 20]}>
{agents.map((agent, index) => (
<Col span={6} key={agent.id || index}>
<AgentCard
onClick={() => onAddAgentConfirm(getAgentFromSystemAgent(agent as any))}
agent={agent as any}
/>
</Col>
))}
</Row>
)
},
[onAddAgentConfirm]
)
const tabItems = useMemo(() => {
const groups = Object.keys(filteredAgentGroups)
return groups.map((group, i) => {
const id = String(i + 1)
const localizedGroupName = getLocalizedGroupName(group)
const agents = filteredAgentGroups[group] || []
return {
label: localizedGroupName,
@@ -135,25 +144,12 @@ const AgentsPage: FC = () => {
<Title level={5} key={group} style={{ marginBottom: 10 }}>
{localizedGroupName}
</Title>
<Row gutter={[20, 20]}>
{group === '我的' ? (
<MyAgents onClick={onAddAgentConfirm} search={search} />
) : (
filteredAgentGroups[group]?.map((agent, index) => (
<Col span={6} key={group + index}>
<AgentCard
onClick={() => onAddAgentConfirm(getAgentFromSystemAgent(agent as any))}
agent={agent as any}
/>
</Col>
))
)}
</Row>
{group === '我的' ? <MyAgents onClick={onAddAgentConfirm} search={search} /> : renderAgentList(agents)}
</TabContent>
)
}
})
}, [filteredAgentGroups, getLocalizedGroupName, onAddAgentConfirm, search])
}, [filteredAgentGroups, getLocalizedGroupName, onAddAgentConfirm, search, renderAgentList])
const handleSearch = () => {
if (searchInput.trim() === '') {
@@ -189,22 +185,9 @@ const AgentsPage: FC = () => {
<AssistantsContainer>
{Object.values(filteredAgentGroups).flat().length > 0 ? (
search.trim() ? (
<TabContent>
<Row gutter={[20, 20]}>
{Object.values(filteredAgentGroups)
.flat()
.map((agent, index, array) => (
<Col span={array.length === 1 ? 12 : 6} key={index}>
<AgentCard
onClick={() => onAddAgentConfirm(getAgentFromSystemAgent(agent as any))}
agent={agent as any}
/>
</Col>
))}
</Row>
</TabContent>
<TabContent>{renderAgentList(Object.values(filteredAgentGroups).flat())}</TabContent>
) : (
<Tabs tabPosition="right" animated items={tabItems} $language={i18n.language} />
<Tabs tabPosition="right" animated={false} items={tabItems} $language={i18n.language} />
)
) : (
<EmptyView>
@@ -232,6 +215,7 @@ const ContentContainer = styled.div`
height: 100%;
padding: 0 10px;
padding-left: 0;
border-top: 0.5px solid var(--color-border);
`
const AssistantsContainer = styled.div`
@@ -247,6 +231,9 @@ const TabContent = styled(Scrollbar)`
margin-right: -4px;
padding-bottom: 20px !important;
overflow-x: hidden;
transform: translateZ(0);
will-change: transform;
-webkit-font-smoothing: antialiased;
`
const AgentPrompt = styled.div`
@@ -268,12 +255,15 @@ const Tabs = styled(TabsAntd)<{ $language: string }>`
display: flex;
flex: 1;
flex-direction: row-reverse;
.ant-tabs-tabpane {
padding-right: 0 !important;
}
.ant-tabs-nav {
min-width: ${({ $language }) => ($language.startsWith('zh') ? '120px' : '140px')};
max-width: ${({ $language }) => ($language.startsWith('zh') ? '120px' : '140px')};
position: relative;
overflow: hidden;
}
.ant-tabs-nav-list {
padding: 10px 8px;
@@ -291,11 +281,15 @@ const Tabs = styled(TabsAntd)<{ $language: string }>`
border: 0.5px solid transparent;
justify-content: ${({ $language }) => ($language.startsWith('zh') ? 'center' : 'flex-start')};
user-select: none;
transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
outline: none !important;
.ant-tabs-tab-btn {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100px;
transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
outline: none !important;
}
&:hover {
color: var(--color-text) !important;
@@ -304,8 +298,8 @@ const Tabs = styled(TabsAntd)<{ $language: string }>`
}
.ant-tabs-tab-active {
background-color: var(--color-background-soft);
border-right: none;
border: 0.5px solid var(--color-border);
transform: scale(1.02);
}
.ant-tabs-content-holder {
border-left: 0.5px solid var(--color-border);
@@ -322,6 +316,9 @@ const Tabs = styled(TabsAntd)<{ $language: string }>`
color: var(--color-text) !important;
}
}
.ant-tabs-content {
transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
}
`
export default AgentsPage

View File

@@ -2,11 +2,12 @@ import { EllipsisOutlined } from '@ant-design/icons'
import { Agent } from '@renderer/types'
import { getLeadingEmoji } from '@renderer/utils'
import { Dropdown } from 'antd'
import { FC, memo } from 'react'
import styled from 'styled-components'
interface Props {
agent: Agent
onClick?: () => void
onClick: () => void
contextMenu?: { label: string; onClick: () => void }[]
menuItems?: {
key: string
@@ -17,7 +18,7 @@ interface Props {
}[]
}
const AgentCard: React.FC<Props> = ({ agent, onClick, contextMenu, menuItems }) => {
const AgentCard: FC<Props> = ({ agent, onClick, contextMenu, menuItems }) => {
const emoji = agent.emoji || getLeadingEmoji(agent.name)
const prompt = (agent.description || agent.prompt).substring(0, 100).replace(/\\n/g, '')
const content = (
@@ -205,4 +206,4 @@ const MenuContainer = styled.div`
}
`
export default AgentCard
export default memo(AgentCard)

View File

@@ -3,7 +3,7 @@ import { useAgents } from '@renderer/hooks/useAgents'
import AssistantSettingsPopup from '@renderer/pages/settings/AssistantSettings'
import { createAssistantFromAgent } from '@renderer/services/AssistantService'
import { Agent } from '@renderer/types'
import { Col } from 'antd'
import { Col, Row } from 'antd'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
@@ -43,7 +43,7 @@ const MyAgents: React.FC<Props> = ({ onClick, search }) => {
)
return (
<>
<Row gutter={[20, 20]}>
{filteredAgents.map((agent) => {
const dropdownMenuItems = [
{
@@ -102,7 +102,7 @@ const MyAgents: React.FC<Props> = ({ onClick, search }) => {
<Col span={6}>
<AddAgentCard onClick={() => AddAgentPopup.show()} />
</Col>
</>
</Row>
)
}

View File

@@ -0,0 +1,33 @@
import { useRuntime } from '@renderer/hooks/useRuntime'
import { Agent } from '@renderer/types'
import { runAsyncFunction } from '@renderer/utils'
import { useEffect, useState } from 'react'
let _agents: Agent[] = []
export const getAgentsFromSystemAgents = (systemAgents: any) => {
const agents: Agent[] = []
for (let i = 0; i < systemAgents.length; i++) {
for (let j = 0; j < systemAgents[i].group.length; j++) {
const agent = { ...systemAgents[i], group: systemAgents[i].group[j], topics: [], type: 'agent' } as Agent
agents.push(agent)
}
}
return agents
}
export function useSystemAgents() {
const [agents, setAgents] = useState<Agent[]>(_agents)
const { resourcesPath } = useRuntime()
useEffect(() => {
runAsyncFunction(async () => {
if (_agents.length > 0) return
const agents = await window.api.fs.read(resourcesPath + '/data/agents.json')
_agents = JSON.parse(agents) as Agent[]
setAgents(_agents)
})
}, [resourcesPath])
return agents
}

View File

@@ -30,6 +30,7 @@ const App: FC<Props> = ({ app, onClick, size = 60 }) => {
key: 'togglePin',
label: isPinned ? t('minapp.sidebar.remove.title') : t('minapp.sidebar.add.title'),
onClick: () => {
console.debug('togglePin', app)
const newPinned = isPinned ? pinned.filter((item) => item.id !== app.id) : [...(pinned || []), app]
updatePinnedMinapps(newPinned)
}

View File

@@ -15,8 +15,6 @@ const AppsPage: FC = () => {
const [search, setSearch] = useState('')
const { minapps } = useMinapps()
console.debug('minapps', minapps)
const filteredApps = search
? minapps.filter(
(app) => app.name.toLowerCase().includes(search.toLowerCase()) || app.url.includes(search.toLowerCase())

View File

@@ -16,37 +16,26 @@ const AttachmentPreview: FC<Props> = ({ files, setFiles }) => {
}
return (
<Container>
<ContentContainer>
<Upload
listType={files.length > 20 ? 'text' : 'picture-card'}
fileList={files.map((file) => ({
uid: file.id,
url: 'file://' + FileManager.getSafePath(file),
status: 'done',
name: file.name
}))}
onRemove={(item) => setFiles(files.filter((file) => item.uid !== file.id))}
/>
</ContentContainer>
</Container>
<ContentContainer>
<Upload
listType={files.length > 20 ? 'text' : 'picture-card'}
fileList={files.map((file) => ({
uid: file.id,
url: 'file://' + FileManager.getSafePath(file),
status: 'done',
name: file.name
}))}
onRemove={(item) => setFiles(files.filter((file) => item.uid !== file.id))}
/>
</ContentContainer>
)
}
const Container = styled.div`
display: flex;
flex-direction: row;
gap: 10px;
padding: 10px 0;
background: var(--color-background);
border-top: 1px solid var(--color-border-mute);
`
const ContentContainer = styled.div`
max-height: 40vh;
width: 100%;
overflow-y: auto;
padding: 0 20px;
width: 100%;
padding: 10px 15px 0;
`
export default AttachmentPreview

View File

@@ -24,7 +24,7 @@ import { estimateTextTokens as estimateTxtTokens } from '@renderer/services/Toke
import { translateText } from '@renderer/services/TranslateService'
import store, { useAppDispatch, useAppSelector } from '@renderer/store'
import { setGenerating, setSearching } from '@renderer/store/runtime'
import { Assistant, FileType, KnowledgeBase, Message, Topic } from '@renderer/types'
import { Assistant, FileType, KnowledgeBase, Message, Model, Topic } from '@renderer/types'
import { classNames, delay, getFileExtension, uuid } from '@renderer/utils'
import { documentExts, imageExts, textExts } from '@shared/config/constant'
import { Button, Popconfirm, Tooltip } from 'antd'
@@ -39,6 +39,8 @@ import NarrowLayout from '../Messages/NarrowLayout'
import AttachmentButton from './AttachmentButton'
import AttachmentPreview from './AttachmentPreview'
import KnowledgeBaseButton from './KnowledgeBaseButton'
import MentionModelsButton from './MentionModelsButton'
import MentionModelsInput from './MentionModelsInput'
import SendMessageButton from './SendMessageButton'
import TokenCount from './TokenCount'
@@ -82,6 +84,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic }) => {
const spaceClickTimer = useRef<NodeJS.Timeout>()
const [isTranslating, setIsTranslating] = useState(false)
const [selectedKnowledgeBase, setSelectedKnowledgeBase] = useState<KnowledgeBase | undefined>(_base)
const [mentionModels, setMentionModels] = useState<Model[]>([])
const isVision = useMemo(() => isVisionModel(model), [model])
const supportExts = useMemo(() => [...textExts, ...documentExts, ...(isVision ? imageExts : [])], [isVision])
@@ -94,6 +97,8 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic }) => {
[estimateTextTokens, showInputEstimatedTokens, text]
)
const newTopicShortcut = useShortcutDisplay('new_topic')
const newContextShortcut = useShortcutDisplay('toggle_new_context')
const cleanTopicShortcut = useShortcutDisplay('clear_topic')
const inputEmpty = isEmpty(text.trim()) && files.length === 0
_text = text
@@ -126,6 +131,10 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic }) => {
message.files = await FileManager.uploadFiles(files)
}
if (mentionModels.length > 0) {
message.mentions = mentionModels
}
EventEmitter.emit(EVENT_NAMES.SEND_MESSAGE, message)
setText('')
@@ -134,7 +143,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic }) => {
setTimeout(() => resizeTextArea(), 0)
setExpend(false)
}, [inputEmpty, text, assistant.id, assistant.topics, selectedKnowledgeBase, files])
}, [inputEmpty, text, assistant.id, assistant.topics, selectedKnowledgeBase, files, mentionModels])
const translate = async () => {
if (isTranslating) {
@@ -180,7 +189,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic }) => {
if (expended) {
if (event.key === 'Escape') {
return setExpend(false)
return onToggleExpended()
}
}
@@ -273,25 +282,31 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic }) => {
const onPaste = useCallback(
async (event: ClipboardEvent) => {
for (const file of event.clipboardData?.files || []) {
event.preventDefault()
const clipboardText = event.clipboardData?.getData('text')
if (clipboardText) {
// Prioritize the text when pasting.
// handled by the default event
} else {
for (const file of event.clipboardData?.files || []) {
event.preventDefault()
if (file.path === '') {
if (file.type.startsWith('image/')) {
const tempFilePath = await window.api.file.create(file.name)
const arrayBuffer = await file.arrayBuffer()
const uint8Array = new Uint8Array(arrayBuffer)
await window.api.file.write(tempFilePath, uint8Array)
const selectedFile = await window.api.file.get(tempFilePath)
selectedFile && setFiles((prevFiles) => [...prevFiles, selectedFile])
break
if (file.path === '') {
if (file.type.startsWith('image/')) {
const tempFilePath = await window.api.file.create(file.name)
const arrayBuffer = await file.arrayBuffer()
const uint8Array = new Uint8Array(arrayBuffer)
await window.api.file.write(tempFilePath, uint8Array)
const selectedFile = await window.api.file.get(tempFilePath)
selectedFile && setFiles((prevFiles) => [...prevFiles, selectedFile])
break
}
}
}
if (file.path) {
if (supportExts.includes(getFileExtension(file.path))) {
const selectedFile = await window.api.file.get(file.path)
selectedFile && setFiles((prevFiles) => [...prevFiles, selectedFile])
if (file.path) {
if (supportExts.includes(getFileExtension(file.path))) {
const selectedFile = await window.api.file.get(file.path)
selectedFile && setFiles((prevFiles) => [...prevFiles, selectedFile])
}
}
}
}
@@ -347,6 +362,14 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic }) => {
}
})
useShortcut('clear_topic', () => {
clearTopic()
})
useShortcut('toggle_new_context', () => {
onNewContext()
})
useEffect(() => {
const _setEstimateTokenCount = debounce(setEstimateTokenCount, 100, { leading: false, trailing: true })
const unsubscribes = [
@@ -380,20 +403,43 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic }) => {
}
}, [])
useEffect(() => {
window.addEventListener('focus', () => {
textareaRef.current?.focus()
})
}, [])
const textareaRows = window.innerHeight >= 1000 || isBubbleStyle ? 2 : 1
const handleKnowledgeBaseSelect = (base?: KnowledgeBase) => {
setSelectedKnowledgeBase(base)
}
const onMentionModel = useCallback(
(model: Model) => {
const isSelected = mentionModels.some((m) => m.id === model.id)
if (isSelected) {
setMentionModels(mentionModels.filter((m) => m.id !== model.id))
} else {
setMentionModels([...mentionModels, model])
}
},
[mentionModels]
)
const handleRemoveModel = (model: Model) => {
setMentionModels(mentionModels.filter((m) => m.id !== model.id))
}
return (
<Container onDragOver={handleDragOver} onDrop={handleDrop} className="inputbar">
<NarrowLayout style={{ width: '100%' }}>
<AttachmentPreview files={files} setFiles={setFiles} />
<InputBarContainer
id="inputbar"
className={classNames('inputbar-container', inputFocus && 'focus')}
ref={containerRef}>
<AttachmentPreview files={files} setFiles={setFiles} />
<MentionModelsInput selectedModels={mentionModels} onRemoveModel={handleRemoveModel} />
<Textarea
value={text}
onChange={(e) => setText(e.target.value)}
@@ -421,6 +467,11 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic }) => {
<FormOutlined />
</ToolbarButton>
</Tooltip>
<MentionModelsButton
mentionModels={mentionModels}
onMentionModel={onMentionModel}
ToolbarButton={ToolbarButton}
/>
{isWebSearchModel(model) && (
<Tooltip placement="top" title={t('chat.input.web_search')} arrow>
<ToolbarButton
@@ -432,14 +483,14 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic }) => {
</ToolbarButton>
</Tooltip>
)}
<Tooltip placement="top" title={t('chat.input.clear')} arrow>
<Tooltip placement="top" title={t('chat.input.clear', { Command: cleanTopicShortcut })} arrow>
<Popconfirm
title={t('chat.input.clear.content')}
placement="top"
onConfirm={clearTopic}
okButtonProps={{ danger: true }}
icon={<QuestionCircleOutlined style={{ color: 'red' }} />}
okText={t('chat.input.clear')}>
okText={t('chat.input.clear.title')}>
<ToolbarButton type="text">
<ClearOutlined />
</ToolbarButton>
@@ -464,11 +515,11 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic }) => {
/>
)}
<AttachmentButton model={model} files={files} setFiles={setFiles} ToolbarButton={ToolbarButton} />
<ToolbarButton type="text" onClick={onNewContext}>
<Tooltip placement="top" title={t('chat.input.new.context')}>
<Tooltip placement="top" title={t('chat.input.new.context', { Command: newContextShortcut })} arrow>
<ToolbarButton type="text" onClick={onNewContext}>
<PicCenterOutlined />
</Tooltip>
</ToolbarButton>
</ToolbarButton>
</Tooltip>
<Tooltip placement="top" title={expended ? t('chat.input.collapse') : t('chat.input.expand')} arrow>
<ToolbarButton type="text" onClick={onToggleExpended}>
{expended ? <FullscreenExitOutlined /> : <FullscreenOutlined />}

View File

@@ -0,0 +1,155 @@
import { PushpinOutlined } from '@ant-design/icons'
import ModelTags from '@renderer/components/ModelTags'
import { getModelLogo, isEmbeddingModel } from '@renderer/config/models'
import db from '@renderer/databases'
import { useProviders } from '@renderer/hooks/useProvider'
import { getModelUniqId } from '@renderer/services/ModelService'
import { Model } from '@renderer/types'
import { Avatar, Dropdown, Tooltip } from 'antd'
import { first, sortBy } from 'lodash'
import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled, { createGlobalStyle } from 'styled-components'
interface Props {
mentionModels: Model[]
onMentionModel: (model: Model) => void
ToolbarButton: any
}
const MentionModelsButton: FC<Props> = ({ onMentionModel: onSelect, ToolbarButton }) => {
const { providers } = useProviders()
const [pinnedModels, setPinnedModels] = useState<string[]>([])
const { t } = useTranslation()
useEffect(() => {
const loadPinnedModels = async () => {
const setting = await db.settings.get('pinned:models')
setPinnedModels(setting?.value || [])
}
loadPinnedModels()
}, [])
const togglePin = async (modelId: string) => {
const newPinnedModels = pinnedModels.includes(modelId)
? pinnedModels.filter((id) => id !== modelId)
: [...pinnedModels, modelId]
await db.settings.put({ id: 'pinned:models', value: newPinnedModels })
setPinnedModels(newPinnedModels)
}
const modelMenuItems = providers
.filter((p) => p.models && p.models.length > 0)
.map((p) => {
const filteredModels = sortBy(p.models, ['group', 'name'])
.filter((m) => !isEmbeddingModel(m))
.map((m) => ({
key: getModelUniqId(m),
label: (
<ModelItem>
<span>
{m?.name} <ModelTags model={m} />
</span>
{/* <Checkbox checked={selectedModels.some((sm) => sm.id === m.id)} /> */}
<PinIcon
onClick={(e) => {
e.stopPropagation()
togglePin(getModelUniqId(m))
}}
$isPinned={pinnedModels.includes(getModelUniqId(m))}>
<PushpinOutlined />
</PinIcon>
</ModelItem>
),
icon: (
<Avatar src={getModelLogo(m.id)} size={24}>
{first(m.name)}
</Avatar>
),
onClick: () => {
onSelect(m)
}
}))
return filteredModels.length > 0
? {
key: p.id,
label: p.isSystem ? t(`provider.${p.id}`) : p.name,
type: 'group' as const,
children: filteredModels
}
: null
})
.filter(Boolean)
if (pinnedModels.length > 0) {
const pinnedItems = modelMenuItems
.flatMap((p) => p?.children || [])
.filter((m) => pinnedModels.includes(m.key))
.map((m) => ({ ...m, key: m.key + 'pinned' }))
if (pinnedItems.length > 0) {
modelMenuItems.unshift({
key: 'pinned',
label: t('models.pinned'),
type: 'group' as const,
children: pinnedItems
})
}
}
return (
<>
<DropdownMenuStyle />
<Dropdown menu={{ items: modelMenuItems }} trigger={['click']} overlayClassName="mention-models-dropdown">
<Tooltip placement="top" title={t('agents.edit.model.select.title')} arrow>
<ToolbarButton type="text">
<i className="iconfont icon-at" style={{ fontSize: 18 }}></i>
</ToolbarButton>
</Tooltip>
</Dropdown>
</>
)
}
const DropdownMenuStyle = createGlobalStyle`
.mention-models-dropdown {
.ant-dropdown-menu {
max-height: 400px;
}
}
`
const ModelItem = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
font-size: 14px;
width: 100%;
gap: 16px;
&:hover {
.pin-icon {
opacity: 0.3;
}
}
`
const PinIcon = styled.span.attrs({ className: 'pin-icon' })<{ $isPinned: boolean }>`
margin-left: auto;
padding: 0 8px;
opacity: ${(props) => (props.$isPinned ? 1 : 'inherit')};
transition: opacity 0.2s;
right: 0;
color: ${(props) => (props.$isPinned ? 'var(--color-primary)' : 'inherit')};
transform: ${(props) => (props.$isPinned ? 'rotate(-45deg)' : 'none')};
opacity: 0;
&:hover {
opacity: 1 !important;
color: ${(props) => (props.$isPinned ? 'var(--color-primary)' : 'inherit')};
}
`
export default MentionModelsButton

View File

@@ -0,0 +1,26 @@
import { Model } from '@renderer/types'
import { Flex, Tag } from 'antd'
import { FC } from 'react'
import styled from 'styled-components'
const MentionModelsInput: FC<{
selectedModels: Model[]
onRemoveModel: (model: Model) => void
}> = ({ selectedModels, onRemoveModel }) => {
return (
<Container gap="4px 0" wrap>
{selectedModels.map((model) => (
<Tag bordered={false} color="processing" key={model.id} closable onClose={() => onRemoveModel(model)}>
@{model.name}
</Tag>
))}
</Container>
)
}
const Container = styled(Flex)`
width: 100%;
padding: 10px 15px 0;
`
export default MentionModelsInput

View File

@@ -5,6 +5,7 @@ import { useModel } from '@renderer/hooks/useModel'
import { useMessageStyle, useSettings } from '@renderer/hooks/useSettings'
import { fetchChatCompletion } from '@renderer/services/ApiService'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { getMessageModelId } from '@renderer/services/MessagesService'
import { estimateMessageUsage } from '@renderer/services/TokenService'
import { Message, Topic } from '@renderer/types'
import { classNames, runAsyncFunction } from '@renderer/utils'
@@ -25,19 +26,28 @@ interface Props {
index?: number
total?: number
hidePresetMessages?: boolean
style?: React.CSSProperties
isGrouped?: boolean
onGetMessages?: () => Message[]
onSetMessages?: Dispatch<SetStateAction<Message[]>>
onDeleteMessage?: (message: Message) => void
onDeleteMessage?: (message: Message) => Promise<void>
}
const getMessageBackground = (isBubbleStyle: boolean, isAssistantMessage: boolean) =>
isBubbleStyle ? (isAssistantMessage ? 'var(--chat-background-assistant)' : 'var(--chat-background-user)') : undefined
const getMessageBackground = (isBubbleStyle: boolean, isAssistantMessage: boolean) => {
return isBubbleStyle
? isAssistantMessage
? 'var(--chat-background-assistant)'
: 'var(--chat-background-user)'
: undefined
}
const MessageItem: FC<Props> = ({
message: _message,
topic,
index,
hidePresetMessages,
isGrouped,
style,
onDeleteMessage,
onSetMessages,
onGetMessages
@@ -45,7 +55,7 @@ const MessageItem: FC<Props> = ({
const [message, setMessage] = useState(_message)
const { t } = useTranslation()
const { assistant, setModel } = useAssistant(message.assistantId)
const model = useModel(message.modelId)
const model = useModel(getMessageModelId(message)) || message.model
const { isBubbleStyle } = useMessageStyle()
const { showMessageDivider, messageFont, fontSize } = useSettings()
const messageContainerRef = useRef<HTMLDivElement>(null)
@@ -109,6 +119,8 @@ const MessageItem: FC<Props> = ({
if (topic && onGetMessages && onSetMessages) {
if (message.status === 'sending') {
const messages = onGetMessages()
const assistantWithModel = message.model ? { ...assistant, model: message.model } : assistant
fetchChatCompletion({
message,
messages: messages
@@ -117,12 +129,11 @@ const MessageItem: FC<Props> = ({
0,
messages.findIndex((m) => m.id === message.id)
),
assistant,
topic,
assistant: assistantWithModel,
onResponse: (msg) => {
setMessage(msg)
if (msg.status !== 'pending') {
const _messages = messages.map((m) => (m.id === msg.id ? msg : m))
const _messages = onGetMessages().map((m) => (m.id === msg.id ? msg : m))
onSetMessages(_messages)
db.topics.update(topic.id, { messages: _messages })
}
@@ -156,8 +167,8 @@ const MessageItem: FC<Props> = ({
'message-user': !isAssistantMessage
})}
ref={messageContainerRef}
style={isBubbleStyle ? { alignItems: isAssistantMessage ? 'start' : 'end' } : undefined}>
<MessageHeader message={message} assistant={assistant} model={model} key={message.modelId} />
style={{ ...style, alignItems: isBubbleStyle ? (isAssistantMessage ? 'start' : 'end') : undefined }}>
<MessageHeader message={message} assistant={assistant} model={model} key={getMessageModelId(message)} />
<MessageContentContainer
className="message-content-container"
style={{ fontFamily, fontSize, background: messageBackground }}>
@@ -178,6 +189,7 @@ const MessageItem: FC<Props> = ({
index={index}
isLastMessage={isLastMessage}
isAssistantMessage={isAssistantMessage}
isGrouped={isGrouped}
setModel={setModel}
onEditMessage={onEditMessage}
onDeleteMessage={onDeleteMessage}
@@ -193,7 +205,6 @@ const MessageItem: FC<Props> = ({
const MessageContainer = styled.div`
display: flex;
flex-direction: column;
padding: 15px 20px 0 20px;
position: relative;
transition: background-color 0.3s ease;
&.message-highlight {

View File

@@ -1,8 +1,9 @@
import { SyncOutlined, TranslationOutlined } from '@ant-design/icons'
import { Message, Model } from '@renderer/types'
import { getBriefInfo } from '@renderer/utils'
import { Divider } from 'antd'
import React from 'react'
import { withMessageThought } from '@renderer/utils/formats'
import { Divider, Flex } from 'antd'
import React, { Fragment } from 'react'
import { useTranslation } from 'react-i18next'
import BeatLoader from 'react-spinners/BeatLoader'
import styled from 'styled-components'
@@ -11,12 +12,16 @@ import Markdown from '../Markdown/Markdown'
import MessageAttachments from './MessageAttachments'
import MessageError from './MessageError'
import MessageSearchResults from './MessageSearchResults'
import MessageThought from './MessageThought'
const MessageContent: React.FC<{
interface Props {
message: Message
model?: Model
}> = ({ message, model }) => {
}
const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
const { t } = useTranslation()
const message = withMessageThought(_message)
if (message.status === 'sending') {
return (
@@ -36,10 +41,14 @@ const MessageContent: React.FC<{
}
return (
<>
<Fragment>
<Flex gap="8px" wrap style={{ marginBottom: 10 }}>
{message.mentions?.map((model) => <MentionTag key={model.id}>{'@' + model.name}</MentionTag>)}
</Flex>
<MessageThought message={message} />
<Markdown message={message} />
{message.translatedContent && (
<>
<Fragment>
<Divider style={{ margin: 0, marginBottom: 10 }}>
<TranslationOutlined />
</Divider>
@@ -48,11 +57,11 @@ const MessageContent: React.FC<{
) : (
<Markdown message={{ ...message, content: message.translatedContent }} />
)}
</>
</Fragment>
)}
<MessageAttachments message={message} />
<MessageSearchResults message={message} />
</>
</Fragment>
)
}
@@ -65,4 +74,8 @@ const MessageContentLoading = styled.div`
margin-bottom: 5px;
`
const MentionTag = styled.span`
color: var(--color-link);
`
export default React.memo(MessageContent)

View File

@@ -0,0 +1,268 @@
import { ColumnHeightOutlined, ColumnWidthOutlined, DeleteOutlined, FolderOutlined } from '@ant-design/icons'
import ModelAvatar from '@renderer/components/Avatar/ModelAvatar'
import { HStack } from '@renderer/components/Layout'
import Scrollbar from '@renderer/components/Scrollbar'
import { useSettings } from '@renderer/hooks/useSettings'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { MultiModelMessageStyle } from '@renderer/store/settings'
import { Message, Model, Topic } from '@renderer/types'
import { Button, Segmented as AntdSegmented } from 'antd'
import { Dispatch, FC, SetStateAction, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled, { css } from 'styled-components'
import MessageItem from './Message'
interface Props {
messages: (Message & { index: number })[]
topic?: Topic
hidePresetMessages?: boolean
onGetMessages?: () => Message[]
onSetMessages?: Dispatch<SetStateAction<Message[]>>
onDeleteMessage?: (message: Message) => Promise<void>
onDeleteGroupMessages?: (askId: string) => Promise<void>
}
const MessageGroup: FC<Props> = ({
messages,
topic,
hidePresetMessages,
onDeleteMessage,
onSetMessages,
onGetMessages,
onDeleteGroupMessages
}) => {
const { multiModelMessageStyle: multiModelMessageStyleSetting } = useSettings()
const { t } = useTranslation()
const [multiModelMessageStyle, setMultiModelMessageStyle] =
useState<MultiModelMessageStyle>(multiModelMessageStyleSetting)
const messageLength = messages.length
const [selectedIndex, setSelectedIndex] = useState(messageLength - 1)
const isGrouped = messageLength > 1
const onDelete = async () => {
window.modal.confirm({
title: t('message.group.delete.title'),
content: t('message.group.delete.content'),
centered: true,
okButtonProps: {
danger: true
},
okText: t('common.delete'),
onOk: () => {
const askId = messages[0].askId
askId && onDeleteGroupMessages?.(askId)
}
})
}
useEffect(() => {
setSelectedIndex(messageLength - 1)
}, [messageLength])
const isHorizontal = multiModelMessageStyle === 'horizontal'
return (
<GroupContainer $isGrouped={isGrouped} $layout={multiModelMessageStyle}>
<GridContainer $count={messageLength} $layout={multiModelMessageStyle}>
{messages.map((message, index) => (
<MessageWrapper
$layout={multiModelMessageStyle}
$selected={index === selectedIndex}
$isGrouped={isGrouped}
key={message.id}
className={message.role === 'assistant' && isHorizontal && isGrouped ? 'group-message-wrapper' : ''}>
<MessageItem
isGrouped={isGrouped}
message={message}
topic={topic}
index={message.index}
hidePresetMessages={hidePresetMessages}
style={{ paddingTop: isGrouped && multiModelMessageStyle === 'horizontal' ? 0 : 15 }}
onSetMessages={onSetMessages}
onDeleteMessage={onDeleteMessage}
onGetMessages={onGetMessages}
/>
</MessageWrapper>
))}
</GridContainer>
{isGrouped && (
<GroupMenuBar className="group-menu-bar" $layout={multiModelMessageStyle}>
<HStack style={{ alignItems: 'center', flex: 1, overflow: 'hidden' }}>
<LayoutContainer>
{['fold', 'vertical', 'horizontal'].map((layout) => (
<LayoutOption
key={layout}
active={multiModelMessageStyle === layout}
onClick={() => setMultiModelMessageStyle(layout as MultiModelMessageStyle)}>
{layout === 'fold' ? (
<FolderOutlined />
) : layout === 'horizontal' ? (
<ColumnWidthOutlined />
) : (
<ColumnHeightOutlined />
)}
</LayoutOption>
))}
</LayoutContainer>
{multiModelMessageStyle === 'fold' && (
<ModelsContainer>
<Segmented
value={selectedIndex.toString()}
onChange={(value) => {
setSelectedIndex(Number(value))
EventEmitter.emit(EVENT_NAMES.LOCATE_MESSAGE + ':' + messages[Number(value)].id, false)
}}
options={messages.map((message, index) => ({
label: (
<SegmentedLabel>
<ModelAvatar model={message.model as Model} size={20} />
<ModelName>{message.model?.name}</ModelName>
</SegmentedLabel>
),
value: index.toString()
}))}
size="small"
/>
</ModelsContainer>
)}
</HStack>
<Button
type="text"
size="small"
icon={<DeleteOutlined style={{ color: 'var(--color-error)' }} />}
onClick={onDelete}
/>
</GroupMenuBar>
)}
</GroupContainer>
)
}
const GroupContainer = styled.div<{ $isGrouped: boolean; $layout: MultiModelMessageStyle }>`
padding-top: ${({ $isGrouped, $layout }) => ($isGrouped && $layout === 'horizontal' ? '15px' : '0')};
`
const GridContainer = styled.div<{ $count: number; $layout: MultiModelMessageStyle }>`
width: 100%;
display: grid;
grid-template-columns: repeat(
${(props) => (['fold', 'vertical'].includes(props.$layout) ? 1 : props.$count)},
minmax(550px, 1fr)
);
gap: ${({ $layout }) => ($layout === 'horizontal' ? '16px' : '0')};
@media (max-width: 800px) {
grid-template-columns: repeat(
${(props) => (['fold', 'vertical'].includes(props.$layout) ? 1 : props.$count)},
minmax(400px, 1fr)
);
}
overflow-y: auto;
`
interface MessageWrapperProps {
$layout: 'fold' | 'horizontal' | 'vertical'
$selected: boolean
$isGrouped: boolean
}
const MessageWrapper = styled(Scrollbar)<MessageWrapperProps>`
width: 100%;
display: ${(props) => {
if (props.$layout === 'fold') {
return props.$selected ? 'block' : 'none'
}
if (props.$layout === 'horizontal') {
return 'inline-block'
}
return 'block'
}};
${({ $layout, $isGrouped }) => {
if ($layout === 'horizontal' && $isGrouped) {
return css`
border: 0.5px solid var(--color-border);
padding: 10px;
border-radius: 6px;
max-height: 600px;
overflow-y: auto;
margin-bottom: 10px;
`
}
return ''
}}
`
const GroupMenuBar = styled.div<{ $layout: MultiModelMessageStyle }>`
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
padding: 6px 10px;
border-radius: 6px;
margin-top: 10px;
justify-content: space-between;
overflow: hidden;
border: 0.5px solid var(--color-border);
height: 40px;
margin-left: ${({ $layout }) => ($layout === 'horizontal' ? '0' : '40px')};
transition: all 0.3s ease;
`
const LayoutContainer = styled.div`
display: flex;
gap: 10px;
flex-direction: row;
`
const LayoutOption = styled.div<{ active: boolean }>`
cursor: pointer;
padding: 2px 10px;
border-radius: 4px;
background-color: ${({ active }) => (active ? 'var(--color-background-soft)' : 'transparent')};
&:hover {
background-color: ${({ active }) => (active ? 'var(--color-background-soft)' : 'var(--color-hover)')};
}
`
const ModelsContainer = styled(Scrollbar)`
display: flex;
flex-direction: column;
justify-content: space-between;
&::-webkit-scrollbar {
display: none;
}
`
const Segmented = styled(AntdSegmented)`
.ant-segmented-item {
background-color: transparent !important;
transition: none !important;
&:hover {
background: transparent !important;
}
}
.ant-segmented-thumb,
.ant-segmented-item-selected {
background-color: transparent !important;
border: 0.5px solid var(--color-border);
transition: none !important;
}
`
const SegmentedLabel = styled.div`
display: flex;
align-items: center;
gap: 5px;
padding: 3px 0;
`
const ModelName = styled.span`
font-weight: 500;
font-size: 12px;
`
export default MessageGroup

View File

@@ -5,6 +5,8 @@ import { getModelLogo } from '@renderer/config/models'
import { useTheme } from '@renderer/context/ThemeProvider'
import useAvatar from '@renderer/hooks/useAvatar'
import { useMessageStyle, useSettings } from '@renderer/hooks/useSettings'
import { getMessageModelId } from '@renderer/services/MessagesService'
import { getModelName } from '@renderer/services/ModelService'
import { Assistant, Message, Model } from '@renderer/types'
import { firstLetter, removeLeadingEmoji } from '@renderer/utils'
import { Avatar } from 'antd'
@@ -31,13 +33,19 @@ const MessageHeader: FC<Props> = memo(({ assistant, model, message }) => {
const { t } = useTranslation()
const { isBubbleStyle } = useMessageStyle()
const avatarSource = useMemo(() => getAvatarSource(isLocalAi, message.modelId), [message.modelId])
const avatarSource = useMemo(() => getAvatarSource(isLocalAi, getMessageModelId(message)), [message])
const getUserName = useCallback(() => {
if (isLocalAi && message.role !== 'user') return APP_NAME
if (message.role === 'assistant') return model?.name || model?.id || message.modelId || ''
if (isLocalAi && message.role !== 'user') {
return APP_NAME
}
if (message.role === 'assistant') {
return getModelName(model) || getMessageModelId(message) || ''
}
return userName || t('common.you')
}, [message.modelId, message.role, model?.id, model?.name, t, userName])
}, [message, model, t, userName])
const isAssistantMessage = message.role === 'assistant'
const showMinappIcon = sidebarIcons.visible.includes('minapp')

View File

@@ -3,6 +3,8 @@ import {
DeleteOutlined,
EditOutlined,
ForkOutlined,
LikeFilled,
LikeOutlined,
MenuOutlined,
QuestionCircleOutlined,
SaveOutlined,
@@ -11,13 +13,16 @@ import {
} from '@ant-design/icons'
import SelectModelPopup from '@renderer/components/Popups/SelectModelPopup'
import TextEditPopup from '@renderer/components/Popups/TextEditPopup'
import { TranslateLanguageOptions } from '@renderer/config/translate'
import { modelGenerating } from '@renderer/hooks/useRuntime'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { resetAssistantMessage } from '@renderer/services/MessagesService'
import { translateText } from '@renderer/services/TranslateService'
import { Message, Model } from '@renderer/types'
import { removeTrailingDoubleSpaces, uuid } from '@renderer/utils'
import { Button, Dropdown, Popconfirm, Tooltip } from 'antd'
import dayjs from 'dayjs'
import { isEmpty } from 'lodash'
import { FC, useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@@ -27,11 +32,12 @@ interface Props {
assistantModel?: Model
model?: Model
index?: number
isGrouped?: boolean
isLastMessage: boolean
isAssistantMessage: boolean
setModel: (model: Model) => void
onEditMessage?: (message: Message) => void
onDeleteMessage?: (message: Message) => void
onDeleteMessage?: (message: Message) => Promise<void>
onGetMessages?: () => Message[]
}
@@ -39,11 +45,11 @@ const MessageMenubar: FC<Props> = (props) => {
const {
message,
index,
isGrouped,
model,
isLastMessage,
isAssistantMessage,
assistantModel,
setModel,
onEditMessage,
onDeleteMessage,
onGetMessages
@@ -53,7 +59,6 @@ const MessageMenubar: FC<Props> = (props) => {
const [isTranslating, setIsTranslating] = useState(false)
const isUserMessage = message.role === 'user'
const canRegenerate = isLastMessage && isAssistantMessage
const onCopy = useCallback(() => {
navigator.clipboard.writeText(removeTrailingDoubleSpaces(message.content))
@@ -62,14 +67,6 @@ const MessageMenubar: FC<Props> = (props) => {
setTimeout(() => setCopied(false), 2000)
}, [message.content, t])
const onRegenerate = useCallback(
(model: Model) => {
setModel(model)
setTimeout(() => EventEmitter.emit(EVENT_NAMES.REGENERATE_MESSAGE, model), 100)
},
[setModel]
)
const onNewBranch = useCallback(async () => {
await modelGenerating()
EventEmitter.emit(EVENT_NAMES.NEW_BRANCH, index)
@@ -82,6 +79,21 @@ const MessageMenubar: FC<Props> = (props) => {
const onResend = useCallback(async () => {
await modelGenerating()
const _messages = onGetMessages?.() || []
const groupdMessages = _messages.filter((m) => m.askId === message.id)
// Resend all groupd messages
if (!isEmpty(groupdMessages)) {
for (const assistantMessage of groupdMessages) {
const _model = assistantMessage.model || assistantModel
EventEmitter.emit(
EVENT_NAMES.RESEND_MESSAGE + ':' + assistantMessage.id,
resetAssistantMessage(assistantMessage, _model)
)
}
return
}
// If there is no groupd message, resend next message
const index = _messages.findIndex((m) => m.id === message.id)
const nextIndex = index + 1
const nextMessage = _messages[nextIndex]
@@ -91,35 +103,42 @@ const MessageMenubar: FC<Props> = (props) => {
...nextMessage,
content: '',
status: 'sending',
modelId: assistantModel?.id || model?.id,
model: assistantModel || model,
translatedContent: undefined
})
}
if (!nextMessage) {
onDeleteMessage?.(message)
// If next message is not exist or next message role is user, delete current message and resend
if (!nextMessage || nextMessage.role === 'user') {
EventEmitter.emit(EVENT_NAMES.SEND_MESSAGE, { ...message, id: uuid() })
onDeleteMessage?.(message)
}
}, [assistantModel?.id, message, model?.id, onDeleteMessage, onGetMessages])
}, [assistantModel, message, model, onDeleteMessage, onGetMessages])
const onEdit = useCallback(async () => {
let resendMessage = false
const editedText = await TextEditPopup.show({
text: message.content,
children: (props) => (
<ReSendButton
icon={<i className="iconfont icon-ic_send" style={{ color: 'var(--color-primary)' }} />}
onClick={() => {
props.onOk?.()
resendMessage = true
}}>
{t('chat.resend')}
</ReSendButton>
)
children: (props) => {
const onPress = () => {
props.onOk?.()
resendMessage = true
}
return message.role === 'user' ? (
<ReSendButton
icon={<i className="iconfont icon-ic_send" style={{ color: 'var(--color-primary)' }} />}
onClick={onPress}>
{t('chat.resend')}
</ReSendButton>
) : null
}
})
editedText && onEditMessage?.({ ...message, content: editedText })
if (editedText) {
await onEditMessage?.({ ...message, content: editedText })
}
resendMessage && onResend()
}, [message, onEditMessage, onResend, t])
@@ -132,8 +151,9 @@ const MessageMenubar: FC<Props> = (props) => {
setIsTranslating(true)
try {
const translatedText = await translateText(message.content, language)
onEditMessage?.({ ...message, translatedContent: translatedText })
await translateText(message.content, language, (text) =>
onEditMessage?.({ ...message, translatedContent: text })
)
} catch (error) {
console.error('Translation failed:', error)
window.message.error({
@@ -175,22 +195,23 @@ const MessageMenubar: FC<Props> = (props) => {
[message, onEdit, onNewBranch, t]
)
const onAtModelRegenerate = async () => {
const onRegenerate = async () => {
await modelGenerating()
const selectedModel = await SelectModelPopup.show({ model })
selectedModel && onRegenerate(selectedModel)
if (!selectedModel) return
const _message: Message = resetAssistantMessage(message, selectedModel)
if (message.askId && message.model) {
return EventEmitter.emit(EVENT_NAMES.APPEND_MESSAGE, { ..._message, id: uuid() })
}
onEditMessage?.(_message)
}
const onDeleteAndRegenerate = async () => {
await modelGenerating()
onEditMessage?.({
...message,
content: '',
status: 'sending',
modelId: assistantModel?.id || model?.id,
translatedContent: undefined
})
}
const onUseful = useCallback(() => {
onEditMessage?.({ ...message, useful: !message.useful })
}, [message, onEditMessage])
return (
<MenusBar className={`menubar ${isLastMessage && 'show'}`}>
@@ -208,23 +229,9 @@ const MessageMenubar: FC<Props> = (props) => {
</ActionButton>
</Tooltip>
{isAssistantMessage && (
<Popconfirm
title={t('message.regenerate.confirm')}
okButtonProps={{ danger: true }}
destroyTooltipOnHide
icon={<QuestionCircleOutlined style={{ color: 'red' }} />}
onConfirm={onDeleteAndRegenerate}>
<Tooltip title={t('common.regenerate')} mouseEnterDelay={0.8}>
<ActionButton className="message-action-button">
<SyncOutlined />
</ActionButton>
</Tooltip>
</Popconfirm>
)}
{canRegenerate && (
<Tooltip title={t('chat.message.regenerate.model')} mouseEnterDelay={0.8}>
<ActionButton className="message-action-button" onClick={onAtModelRegenerate}>
<i className="iconfont icon-at"></i>
<Tooltip title={t('common.regenerate')} mouseEnterDelay={0.8}>
<ActionButton className="message-action-button" onClick={onRegenerate}>
<SyncOutlined />
</ActionButton>
</Tooltip>
)}
@@ -232,36 +239,11 @@ const MessageMenubar: FC<Props> = (props) => {
<Dropdown
menu={{
items: [
{
label: '🇨🇳 ' + t('languages.chinese'),
key: 'translate-chinese',
onClick: () => handleTranslate('chinese')
},
{
label: '🇭🇰 ' + t('languages.chinese-traditional'),
key: 'translate-chinese-traditional',
onClick: () => handleTranslate('chinese-traditional')
},
{
label: '🇬🇧 ' + t('languages.english'),
key: 'translate-english',
onClick: () => handleTranslate('english')
},
{
label: '🇯🇵 ' + t('languages.japanese'),
key: 'translate-japanese',
onClick: () => handleTranslate('japanese')
},
{
label: '🇰🇷 ' + t('languages.korean'),
key: 'translate-korean',
onClick: () => handleTranslate('korean')
},
{
label: '🇷🇺 ' + t('languages.russian'),
key: 'translate-russian',
onClick: () => handleTranslate('russian')
},
...TranslateLanguageOptions.map((item) => ({
label: item.emoji + ' ' + item.label,
key: item.value,
onClick: () => handleTranslate(item.value)
})),
{
label: '✖ ' + t('translate.close'),
key: 'translate-close',
@@ -279,13 +261,23 @@ const MessageMenubar: FC<Props> = (props) => {
</Tooltip>
</Dropdown>
)}
{isAssistantMessage && (
<Tooltip title={t('chat.message.useful')} mouseEnterDelay={0.8}>
<ActionButton className="message-action-button" onClick={onUseful}>
{message.useful ? <LikeFilled /> : <LikeOutlined />}
</ActionButton>
</Tooltip>
)}
<Popconfirm
disabled={isGrouped}
title={t('message.message.delete.content')}
okButtonProps={{ danger: true }}
icon={<QuestionCircleOutlined style={{ color: 'red' }} />}
onConfirm={() => onDeleteMessage?.(message)}>
<Tooltip title={t('common.delete')} mouseEnterDelay={1}>
<ActionButton className="message-action-button">
<ActionButton
className="message-action-button"
onClick={isGrouped ? () => onDeleteMessage?.(message) : undefined}>
<DeleteOutlined />
</ActionButton>
</Tooltip>

View File

@@ -0,0 +1,61 @@
import { Message } from '@renderer/types'
import { Collapse } from 'antd'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
import ReactMarkdown from 'react-markdown'
import BarLoader from 'react-spinners/BarLoader'
import styled from 'styled-components'
interface Props {
message: Message
}
const MessageThought: FC<Props> = ({ message }) => {
const isThinking = !message.content
const { t } = useTranslation()
if (!message.reasoning_content) {
return null
}
const thinkingTime = message.metrics?.time_thinking_millsec || 0
const thinkingTimeSecounds = (thinkingTime / 1000).toFixed(1)
return (
<CollapseContainer
className="message-thought-container"
items={[
{
key: 'thought',
label: (
<MessageTitleLabel>
<TinkingText>
{isThinking ? t('chat.thinking') : t('chat.deeply_thought', { secounds: thinkingTimeSecounds })}
</TinkingText>
{isThinking && <BarLoader color="#9254de" />}
</MessageTitleLabel>
),
children: <ReactMarkdown>{message.reasoning_content}</ReactMarkdown>
}
]}
/>
)
}
const CollapseContainer = styled(Collapse)`
margin-bottom: 15px;
`
const MessageTitleLabel = styled.div`
display: flex;
flex-direction: row;
align-items: center;
height: 22px;
gap: 15px;
`
const TinkingText = styled.span`
color: var(--color-text-2);
`
export default MessageThought

View File

@@ -12,6 +12,7 @@ import {
filterMessages,
getAssistantMessage,
getContextCount,
getGroupedMessages,
getUserMessage
} from '@renderer/services/MessagesService'
import { estimateHistoryTokens } from '@renderer/services/TokenService'
@@ -25,7 +26,7 @@ import BeatLoader from 'react-spinners/BeatLoader'
import styled from 'styled-components'
import Suggestions from '../components/Suggestions'
import MessageItem from './Message'
import MessageGroup from './MessageGroup'
import NarrowLayout from './NarrowLayout'
import Prompt from './Prompt'
@@ -35,39 +36,6 @@ interface Props {
setActiveTopic: (topic: Topic) => void
}
interface LoaderProps {
$loading: boolean
}
const LoaderContainer = styled.div<LoaderProps>`
display: flex;
justify-content: center;
padding: 10px;
width: 100%;
background: var(--color-background);
opacity: ${(props) => (props.$loading ? 1 : 0)};
transition: opacity 0.3s ease;
pointer-events: none;
`
const ScrollContainer = styled.div`
display: flex;
flex-direction: column-reverse;
`
interface ContainerProps {
right?: boolean
}
const Container = styled(Scrollbar)<ContainerProps>`
display: flex;
flex-direction: column-reverse;
padding: 10px 0;
padding-bottom: 20px;
overflow-x: hidden;
background-color: var(--color-background);
`
const Messages: FC<Props> = ({ assistant, topic, setActiveTopic }) => {
const [messages, setMessages] = useState<Message[]>([])
const [displayMessages, setDisplayMessages] = useState<Message[]>([])
@@ -79,6 +47,8 @@ const Messages: FC<Props> = ({ assistant, topic, setActiveTopic }) => {
const { updateTopic, addTopic } = useAssistant(assistant.id)
const { showTopics, topicPosition, showAssistants, enableTopicNaming } = useSettings()
const groupedMessages = getGroupedMessages(displayMessages)
const INITIAL_MESSAGES_COUNT = 20
const LOAD_MORE_COUNT = 20
@@ -97,10 +67,23 @@ const Messages: FC<Props> = ({ assistant, topic, setActiveTopic }) => {
const onSendMessage = useCallback(
async (message: Message) => {
const assistantMessage = getAssistantMessage({ assistant, topic })
const assistantMessages: Message[] = []
if (message.mentions?.length) {
message.mentions.forEach((m) => {
const assistantMessage = getAssistantMessage({ assistant: { ...assistant, model: m }, topic })
assistantMessage.model = m
assistantMessage.askId = message.id
assistantMessages.push(assistantMessage)
})
} else {
const assistantMessage = getAssistantMessage({ assistant, topic })
assistantMessage.askId = message.id
assistantMessages.push(assistantMessage)
}
setMessages((prev) => {
const messages = prev.concat([message, assistantMessage])
const messages = prev.concat([message, ...assistantMessages])
db.topics.put({ id: topic.id, messages })
return messages
})
@@ -110,6 +93,17 @@ const Messages: FC<Props> = ({ assistant, topic, setActiveTopic }) => {
[assistant, scrollToBottom, topic]
)
const onAppendMessage = useCallback(
(message: Message) => {
setMessages((prev) => {
const messages = prev.concat([message])
db.topics.put({ id: topic.id, messages })
return messages
})
},
[topic.id]
)
const autoRenameTopic = useCallback(async () => {
const _topic = getTopic(assistant, topic.id)
@@ -134,12 +128,25 @@ const Messages: FC<Props> = ({ assistant, topic, setActiveTopic }) => {
}, [assistant, enableTopicNaming, messages, setActiveTopic, topic.id, updateTopic])
const onDeleteMessage = useCallback(
(message: Message) => {
async (message: Message) => {
const _messages = messages.filter((m) => m.id !== message.id)
setMessages(_messages)
setDisplayMessages(_messages)
db.topics.update(topic.id, { messages: _messages })
deleteMessageFiles(message)
await db.topics.update(topic.id, { messages: _messages })
await deleteMessageFiles(message)
},
[messages, topic.id]
)
const onDeleteGroupMessages = useCallback(
async (askId: string) => {
const _messages = messages.filter((m) => m.askId !== askId && m.id !== askId)
setMessages(_messages)
setDisplayMessages(_messages)
await db.topics.update(topic.id, { messages: _messages })
for (const message of _messages) {
await deleteMessageFiles(message)
}
},
[messages, topic.id]
)
@@ -151,12 +158,13 @@ const Messages: FC<Props> = ({ assistant, topic, setActiveTopic }) => {
useEffect(() => {
const unsubscribes = [
EventEmitter.on(EVENT_NAMES.SEND_MESSAGE, onSendMessage),
EventEmitter.on(EVENT_NAMES.APPEND_MESSAGE, onAppendMessage),
EventEmitter.on(EVENT_NAMES.RECEIVE_MESSAGE, async () => {
setTimeout(() => EventEmitter.emit(EVENT_NAMES.AI_AUTO_RENAME), 100)
}),
EventEmitter.on(EVENT_NAMES.REGENERATE_MESSAGE, async (model: Model) => {
const lastUserMessage = last(filterMessages(messages).filter((m) => m.role === 'user'))
lastUserMessage && onSendMessage({ ...lastUserMessage, id: uuid(), type: '@', modelId: model.id })
lastUserMessage && onSendMessage({ ...lastUserMessage, id: uuid(), model: model, mentions: [model] })
}),
EventEmitter.on(EVENT_NAMES.AI_AUTO_RENAME, autoRenameTopic),
EventEmitter.on(EVENT_NAMES.CLEAR_MESSAGES, () => {
@@ -204,7 +212,7 @@ const Messages: FC<Props> = ({ assistant, topic, setActiveTopic }) => {
setActiveTopic(newTopic)
autoRenameTopic()
// 由于复制了消<EFBFBD><EFBFBD><EFBFBD>,消息中附带的文件的总数变了,需要更新
// 由于复制了消,消息中附带的文件的总数变了,需要更新
const filesArr = branchMessages.map((m) => m.files)
const files = flatten(filesArr).filter(Boolean)
files.map(async (f) => {
@@ -219,6 +227,7 @@ const Messages: FC<Props> = ({ assistant, topic, setActiveTopic }) => {
assistant,
autoRenameTopic,
messages,
onAppendMessage,
onDeleteMessage,
onSendMessage,
scrollToBottom,
@@ -283,7 +292,7 @@ const Messages: FC<Props> = ({ assistant, topic, setActiveTopic }) => {
style={{ maxWidth }}
key={assistant.id}
ref={containerRef}
right={topicPosition === 'left'}>
$right={topicPosition === 'left'}>
<NarrowLayout style={{ display: 'flex', flexDirection: 'column-reverse' }}>
<Suggestions assistant={assistant} messages={messages} />
<InfiniteScroll
@@ -297,15 +306,15 @@ const Messages: FC<Props> = ({ assistant, topic, setActiveTopic }) => {
<LoaderContainer $loading={isLoadingMore}>
<BeatLoader size={8} color="var(--color-text-2)" />
</LoaderContainer>
{displayMessages.map((message, index) => (
<MessageItem
key={message.id}
message={message}
{Object.entries(groupedMessages).map(([key, messages]) => (
<MessageGroup
key={key}
messages={messages}
topic={topic}
index={index}
hidePresetMessages={assistant.settings?.hideMessages}
onSetMessages={setMessages}
onDeleteMessage={onDeleteMessage}
onDeleteGroupMessages={onDeleteGroupMessages}
onGetMessages={onGetMessages}
/>
))}
@@ -317,4 +326,38 @@ const Messages: FC<Props> = ({ assistant, topic, setActiveTopic }) => {
)
}
interface LoaderProps {
$loading: boolean
}
const LoaderContainer = styled.div<LoaderProps>`
display: flex;
justify-content: center;
padding: 10px;
width: 100%;
background: var(--color-background);
opacity: ${(props) => (props.$loading ? 1 : 0)};
transition: opacity 0.3s ease;
pointer-events: none;
`
const ScrollContainer = styled.div`
display: flex;
flex-direction: column-reverse;
padding: 0 20px;
`
interface ContainerProps {
$right?: boolean
}
const Container = styled(Scrollbar)<ContainerProps>`
display: flex;
flex-direction: column-reverse;
padding: 10px 0;
padding-bottom: 20px;
overflow-x: hidden;
background-color: var(--color-background);
`
export default Messages

View File

@@ -27,7 +27,6 @@ const Prompt: FC<Props> = ({ assistant }) => {
const Container = styled.div`
padding: 10px 20px;
background-color: var(--color-background-soft);
margin-bottom: 20px;
margin: 4px 20px 0 20px;
border-radius: 6px;
cursor: pointer;

View File

@@ -22,6 +22,7 @@ import {
setMathEngine,
setMessageFont,
setMessageStyle,
setMultiModelMessageStyle,
setPasteLongTextAsFile,
setPasteLongTextThreshold,
setRenderInputMessageAsMarkdown,
@@ -64,7 +65,8 @@ const SettingsTab: FC<Props> = (props) => {
codeCollapsible,
mathEngine,
autoTranslateWithSpace,
pasteLongTextThreshold
pasteLongTextThreshold,
multiModelMessageStyle
} = useSettings()
const onUpdateAssistantSettings = (settings: Partial<AssistantSettings>) => {
@@ -255,6 +257,19 @@ const SettingsTab: FC<Props> = (props) => {
</Select>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>{t('message.message.multi_model_style')}</SettingRowTitleSmall>
<Select
size="small"
value={multiModelMessageStyle}
onChange={(value) => dispatch(setMultiModelMessageStyle(value))}
style={{ width: 135 }}>
<Select.Option value="fold">{t('message.message.multi_model_style.fold')}</Select.Option>
<Select.Option value="vertical">{t('message.message.multi_model_style.vertical')}</Select.Option>
<Select.Option value="horizontal">{t('message.message.multi_model_style.horizontal')}</Select.Option>
</Select>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>{t('message.message.code_style')}</SettingRowTitleSmall>
<Select

View File

@@ -361,6 +361,13 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
{providerName && <Tag color="purple">{providerName}</Tag>}
</ModelInfo>
<ModelInfo>
<label htmlFor="model-info">{t('knowledge.chunk_size')}</label>
<Tag color="green">{base.chunkSize || t('knowledge.not_set')}</Tag>
<label htmlFor="model-info">{t('knowledge.chunk_overlap')}</label>
<Tag color="orange">{base.chunkOverlap || t('knowledge.not_set')}</Tag>
</ModelInfo>
<IndexSection>
<Button
type="primary"

View File

@@ -7,7 +7,7 @@ import Scrollbar from '@renderer/components/Scrollbar'
import { useKnowledgeBases } from '@renderer/hooks/useKnowledge'
import { KnowledgeBase } from '@renderer/types'
import { Dropdown, Empty, MenuProps } from 'antd'
import { FC, useCallback, useEffect, useRef, useState } from 'react'
import { FC, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@@ -17,33 +17,18 @@ import KnowledgeContent from './KnowledgeContent'
const KnowledgePage: FC = () => {
const { t } = useTranslation()
const { bases, renameKnowledgeBase, deleteKnowledgeBase, updateKnowledgeBases } = useKnowledgeBases()
const [selectedBase, setSelectedBase] = useState<KnowledgeBase>()
const [selectedBase, setSelectedBase] = useState<KnowledgeBase | undefined>(bases[0])
const [isDragging, setIsDragging] = useState(false)
const prevLength = useRef(0)
const handleAddKnowledge = async () => {
await AddKnowledgePopup.show({ title: t('knowledge.add.title') })
}
useEffect(() => {
if (bases.length > 0) {
if (!selectedBase) {
return setSelectedBase(bases[0])
}
if (selectedBase && !bases.find((base) => base.id === selectedBase.id)) {
return setSelectedBase(bases[0])
}
}
const hasSelectedBase = bases.find((base) => base.id === selectedBase?.id)
!hasSelectedBase && setSelectedBase(bases[0])
}, [bases, selectedBase])
useEffect(() => {
const currentLength = bases.length
if (currentLength > 0 && currentLength > prevLength.current) {
setSelectedBase(bases[currentLength - 1])
}
prevLength.current = currentLength
}, [bases])
const getMenuItems = useCallback(
(base: KnowledgeBase) => {
const menus: MenuProps['items'] = [

View File

@@ -6,7 +6,7 @@ import AiProvider from '@renderer/providers/AiProvider'
import { getKnowledgeBaseParams } from '@renderer/services/KnowledgeService'
import { getModelUniqId } from '@renderer/services/ModelService'
import { Model } from '@renderer/types'
import { Form, Input, Modal, Select } from 'antd'
import { Form, Input, InputNumber, Modal, Select } from 'antd'
import { find, sortBy } from 'lodash'
import { nanoid } from 'nanoid'
import { useState } from 'react'
@@ -19,6 +19,8 @@ interface ShowParams {
interface FormData {
name: string
model: string
chunkSize?: number
chunkOverlap?: number
}
interface Props extends ShowParams {
@@ -81,6 +83,8 @@ const PopupContainer: React.FC<Props> = ({ title, resolve }) => {
name: values.name,
model: selectedModel,
dimensions,
chunkSize: values.chunkSize,
chunkOverlap: values.chunkOverlap,
items: [],
created_at: Date.now(),
updated_at: Date.now(),
@@ -131,6 +135,27 @@ const PopupContainer: React.FC<Props> = ({ title, resolve }) => {
rules={[{ required: true, message: t('message.error.enter.model') }]}>
<Select style={{ width: '100%' }} options={selectOptions} placeholder={t('settings.models.empty')} />
</Form.Item>
<Form.Item name="chunkSize" label={t('knowledge.chunk_size')}>
<InputNumber style={{ width: '100%' }} min={1} />
</Form.Item>
<Form.Item
name="chunkOverlap"
label={t('knowledge.chunk_overlap')}
rules={[
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('chunkSize') > value) {
return Promise.resolve()
}
return Promise.reject(new Error(t('message.error.chunk_overlap_too_large')))
}
})
]}
dependencies={['chunkSize']}>
<InputNumber style={{ width: '100%' }} min={0} />
</Form.Item>
</Form>
</Modal>
)

View File

@@ -55,7 +55,7 @@ const AboutSettings: FC = () => {
}
const mailto = async () => {
const email = 'kangfenmao@qq.com'
const email = 'support@cherry-ai.com'
const subject = `${APP_NAME} Feedback`
const version = (await window.api.getAppInfo()).version
const platform = window.electron.process.platform

View File

@@ -2,7 +2,7 @@ import { DeleteOutlined, PlusOutlined } from '@ant-design/icons'
import { Assistant, AssistantMessage, AssistantSettings } from '@renderer/types'
import { Button, Card, Col, Divider, Form as FormAntd, FormInstance, Row, Space, Switch } from 'antd'
import TextArea from 'antd/es/input/TextArea'
import { FC, useRef, useState } from 'react'
import { FC, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@@ -19,7 +19,15 @@ const AssistantMessagesSettings: FC<Props> = ({ assistant, updateAssistant, upda
const [messages, setMessagess] = useState<AssistantMessage[]>(assistant?.messages || [])
const [hideMessages, setHideMessages] = useState(assistant?.settings?.hideMessages || false)
const showSaveButton = (assistant?.messages || []).length !== messages.length
const showSaveButton = useMemo(() => {
const originalMessages = assistant?.messages || []
if (originalMessages.length !== messages.length) return true
return messages.some((msg, index) => {
const originalMsg = originalMessages[index]
return !originalMsg || msg.content.trim() !== originalMsg.content.trim()
})
}, [messages, assistant?.messages])
const onSave = () => {
// 检查是否有空对话组

View File

@@ -40,7 +40,7 @@ const WebDavSettings: FC = () => {
const dispatch = useAppDispatch()
const { t } = useTranslation()
const { t, i18n } = useTranslation()
const { webdavSync } = useRuntime()
@@ -52,7 +52,7 @@ const WebDavSettings: FC = () => {
return
}
setBackuping(true)
await backupToWebdav()
await backupToWebdav({ showMessage: true })
setBackuping(false)
}
@@ -168,12 +168,19 @@ const WebDavSettings: FC = () => {
<SettingRowTitle>{t('settings.data.webdav.autoSync')}</SettingRowTitle>
<Select value={syncInterval} onChange={onSyncIntervalChange} disabled={!webdavHost} style={{ width: 120 }}>
<Select.Option value={0}>{t('settings.data.webdav.autoSync.off')}</Select.Option>
<Select.Option value={1}>1 {t('settings.data.webdav.minutes')}</Select.Option>
<Select.Option value={1}>
1 {i18n.language === 'en-US' ? t('settings.data.webdav.minute') : t('settings.data.webdav.minutes')}
</Select.Option>
<Select.Option value={5}>5 {t('settings.data.webdav.minutes')}</Select.Option>
<Select.Option value={15}>15 {t('settings.data.webdav.minutes')}</Select.Option>
<Select.Option value={30}>30 {t('settings.data.webdav.minutes')}</Select.Option>
<Select.Option value={60}>60 {t('settings.data.webdav.minutes')}</Select.Option>
<Select.Option value={120}>120 {t('settings.data.webdav.minutes')}</Select.Option>
<Select.Option value={60}>
1 {i18n.language === 'en-US' ? t('settings.data.webdav.hour') : t('settings.data.webdav.hours')}
</Select.Option>
<Select.Option value={120}>2 {t('settings.data.webdav.hours')}</Select.Option>
<Select.Option value={360}>6 {t('settings.data.webdav.hours')}</Select.Option>
<Select.Option value={720}>12 {t('settings.data.webdav.hours')}</Select.Option>
<Select.Option value={1440}>24 {t('settings.data.webdav.hours')}</Select.Option>
</Select>
</SettingRow>
{webdavSync && syncInterval > 0 && (

View File

@@ -56,9 +56,9 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
<div style={{ marginBottom: 10 }}>{t('settings.models.topic_naming_prompt')}</div>
<Input.TextArea
rows={4}
value={topicNamingPrompt || t('prompts.summarize')}
value={topicNamingPrompt || t('prompts.title')}
onChange={(e) => dispatch(setTopicNamingPrompt(e.target.value.trim()))}
placeholder={t('prompts.summarize')}
placeholder={t('prompts.title')}
/>
{topicNamingPrompt && (
<Button style={{ marginTop: 10 }} onClick={handleReset}>

View File

@@ -68,6 +68,9 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
}
const onAddModel = (model: Model) => {
if (isEmpty(model.name)) {
return
}
addModel(model)
}
@@ -92,7 +95,7 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
description: model?.description,
owned_by: model?.owned_by
}))
.filter((model) => !isEmpty(model.id))
.filter((model) => !isEmpty(model.name))
)
setLoading(false)
} catch (error) {
@@ -151,7 +154,7 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
<ListItem key={model.id}>
<ListItemHeader>
<Avatar src={getModelLogo(model.id)} size={24}>
{model.name[0].toUpperCase()}
{model?.name?.[0]?.toUpperCase()}
</Avatar>
<ListItemName>
<Tooltip title={model.id} placement="top">

View File

@@ -67,8 +67,22 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
setApiHost(provider.apiHost)
}, [provider])
const onUpdateApiKey = () => updateProvider({ ...provider, apiKey })
const onUpdateApiHost = () => updateProvider({ ...provider, apiHost })
const onUpdateApiKey = () => {
if (apiKey.trim()) {
updateProvider({ ...provider, apiKey })
} else {
setApiKey(provider.apiKey)
}
}
const onUpdateApiHost = () => {
if (apiHost.trim()) {
updateProvider({ ...provider, apiHost })
} else {
setApiHost(provider.apiHost)
}
}
const onUpdateApiVersion = () => updateProvider({ ...provider, apiVersion })
const onManageModel = () => EditModelsPopup.show({ provider })
const onAddModel = () => AddModelPopup.show({ title: t('settings.models.add.add_model'), provider })
@@ -279,9 +293,9 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
<ModelListItem key={model.id}>
<ModelListHeader>
<Avatar src={getModelLogo(model.id)} size={22} style={{ marginRight: '8px' }}>
{model.name[0].toUpperCase()}
{model?.name?.[0]?.toUpperCase()}
</Avatar>
{model.name}
{model?.name}
<ModelTags model={model} />
<Popover content={modelTypeContent(model)} title={t('models.type.select')} trigger="click">
<SettingIcon />

View File

@@ -164,7 +164,7 @@ const ProviderListContainer = styled.div`
display: flex;
flex-direction: column;
min-width: calc(var(--settings-width) + 10px);
height: calc(100vh - var(--navbar-height));
height: calc(75vh - var(--navbar-height));
border-right: 0.5px solid var(--color-border);
`
@@ -180,19 +180,18 @@ const ProviderListItem = styled.div`
display: flex;
flex-direction: row;
align-items: center;
padding: 5px 10px;
padding: 8px 8px;
width: 100%;
cursor: grab;
border-radius: var(--list-item-border-radius);
border-radius: 8px;
font-size: 14px;
transition: all 0.2s ease-in-out;
border: 0.5px solid transparent;
&:hover {
background: var(--color-background-soft);
background: var(--color-primary-mute);
}
&.active {
background: var(--color-background-soft);
border: 0.5px solid var(--color-border);
background: var(--color-primary-mute);
color: var(--color-primary);
font-weight: bold !important;
}
`

View File

@@ -0,0 +1,90 @@
import { InfoCircleOutlined } from '@ant-design/icons'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useSettings } from '@renderer/hooks/useSettings'
import { useAppDispatch } from '@renderer/store'
import { setClickTrayToShowQuickAssistant, setEnableQuickAssistant } from '@renderer/store/settings'
import HomeWindow from '@renderer/windows/mini/home/HomeWindow'
import { Switch, Tooltip } from 'antd'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import { SettingContainer, SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '.'
const QuickAssistantSettings: FC = () => {
const { t } = useTranslation()
const { theme } = useTheme()
const { enableQuickAssistant, clickTrayToShowQuickAssistant, setTray } = useSettings()
const dispatch = useAppDispatch()
const handleEnableQuickAssistant = async (enable: boolean) => {
dispatch(setEnableQuickAssistant(enable))
await window.api.config.set('enableQuickAssistant', enable)
window.api.restartTray()
const disable = !enable
disable && window.api.miniWindow.close()
if (enable && !clickTrayToShowQuickAssistant) {
window.message.info({
content: t('settings.quickAssistant.use_shortcut_to_show'),
duration: 4,
icon: <InfoCircleOutlined />,
key: 'quick-assistant-info'
})
}
if (enable && clickTrayToShowQuickAssistant) {
setTray(true)
}
}
const handleClickTrayToShowQuickAssistant = async (checked: boolean) => {
dispatch(setClickTrayToShowQuickAssistant(checked))
await window.api.config.set('clickTrayToShowQuickAssistant', checked)
checked && setTray(true)
}
return (
<SettingContainer theme={theme}>
<SettingGroup theme={theme}>
<SettingTitle>{t('settings.quickAssistant.title')}</SettingTitle>
<SettingDivider />
<SettingRow>
<SettingRowTitle style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span>{t('settings.quickAssistant.enable_quick_assistant')}</span>
<Tooltip title={t('settings.quickAssistant.use_shortcut_to_show')} placement="right">
<InfoCircleOutlined style={{ cursor: 'pointer' }} />
</Tooltip>
</SettingRowTitle>
<Switch checked={enableQuickAssistant} onChange={handleEnableQuickAssistant} />
</SettingRow>
{enableQuickAssistant && (
<>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.quickAssistant.click_tray_to_show')}</SettingRowTitle>
<Switch checked={clickTrayToShowQuickAssistant} onChange={handleClickTrayToShowQuickAssistant} />
</SettingRow>
</>
)}
</SettingGroup>
{enableQuickAssistant && (
<AssistantContainer>
<HomeWindow />
</AssistantContainer>
)}
</SettingContainer>
)
}
const AssistantContainer = styled.div`
width: 100%;
height: 460px;
background-color: var(--color-background);
border-radius: 10px;
border: 0.5px solid var(--color-border);
margin: 0 auto;
overflow: hidden;
`
export default QuickAssistantSettings

View File

@@ -3,14 +3,14 @@ import {
InfoCircleOutlined,
LayoutOutlined,
MacCommandOutlined,
RocketOutlined,
SaveOutlined,
SettingOutlined
} from '@ant-design/icons'
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
import { isLocalAi } from '@renderer/config/env'
import { FC } from 'react'
import { Breadcrumb, Button, Menu } from 'antd'
import { FC, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Link, Route, Routes, useLocation } from 'react-router-dom'
import styled from 'styled-components'
import AboutSettings from './AboutSettings'
@@ -19,89 +19,148 @@ import DisplaySettings from './DisplaySettings/DisplaySettings'
import GeneralSettings from './GeneralSettings'
import ModelSettings from './ModalSettings/ModelSettings'
import ProvidersList from './ProviderSettings'
import QuickAssistantSettings from './QuickAssistantSettings'
import ShortcutSettings from './ShortcutSettings'
const SettingsPage: FC = () => {
const { pathname } = useLocation()
const { t } = useTranslation()
export type SettingsTab =
| 'provider'
| 'model'
| 'general'
| 'display'
| 'data'
| 'quickAssistant'
| 'shortcut'
| 'about'
const isRoute = (path: string): string => (pathname.startsWith(path) ? 'active' : '')
return (
<Container>
<Navbar>
<NavbarCenter style={{ borderRight: 'none' }}>{t('settings.title')}</NavbarCenter>
</Navbar>
<ContentContainer id="content-container">
<SettingMenus>
{!isLocalAi && (
<>
<MenuItemLink to="/settings/provider">
<MenuItem className={isRoute('/settings/provider')}>
<CloudOutlined />
{t('settings.provider.title')}
</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/general">
<MenuItem className={isRoute('/settings/general')}>
<SettingOutlined />
{t('settings.general')}
</MenuItem>
</MenuItemLink>
<MenuItemLink to="/settings/display">
<MenuItem className={isRoute('/settings/display')}>
<LayoutOutlined />
{t('settings.display.title')}
</MenuItem>
</MenuItemLink>
<MenuItemLink to="/settings/shortcut">
<MenuItem className={isRoute('/settings/shortcut')}>
<MacCommandOutlined />
{t('settings.shortcuts.title')}
</MenuItem>
</MenuItemLink>
<MenuItemLink to="/settings/data">
<MenuItem className={isRoute('/settings/data')}>
<SaveOutlined />
{t('settings.data.title')}
</MenuItem>
</MenuItemLink>
<MenuItemLink to="/settings/about">
<MenuItem className={isRoute('/settings/about')}>
<InfoCircleOutlined />
{t('settings.about')}
</MenuItem>
</MenuItemLink>
</SettingMenus>
<SettingContent>
<Routes>
<Route path="provider" element={<ProvidersList />} />
<Route path="model" element={<ModelSettings />} />
<Route path="general/*" element={<GeneralSettings />} />
<Route path="display" element={<DisplaySettings />} />
<Route path="data/*" element={<DataSettings />} />
<Route path="shortcut" element={<ShortcutSettings />} />
<Route path="about" element={<AboutSettings />} />
</Routes>
</SettingContent>
</ContentContainer>
</Container>
)
interface Props {
activeTab?: SettingsTab
onTabChange?: (tab: SettingsTab) => void
}
interface MenuItem {
label: string
icon: React.ReactNode
key: string
enabled: boolean
}
const Container = styled.div`
display: flex;
flex-direction: column;
flex: 1;
`
const SettingsPage: FC<Props> = (props) => {
const { t } = useTranslation()
const [collapsed, setCollapsed] = useState(false)
const activeTab = props.activeTab || 'provider'
const settingMenus = useMemo<MenuItem[]>(
() => [
{
label: t('settings.provider.title'),
icon: <CloudOutlined />,
key: 'provider',
enabled: !isLocalAi
},
{
label: t('settings.model'),
icon: <i className="iconfont icon-ai-model" />,
key: 'model',
enabled: !isLocalAi
},
{
label: t('settings.general'),
icon: <SettingOutlined />,
key: 'general',
enabled: true
},
{
label: t('settings.display.title'),
icon: <LayoutOutlined />,
key: 'display',
enabled: true
},
{
label: t('settings.shortcuts.title'),
icon: <MacCommandOutlined />,
key: 'shortcut',
enabled: true
},
{
label: t('settings.quickAssistant.title'),
icon: <RocketOutlined />,
key: 'quickAssistant',
enabled: true
},
{
label: t('settings.data.title'),
icon: <SaveOutlined />,
key: 'data',
enabled: true
},
{
label: t('settings.about'),
icon: <InfoCircleOutlined />,
key: 'about',
enabled: true
}
],
[t]
)
const breadcrumbItems = useMemo(() => {
return [
{
title: t('settings.title')
},
{
title: settingMenus.find((item) => item.key === activeTab)?.label
}
]
}, [t, activeTab, settingMenus])
const renderContent = () => {
switch (activeTab) {
case 'provider':
return <ProvidersList />
case 'model':
return <ModelSettings />
case 'general':
return <GeneralSettings />
case 'display':
return <DisplaySettings />
case 'data':
return <DataSettings />
case 'quickAssistant':
return <QuickAssistantSettings />
case 'shortcut':
return <ShortcutSettings />
case 'about':
return <AboutSettings />
default:
return <GeneralSettings />
}
}
return (
<ContentContainer>
<MenuContainer $isCollapsed={collapsed}>
<Title>{t('settings.title')}</Title>
<Menu
mode="inline"
onClick={(e) => props.onTabChange?.(e.key as SettingsTab)}
selectedKeys={[activeTab]}
items={settingMenus.filter((item) => item.enabled)}
inlineCollapsed={collapsed}
/>
</MenuContainer>
<SettingContent>
<SettingHeader>
<CollapseButton shape="circle" type="text" onClick={() => setCollapsed(!collapsed)} $isCollapsed={collapsed}>
<i className="iconfont icon-hide-sidebar" />
</CollapseButton>
<Breadcrumb items={breadcrumbItems} />
</SettingHeader>
{renderContent()}
</SettingContent>
</ContentContainer>
)
}
const ContentContainer = styled.div`
display: flex;
@@ -109,57 +168,40 @@ const ContentContainer = styled.div`
flex-direction: row;
`
const SettingMenus = styled.ul`
display: flex;
flex-direction: column;
min-width: var(--settings-width);
border-right: 0.5px solid var(--color-border);
padding: 10px;
user-select: none;
`
const MenuItemLink = styled(Link)`
text-decoration: none;
color: var(--color-text-1);
margin-bottom: 5px;
`
const MenuItem = styled.li`
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
padding: 6px 10px;
width: 100%;
cursor: pointer;
border-radius: var(--list-item-border-radius);
font-weight: 500;
transition: all 0.2s ease-in-out;
border: 0.5px solid transparent;
.anticon {
font-size: 16px;
opacity: 0.8;
const MenuContainer = styled.div<{ $isCollapsed: boolean }>`
width: ${({ $isCollapsed }) => ($isCollapsed ? '80px' : '160px')};
background-color: var(--color-background-mute);
transition: width 0.3s ease-in-out;
position: relative;
.ant-menu-light {
background-color: var(--color-background-mute);
}
`
const CollapseButton = styled(Button)<{ $isCollapsed: boolean }>`
color: var(--color-icon);
.iconfont {
font-size: 18px;
line-height: 18px;
opacity: 0.7;
margin-left: -1px;
}
&:hover {
background: var(--color-background-soft);
}
&.active {
background: var(--color-background-soft);
border: 0.5px solid var(--color-border);
transform: rotate(${({ $isCollapsed }) => ($isCollapsed ? '180deg' : '0deg')});
}
`
const Title = styled.div`
font-size: 16px;
font-weight: 600;
padding: 16px 24px;
`
const SettingContent = styled.div`
display: flex;
height: 100%;
flex: 1;
border-right: 0.5px solid var(--color-border);
`
const SettingHeader = styled.div`
padding: 4px 8px;
border-bottom: 0.5px solid var(--color-border);
display: flex;
align-items: center;
gap: 8px;
`
export default SettingsPage

View File

@@ -2,7 +2,8 @@ import { ClearOutlined, UndoOutlined } from '@ant-design/icons'
import { HStack } from '@renderer/components/Layout'
import { isMac, isWindows } from '@renderer/config/constant'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { useShortcuts } from '@renderer/hooks/useShortcuts'
import { useAppDispatch } from '@renderer/store'
import { initialState, resetShortcuts, toggleShortcut, updateShortcut } from '@renderer/store/shortcuts'
import { Shortcut } from '@renderer/types'
import { Button, Input, InputRef, Switch, Table as AntTable, Tooltip } from 'antd'
@@ -17,7 +18,7 @@ const ShortcutSettings: FC = () => {
const { t } = useTranslation()
const { theme } = useTheme()
const dispatch = useAppDispatch()
const shortcuts = useAppSelector((state) => state.shortcuts.shortcuts)
const { shortcuts } = useShortcuts()
const inputRefs = useRef<Record<string, InputRef>>({})
const [editingKey, setEditingKey] = useState<string | null>(null)

View File

@@ -7,12 +7,11 @@ export const SettingContainer = styled.div<{ theme?: ThemeMode }>`
display: flex;
flex-direction: column;
flex: 1;
height: calc(100vh - var(--navbar-height));
padding: 20px;
height: calc(75vh - var(--navbar-height));
padding: 16px;
padding-top: 15px;
overflow-y: scroll;
font-family: Ubuntu;
background: ${(props) => (props.theme === 'dark' ? 'transparent' : 'var(--color-background-soft)')};
&::-webkit-scrollbar {
display: none;

View File

@@ -1,7 +1,9 @@
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 SettingsPopup from '@renderer/components/Popups/SettingsPopup'
import { isLocalAi } from '@renderer/config/env'
import { TranslateLanguageOptions } from '@renderer/config/translate'
import db from '@renderer/databases'
import { useDefaultModel } from '@renderer/hooks/useAssistant'
import { fetchTranslate } from '@renderer/services/ApiService'
@@ -33,64 +35,6 @@ const TranslatePage: FC = () => {
_result = result
_targetLanguage = targetLanguage
const languageOptions = [
{
value: 'english',
label: t('languages.english'),
emoji: '🇬🇧'
},
{
value: 'chinese',
label: t('languages.chinese'),
emoji: '🇨🇳'
},
{
value: 'chinese-traditional',
label: t('languages.chinese-traditional'),
emoji: '🇭🇰'
},
{
value: 'japanese',
label: t('languages.japanese'),
emoji: '🇯🇵'
},
{
value: 'korean',
label: t('languages.korean'),
emoji: '🇰🇷'
},
{
value: 'russian',
label: t('languages.russian'),
emoji: '🇷🇺'
},
{
value: 'spanish',
label: t('languages.spanish'),
emoji: '🇪🇸'
},
{
value: 'french',
label: t('languages.french'),
emoji: '🇫🇷'
},
{
value: 'italian',
label: t('languages.italian'),
emoji: '🇮🇹'
},
{
value: 'portuguese',
label: t('languages.portuguese'),
emoji: '🇵🇹'
},
{
value: 'arabic',
label: t('languages.arabic'),
emoji: '🇸🇦'
}
]
const onTranslate = async () => {
if (!text.trim()) {
return
@@ -112,15 +56,14 @@ const TranslatePage: FC = () => {
content: text,
assistantId: assistant.id,
topicId: uuid(),
modelId: translateModel.id,
model: translateModel,
createdAt: new Date().toISOString(),
type: 'text',
status: 'sending'
}
setLoading(true)
const translateText = await fetchTranslate({ message, assistant })
setResult(translateText)
await fetchTranslate({ message, assistant, onResponse: (text) => setResult(text) })
setLoading(false)
}
@@ -148,9 +91,10 @@ const TranslatePage: FC = () => {
if (translateModel) {
return (
<Link to="/settings/model" style={{ color: 'var(--color-text-2)' }}>
<SettingOutlined />
</Link>
<SettingsPopup
activeTab="model"
actionButton={<Button type="text" shape="circle" icon={<SettingOutlined />} />}
/>
)
}
@@ -187,7 +131,7 @@ const TranslatePage: FC = () => {
value={targetLanguage}
style={{ width: 180 }}
optionFilterProp="label"
options={languageOptions}
options={TranslateLanguageOptions}
onChange={(value) => {
setTargetLanguage(value)
db.settings.put({ id: 'translate:target:language', value })

View File

@@ -20,8 +20,8 @@ export default class AiProvider {
return this.sdk.completions({ messages, assistant, onChunk, onFilterMessages })
}
public async translate(message: Message, assistant: Assistant): Promise<string> {
return this.sdk.translate(message, assistant)
public async translate(message: Message, assistant: Assistant, onResponse?: (text: string) => void): Promise<string> {
return this.sdk.translate(message, assistant, onResponse)
}
public async summaries(messages: Message[], assistant: Assistant): Promise<string> {

View File

@@ -149,7 +149,7 @@ export default class AnthropicProvider extends BaseProvider {
})
}
public async translate(message: Message, assistant: Assistant) {
public async translate(message: Message, assistant: Assistant, onResponse?: (text: string) => void) {
const defaultModel = getDefaultModel()
const model = assistant.model || defaultModel
const messages = [
@@ -157,16 +157,33 @@ export default class AnthropicProvider extends BaseProvider {
{ role: 'user', content: message.content }
]
const response = await this.sdk.messages.create({
const stream = onResponse ? true : false
const body: MessageCreateParamsNonStreaming = {
model: model.id,
messages: messages.filter((m) => m.role === 'user') as MessageParam[],
max_tokens: 4096,
temperature: assistant?.settings?.temperature,
system: assistant.prompt,
stream: false
})
system: assistant.prompt
}
return response.content[0].type === 'text' ? response.content[0].text : ''
if (!stream) {
const response = await this.sdk.messages.create({ ...body, stream: false })
return response.content[0].type === 'text' ? response.content[0].text : ''
}
let text = ''
return new Promise<string>((resolve, reject) => {
this.sdk.messages
.stream({ ...body, stream: true })
.on('text', (_text) => {
text += _text
onResponse?.(text)
})
.on('finalMessage', () => resolve(text))
.on('error', (error) => reject(error))
})
}
public async summaries(messages: Message[], assistant: Assistant): Promise<string> {
@@ -190,7 +207,7 @@ export default class AnthropicProvider extends BaseProvider {
const systemMessage = {
role: 'system',
content: (getStoreSetting('topicNamingPrompt') as string) || i18n.t('prompts.summarize')
content: (getStoreSetting('topicNamingPrompt') as string) || i18n.t('prompts.title')
}
const userMessage = {

View File

@@ -3,7 +3,7 @@ import { getOllamaKeepAliveTime } from '@renderer/hooks/useOllama'
import { getKnowledgeReferences } from '@renderer/services/KnowledgeService'
import store from '@renderer/store'
import { Assistant, GenerateImageParams, Message, Model, Provider, Suggestion } from '@renderer/types'
import { delay, isJSON } from '@renderer/utils'
import { delay, isJSON, parseJSON } from '@renderer/utils'
import OpenAI from 'openai'
import { CompletionsParams } from '.'
@@ -20,7 +20,7 @@ export default abstract class BaseProvider {
}
abstract completions({ messages, assistant, onChunk, onFilterMessages }: CompletionsParams): Promise<void>
abstract translate(message: Message, assistant: Assistant): Promise<string>
abstract translate(message: Message, assistant: Assistant, onResponse?: (text: string) => void): Promise<string>
abstract summaries(messages: Message[], assistant: Assistant): Promise<string>
abstract suggestions(messages: Message[], assistant: Assistant): Promise<Suggestion[]>
abstract generateText({ prompt, content }: { prompt: string; content: string }): Promise<string>
@@ -98,9 +98,15 @@ export default abstract class BaseProvider {
}
if (param.type === 'json') {
const value = param.value as string
return { ...acc, [param.name]: isJSON(value) ? JSON.parse(value) : value }
return {
...acc,
[param.name]: isJSON(value) ? parseJSON(value) : value
}
}
return {
...acc,
[param.name]: param.value
}
return { ...acc, [param.name]: param.value }
}, {}) || {}
)
}

View File

@@ -7,6 +7,7 @@ import {
InlineDataPart,
Part,
RequestOptions,
SafetySetting,
TextPart
} from '@google/generative-ai'
import { isWebSearchModel } from '@renderer/config/models'
@@ -112,6 +113,35 @@ export default class GeminiProvider extends BaseProvider {
}
}
private getSafetySettings(modelId: string): SafetySetting[] {
const safetyThreshold = modelId.includes('gemini-2.0-flash-exp')
? ('OFF' as HarmBlockThreshold)
: HarmBlockThreshold.BLOCK_NONE
return [
{
category: HarmCategory.HARM_CATEGORY_HATE_SPEECH,
threshold: safetyThreshold
},
{
category: HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT,
threshold: safetyThreshold
},
{
category: HarmCategory.HARM_CATEGORY_HARASSMENT,
threshold: safetyThreshold
},
{
category: HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT,
threshold: safetyThreshold
},
{
category: 'HARM_CATEGORY_CIVIC_INTEGRITY' as HarmCategory,
threshold: safetyThreshold
}
]
}
public async completions({ messages, assistant, onChunk, onFilterMessages }: CompletionsParams) {
const defaultModel = getDefaultModel()
const model = assistant.model || defaultModel
@@ -138,21 +168,13 @@ export default class GeminiProvider extends BaseProvider {
systemInstruction: assistant.prompt,
// @ts-ignore googleSearch is not a valid tool for Gemini
tools: assistant.enableWebSearch && isWebSearchModel(model) ? [{ googleSearch: {} }] : undefined,
safetySettings: this.getSafetySettings(model.id),
generationConfig: {
maxOutputTokens: maxTokens,
temperature: assistant?.settings?.temperature,
topP: assistant?.settings?.topP,
...this.getCustomParameters(assistant)
},
safetySettings: [
{ category: HarmCategory.HARM_CATEGORY_HATE_SPEECH, threshold: HarmBlockThreshold.BLOCK_NONE },
{
category: HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT,
threshold: HarmBlockThreshold.BLOCK_NONE
},
{ category: HarmCategory.HARM_CATEGORY_HARASSMENT, threshold: HarmBlockThreshold.BLOCK_NONE },
{ category: HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, threshold: HarmBlockThreshold.BLOCK_NONE }
]
}
},
this.requestOptions
)
@@ -208,7 +230,7 @@ export default class GeminiProvider extends BaseProvider {
}
}
async translate(message: Message, assistant: Assistant) {
async translate(message: Message, assistant: Assistant, onResponse?: (text: string) => void) {
const defaultModel = getDefaultModel()
const { maxTokens } = getAssistantSettings(assistant)
const model = assistant.model || defaultModel
@@ -225,9 +247,21 @@ export default class GeminiProvider extends BaseProvider {
this.requestOptions
)
const { response } = await geminiModel.generateContent(message.content)
if (!onResponse) {
const { response } = await geminiModel.generateContent(message.content)
return response.text()
}
return response.text()
const response = await geminiModel.generateContentStream(message.content)
let text = ''
for await (const chunk of response.stream) {
text += chunk.text()
onResponse(text)
}
return text
}
public async summaries(messages: Message[], assistant: Assistant): Promise<string> {
@@ -247,7 +281,7 @@ export default class GeminiProvider extends BaseProvider {
const systemMessage = {
role: 'system',
content: (getStoreSetting('topicNamingPrompt') as string) || i18n.t('prompts.summarize')
content: (getStoreSetting('topicNamingPrompt') as string) || i18n.t('prompts.title')
}
const userMessage = {

View File

@@ -117,6 +117,20 @@ export default class OpenAIProvider extends BaseProvider {
} as ChatCompletionMessageParam
}
private getTemperature(assistant: Assistant, model: Model) {
const isOpenAIo1 = model.id.startsWith('o1')
if (isOpenAIo1) {
return undefined
}
if (model.provider === 'deepseek' && model.id === 'deepseek-reasoner') {
return undefined
}
return assistant?.settings?.temperature
}
async completions({ messages, assistant, onChunk, onFilterMessages }: CompletionsParams): Promise<void> {
const defaultModel = getDefaultModel()
const model = assistant.model || defaultModel
@@ -128,11 +142,17 @@ export default class OpenAIProvider extends BaseProvider {
const _messages = filterContextMessages(takeRight(messages, contextCount + 1))
onFilterMessages(_messages)
if (model.id === 'deepseek-reasoner') {
if (_messages[0]?.role !== 'user') {
userMessages.push({ role: 'user', content: '' })
}
}
for (const message of _messages) {
userMessages.push(await this.getMessageParam(message, model))
}
const isOpenAIo1 = model.id.includes('o1-')
const isOpenAIo1 = model.id.startsWith('o1')
const isSupportStreamOutput = () => {
if (this.provider.id === 'github' && isOpenAIo1) {
@@ -142,6 +162,7 @@ export default class OpenAIProvider extends BaseProvider {
}
let time_first_token_millsec = 0
let time_first_content_millsec = 0
const start_time_millsec = new Date().getTime()
// @ts-ignore key is not typed
@@ -150,7 +171,7 @@ export default class OpenAIProvider extends BaseProvider {
messages: [isOpenAIo1 ? undefined : systemMessage, ...userMessages].filter(
Boolean
) as ChatCompletionMessageParam[],
temperature: isOpenAIo1 ? 1 : assistant?.settings?.temperature,
temperature: this.getTemperature(assistant, model),
top_p: assistant?.settings?.topP,
max_tokens: maxTokens,
keep_alive: this.keepAliveTime,
@@ -176,23 +197,34 @@ export default class OpenAIProvider extends BaseProvider {
if (window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED)) {
break
}
if (time_first_token_millsec == 0) {
time_first_token_millsec = new Date().getTime() - start_time_millsec
}
if (time_first_content_millsec == 0 && chunk.choices[0]?.delta?.content) {
time_first_content_millsec = new Date().getTime()
}
const time_completion_millsec = new Date().getTime() - start_time_millsec
const time_thinking_millsec = time_first_content_millsec ? time_first_content_millsec - start_time_millsec : 0
onChunk({
text: chunk.choices[0]?.delta?.content || '',
// @ts-ignore key is not typed
reasoning_content: chunk.choices[0]?.delta?.reasoning_content || '',
usage: chunk.usage,
metrics: {
completion_tokens: chunk.usage?.completion_tokens,
time_completion_millsec,
time_first_token_millsec
time_first_token_millsec,
time_thinking_millsec
}
})
}
}
async translate(message: Message, assistant: Assistant) {
async translate(message: Message, assistant: Assistant, onResponse?: (text: string) => void) {
const defaultModel = getDefaultModel()
const model = assistant.model || defaultModel
const messages = [
@@ -200,16 +232,41 @@ export default class OpenAIProvider extends BaseProvider {
{ role: 'user', content: message.content }
]
const isOpenAIo1 = model.id.startsWith('o1')
const isSupportedStreamOutput = () => {
if (!onResponse) {
return false
}
if (this.provider.id === 'github' && isOpenAIo1) {
return false
}
return true
}
const stream = isSupportedStreamOutput()
// @ts-ignore key is not typed
const response = await this.sdk.chat.completions.create({
model: model.id,
messages: messages as ChatCompletionMessageParam[],
stream: false,
stream,
keep_alive: this.keepAliveTime,
temperature: assistant?.settings?.temperature
})
return response.choices[0].message?.content || ''
if (!stream) {
return response.choices[0].message?.content || ''
}
let text = ''
for await (const chunk of response) {
text += chunk.choices[0]?.delta?.content || ''
onResponse?.(text)
}
return text
}
public async summaries(messages: Message[], assistant: Assistant): Promise<string> {
@@ -229,7 +286,7 @@ export default class OpenAIProvider extends BaseProvider {
const systemMessage = {
role: 'system',
content: getStoreSetting('topicNamingPrompt') || i18n.t('prompts.summarize')
content: getStoreSetting('topicNamingPrompt') || i18n.t('prompts.title')
}
const userMessage = {

View File

@@ -4,6 +4,7 @@ import AnthropicProvider from './AnthropicProvider'
import BaseProvider from './BaseProvider'
import GeminiProvider from './GeminiProvider'
import OpenAIProvider from './OpenAIProvider'
import QwenLMProvider from './QwenLMProvider'
export default class ProviderFactory {
static create(provider: Provider): BaseProvider {
@@ -12,6 +13,8 @@ export default class ProviderFactory {
return new AnthropicProvider(provider)
case 'gemini':
return new GeminiProvider(provider)
case 'qwenlm':
return new QwenLMProvider(provider)
default:
return new OpenAIProvider(provider)
}

View File

@@ -0,0 +1,160 @@
import { getOpenAIWebSearchParams, isVisionModel } from '@renderer/config/models'
import { getAssistantSettings, getDefaultModel } from '@renderer/services/AssistantService'
import { EVENT_NAMES } from '@renderer/services/EventService'
import { filterContextMessages } from '@renderer/services/MessagesService'
import { FileTypes, Message, Model, Provider } from '@renderer/types'
import { takeRight } from 'lodash'
import OpenAI from 'openai'
import { ChatCompletionContentPart, ChatCompletionMessageParam } from 'openai/resources'
import { CompletionsParams } from '.'
import OpenAIProvider from './OpenAIProvider'
class QwenLMProvider extends OpenAIProvider {
constructor(provider: Provider) {
super(provider)
}
private async getMessageParams(
message: Message,
model: Model
): Promise<OpenAI.Chat.Completions.ChatCompletionMessageParam> {
const isVision = isVisionModel(model)
const content = await this.getMessageContent(message)
if (!message.files) {
return {
role: message.role,
content
}
}
const parts: ChatCompletionContentPart[] = [
{
type: 'text',
text: content
}
]
const qwenlm_image_url: { type: string; image: string }[] = []
for (const file of message.files || []) {
if (file.type === FileTypes.IMAGE && isVision) {
const image = await window.api.file.binaryFile(file.id + file.ext)
const imageId = await this.uploadImageToQwenLM(image.data, file.origin_name, image.mime)
qwenlm_image_url.push({
type: 'image',
image: imageId
})
}
if ([FileTypes.TEXT, FileTypes.DOCUMENT].includes(file.type)) {
const fileContent = await (await window.api.file.read(file.id + file.ext)).trim()
parts.push({
type: 'text',
text: file.origin_name + '\n' + fileContent
})
}
}
return {
role: message.role,
content: [...parts, ...qwenlm_image_url]
} as ChatCompletionMessageParam
}
private async uploadImageToQwenLM(image_file: Buffer, file_name: string, mime: string): Promise<string> {
try {
// 创建 FormData
const formData = new FormData()
formData.append('file', new Blob([image_file], { type: mime }), file_name)
// 发送上传请求
const response = await fetch(`${this.provider.apiHost}v1/files/`, {
method: 'POST',
headers: {
Authorization: `Bearer ${this.apiKey}`
},
body: formData
})
if (!response.ok) {
throw new Error('Failed to upload image to QwenLM')
}
const data = await response.json()
return data.id
} catch (error) {
console.error('Error uploading image to QwenLM:', error)
throw error
}
}
async completions({ messages, assistant, onChunk, onFilterMessages }: CompletionsParams): 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[] = []
const _messages = filterContextMessages(takeRight(messages, contextCount + 1))
onFilterMessages(_messages)
if (_messages[0]?.role !== 'user') {
userMessages.push({ role: 'user', content: '' })
}
for (const message of _messages) {
userMessages.push(await this.getMessageParams(message, model))
}
let time_first_token_millsec = 0
const start_time_millsec = new Date().getTime()
// @ts-ignore key is not typed
const stream = await this.sdk.chat.completions.create({
model: model.id,
messages: [systemMessage, ...userMessages].filter(Boolean) as ChatCompletionMessageParam[],
temperature: assistant?.settings?.temperature,
top_p: assistant?.settings?.topP,
max_tokens: maxTokens,
stream: true,
...(assistant.enableWebSearch ? getOpenAIWebSearchParams(model) : {}),
...this.getCustomParameters(assistant)
})
let accumulatedText = ''
for await (const chunk of stream) {
if (window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED)) {
break
}
if (time_first_token_millsec == 0) {
time_first_token_millsec = new Date().getTime() - start_time_millsec
}
// 获取当前块的完整内容
const currentContent = chunk.choices[0]?.delta?.content || ''
// 如果内容与累积的内容不同,则只发送增量部分
if (currentContent !== accumulatedText) {
const deltaText = currentContent.slice(accumulatedText.length)
accumulatedText = currentContent // 更新累积的文本
const time_completion_millsec = new Date().getTime() - start_time_millsec
onChunk({
text: deltaText,
usage: chunk.usage,
metrics: {
completion_tokens: chunk.usage?.completion_tokens,
time_completion_millsec,
time_first_token_millsec
}
})
}
}
}
}
export default QwenLMProvider

View File

@@ -1,8 +1,9 @@
import type { GroundingMetadata } from '@google/generative-ai'
import type { Assistant, Metrics } from '@renderer/types'
import type { Assistant, Message, Metrics } from '@renderer/types'
interface ChunkCallbackData {
text?: string
reasoning_content?: string
usage?: OpenAI.Completions.CompletionUsage
metrics?: Metrics
search?: GroundingMetadata
@@ -11,6 +12,6 @@ interface ChunkCallbackData {
interface CompletionsParams {
messages: Message[]
assistant: Assistant
onChunk: ({ text, usage, metrics, search }: ChunkCallbackData) => void
onChunk: ({ text, reasoning_content, usage, metrics, search }: ChunkCallbackData) => void
onFilterMessages: (messages: Message[]) => void
}

View File

@@ -1,7 +1,7 @@
import i18n from '@renderer/i18n'
import store from '@renderer/store'
import { setGenerating } from '@renderer/store/runtime'
import { Assistant, Message, Model, Provider, Suggestion, Topic } from '@renderer/types'
import { Assistant, Message, Model, Provider, Suggestion } from '@renderer/types'
import { isEmpty } from 'lodash'
import AiProvider from '../providers/AiProvider'
@@ -13,7 +13,7 @@ import {
getTranslateModel
} from './AssistantService'
import { EVENT_NAMES, EventEmitter } from './EventService'
import { filterMessages } from './MessagesService'
import { filterMessages, filterUsefulMessages } from './MessagesService'
import { estimateMessagesUsage } from './TokenService'
export async function fetchChatCompletion({
@@ -24,7 +24,6 @@ export async function fetchChatCompletion({
}: {
message: Message
messages: Message[]
topic: Topic
assistant: Assistant
onResponse: (message: Message) => void
}) {
@@ -54,14 +53,18 @@ export async function fetchChatCompletion({
let _messages: Message[] = []
await AI.completions({
messages,
messages: filterUsefulMessages(messages),
assistant,
onFilterMessages: (messages) => (_messages = messages),
onChunk: ({ text, usage, metrics, search }) => {
onChunk: ({ text, reasoning_content, usage, metrics, search }) => {
message.content = message.content + text || ''
message.usage = usage
message.metrics = metrics
if (reasoning_content) {
message.reasoning_content = (message.reasoning_content || '') + reasoning_content
}
if (search) {
message.metadata = { groundingMetadata: search }
}
@@ -102,7 +105,13 @@ export async function fetchChatCompletion({
return message
}
export async function fetchTranslate({ message, assistant }: { message: Message; assistant: Assistant }) {
interface FetchTranslateProps {
message: Message
assistant: Assistant
onResponse?: (text: string) => void
}
export async function fetchTranslate({ message, assistant, onResponse }: FetchTranslateProps) {
const model = getTranslateModel()
if (!model) {
@@ -118,7 +127,7 @@ export async function fetchTranslate({ message, assistant }: { message: Message;
const AI = new AiProvider(provider)
try {
return await AI.translate(message, assistant)
return await AI.translate(message, assistant, onResponse)
} catch (error: any) {
return ''
}

View File

@@ -133,7 +133,7 @@ export async function addAssistantMessagesToTopic({ assistant, topic }: { assist
topicId: topic.id,
createdAt: new Date().toISOString(),
status: 'success',
modelId: assistant.defaultModel?.id || defaultModel.id,
model: assistant.defaultModel || defaultModel,
type: 'text',
isPreset: true
}

View File

@@ -59,7 +59,7 @@ export async function reset() {
}
// 备份到 webdav
export async function backupToWebdav({ showMessage = true }: { showMessage?: boolean } = {}) {
export async function backupToWebdav({ showMessage = false }: { showMessage?: boolean } = {}) {
if (isManualBackupRunning) {
console.log('[Backup] Manual backup already in progress')
return
@@ -181,10 +181,8 @@ export function startAutoSync() {
try {
console.log('[AutoSync] Performing auto backup...')
await backupToWebdav({ showMessage: false })
window.message.success({ content: i18n.t('message.backup.success'), key: 'webdav-auto-sync' })
} catch (error) {
console.error('[AutoSync] Auto backup failed:', error)
window.message.error({ content: i18n.t('message.backup.failed'), key: 'webdav-auto-sync' })
} finally {
isAutoBackupRunning = false
scheduleNextBackup()

View File

@@ -4,6 +4,7 @@ export const EventEmitter = new Emittery()
export const EVENT_NAMES = {
SEND_MESSAGE: 'SEND_MESSAGE',
APPEND_MESSAGE: 'APPEND_MESSAGE',
RECEIVE_MESSAGE: 'RECEIVE_MESSAGE',
AI_AUTO_RENAME: 'AI_AUTO_RENAME',
CLEAR_MESSAGES: 'CLEAR_MESSAGES',

View File

@@ -22,7 +22,9 @@ export const getKnowledgeBaseParams = (base: KnowledgeBase): KnowledgeBaseParams
dimensions: base.dimensions,
apiKey: aiProvider.getApiKey() || 'secret',
apiVersion: provider.apiVersion,
baseURL: host
baseURL: host,
chunkSize: base.chunkSize,
chunkOverlap: base.chunkOverlap
}
}

View File

@@ -3,9 +3,9 @@ import { DEFAULT_CONTEXTCOUNT } from '@renderer/config/constant'
import { getTopicById } from '@renderer/hooks/useTopic'
import i18n from '@renderer/i18n'
import store from '@renderer/store'
import { Assistant, Message, Topic } from '@renderer/types'
import { Assistant, Message, Model, Topic } from '@renderer/types'
import { uuid } from '@renderer/utils'
import { isEmpty, takeRight } from 'lodash'
import { isEmpty, remove, takeRight } from 'lodash'
import { NavigateFunction } from 'react-router'
import { getAssistantById, getDefaultModel } from './AssistantService'
@@ -86,7 +86,7 @@ export function getUserMessage({
content: content || '',
assistantId: assistant.id,
topicId: topic.id,
modelId: model.id,
model,
createdAt: new Date().toISOString(),
type,
status: 'success'
@@ -103,9 +103,68 @@ export function getAssistantMessage({ assistant, topic }: { assistant: Assistant
content: '',
assistantId: assistant.id,
topicId: topic.id,
modelId: model.id,
model,
createdAt: new Date().toISOString(),
type: 'text',
status: 'sending'
}
}
export function filterUsefulMessages(messages: Message[]): Message[] {
const _messages = messages
const groupedMessages = getGroupedMessages(messages)
Object.entries(groupedMessages).forEach(([key, messages]) => {
if (key.startsWith('assistant')) {
const usefulMessage = messages.find((m) => m.useful === true)
if (usefulMessage) {
messages.forEach((m) => {
if (m.id !== usefulMessage.id) {
remove(_messages, (o) => o.id === m.id)
}
})
} else {
messages?.slice(0, -1).forEach((m) => {
remove(_messages, (o) => o.id === m.id)
})
}
}
})
while (_messages.length > 0 && _messages[_messages.length - 1].role === 'assistant') {
_messages.pop()
}
return _messages
}
export function getGroupedMessages(messages: Message[]): { [key: string]: (Message & { index: number })[] } {
const groups: { [key: string]: (Message & { index: number })[] } = {}
messages.forEach((message, index) => {
const key = message.askId ? 'assistant' + message.askId : 'user' + message.id
if (key && !groups[key]) {
groups[key] = []
}
groups[key].unshift({ ...message, index })
})
return groups
}
export function getMessageModelId(message: Message) {
return message?.model?.id || message.modelId
}
export function resetAssistantMessage(message: Message, model?: Model): Message {
return {
...message,
model: model || message.model,
content: '',
status: 'sending',
translatedContent: undefined,
reasoning_content: undefined,
usage: undefined,
metrics: undefined,
metadata: undefined,
useful: undefined
}
}

View File

@@ -15,3 +15,7 @@ export const hasModel = (m?: Model) => {
return allModels.find((model) => model.id === m?.id)
}
export function getModelName(model?: Model) {
return model?.name || model?.id || ''
}

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