Compare commits

...

45 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
74 changed files with 1617 additions and 485 deletions

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,10 +80,4 @@ afterPack: scripts/after-pack.js
afterSign: scripts/notarize.js
releaseInfo:
releaseNotes: |
新增快捷助手弹窗
翻译默认使用流输出
小程序弹窗顶部增加固定按钮 @ousugo
新增清除消息、清除上下文快捷键 @cljnnn
Gemini 安全设置更新 @magicdmer
智能体页面性能优化 @magicdmer
修复 WebDAV 不能自动备份问题
错误修复

View File

@@ -1,6 +1,6 @@
{
"name": "CherryStudio",
"version": "0.9.12",
"version": "0.9.17",
"private": true,
"description": "A powerful AI assistant for producer.",
"main": "./out/main/index.js",

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 = [

View File

@@ -10,12 +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()
@@ -31,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
}))
@@ -130,6 +133,9 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle('file:copy', fileManager.copyFile)
ipcMain.handle('file:binaryFile', fileManager.binaryFile)
// fs
ipcMain.handle('fs:read', FileService.readFile)
// minapp
ipcMain.handle('minapp', (_, args) => {
windowService.createMinappWindow({

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

@@ -388,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

@@ -14,7 +14,6 @@ export class WindowService {
private static instance: WindowService | null = null
private mainWindow: BrowserWindow | null = null
private miniWindow: BrowserWindow | null = null
private isQuitting: boolean = false
private wasFullScreen: boolean = false
private selectionMenuWindow: BrowserWindow | null = null
private lastSelectedText: string = ''
@@ -199,30 +198,25 @@ export class WindowService {
}
private setupWindowLifecycleEvents(mainWindow: BrowserWindow) {
// 监听应用退出事件
app.on('before-quit', () => {
this.isQuitting = true
})
mainWindow.on('close', (event) => {
const notInTray = !configManager.getTray()
// 如果已经触发退出,直接退出
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()
})
}

View File

@@ -56,6 +56,9 @@ declare global {
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>
}

View File

@@ -47,6 +47,9 @@ const api = {
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)
},

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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -234,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,
@@ -246,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,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

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

View File

@@ -253,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
},
{

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) {
@@ -441,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: [
@@ -758,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: [

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

@@ -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",
@@ -375,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",
@@ -613,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",

View File

@@ -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": "ミニアプリ",
@@ -374,6 +385,7 @@
"webdav.path.placeholder": "/backup",
"webdav.autoSync": "自動バックアップ",
"webdav.minutes": "分",
"webdav.hours": "時間",
"webdav.restore.button": "WebDAVから復元",
"webdav.title": "WebDAV",
"webdav.user": "WebDAVユーザー",
@@ -598,7 +610,10 @@
"model_info": "モデル情報",
"not_support": "ナレッジベースデータベースエンジンが更新されました。このナレッジベースはもうサポートされていません。新しいナレッジベースを作成してください",
"no_provider": "ナレッジベースモデルプロバイダーが設定されていません。ナレッジベースはもうサポートされていません。新しいナレッジベースを作成してください",
"source": "ソース"
"source": "ソース",
"chunk_size": "チャンクサイズ",
"chunk_overlap": "チャンクの重なり",
"not_set": "未設定"
},
"models": {
"pinned": "固定済み",

View File

@@ -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": "Встроенные приложения",
@@ -376,6 +386,7 @@
"webdav.path.placeholder": "/backup",
"webdav.autoSync": "Автоматическое резервное копирование",
"webdav.minutes": "минут",
"webdav.hours": "часов",
"webdav.restore.button": "Восстановление с WebDAV",
"webdav.title": "WebDAV",
"webdav.user": "Пользователь WebDAV",
@@ -612,7 +623,10 @@
"model_info": "Модель информации",
"not_support": "База знаний базы данных движок обновлен, база знаний больше не поддерживается, пожалуйста, создайте новую базу знаний",
"no_provider": "База знаний модель поставщика не настроена, база знаний больше не поддерживается, пожалуйста, создайте новую базу знаний",
"source": "Источник"
"source": "Источник",
"chunk_size": "Размер фрагмента",
"chunk_overlap": "Перекрытие фрагмента",
"not_set": "Не установлено"
},
"models": {
"pinned": "Закреплено",

View File

@@ -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": "小程序",
@@ -377,6 +387,7 @@
"webdav.path.placeholder": "/backup",
"webdav.autoSync": "自动备份",
"webdav.minutes": "分钟",
"webdav.hours": "小时",
"webdav.restore.button": "从 WebDAV 恢复",
"webdav.title": "WebDAV",
"webdav.user": "WebDAV 用户名",
@@ -601,7 +612,10 @@
"model_info": "模型信息",
"not_support": "知识库数据库引擎已更新,该知识库将不再支持,请重新创建知识库",
"no_provider": "知识库模型服务商丢失,该知识库将不再支持,请重新创建知识库",
"source": "来源"
"source": "来源",
"chunk_size": "分段大小",
"chunk_overlap": "重叠大小",
"not_set": "未设置"
},
"models": {
"pinned": "已固定",

View File

@@ -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": "小程序",
@@ -376,6 +386,7 @@
"webdav.path.placeholder": "/backup",
"webdav.autoSync": "自動備份",
"webdav.minutes": "分鐘",
"webdav.hours": "小時",
"webdav.restore.button": "從 WebDAV 恢復",
"webdav.title": "WebDAV",
"webdav.user": "WebDAV 使用者名稱",
@@ -600,7 +611,10 @@
"model_info": "模型信息",
"not_support": "知識庫數據庫引擎已更新,該知識庫將不再支持,請重新創建知識庫",
"no_provider": "知識庫模型提供商遺失,該知識庫將不再支持,請重新創建知識庫",
"source": "來源"
"source": "來源",
"chunk_size": "分段大小",
"chunk_overlap": "重疊大小",
"not_set": "未設置"
},
"models": {
"pinned": "已固定",

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,
@@ -292,13 +282,14 @@ const Tabs = styled(TabsAntd)<{ $language: string }>`
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;

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

@@ -139,7 +139,6 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic }) => {
setText('')
setFiles([])
setMentionModels([])
setTimeout(() => setText(''), 500)
setTimeout(() => resizeTextArea(), 0)

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)
@@ -123,7 +133,7 @@ const MessageItem: FC<Props> = ({
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 })
}
@@ -157,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 }}>
@@ -179,6 +189,7 @@ const MessageItem: FC<Props> = ({
index={index}
isLastMessage={isLastMessage}
isAssistantMessage={isAssistantMessage}
isGrouped={isGrouped}
setModel={setModel}
onEditMessage={onEditMessage}
onDeleteMessage={onDeleteMessage}
@@ -194,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 { withMessageThought } from '@renderer/utils/formats'
import { Divider, Flex } from 'antd'
import React from 'react'
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,13 +41,14 @@ const MessageContent: React.FC<{
}
return (
<>
<Flex gap="8px" wrap>
<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>
@@ -51,11 +57,11 @@ const MessageContent: React.FC<{
) : (
<Markdown message={{ ...message, content: message.translatedContent }} />
)}
</>
</Fragment>
)}
<MessageAttachments message={message} />
<MessageSearchResults message={message} />
</>
</Fragment>
)
}

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 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
@@ -98,14 +68,18 @@ const Messages: FC<Props> = ({ assistant, topic, setActiveTopic }) => {
const onSendMessage = useCallback(
async (message: Message) => {
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 {
assistantMessages.push(getAssistantMessage({ assistant, topic }))
const assistantMessage = getAssistantMessage({ assistant, topic })
assistantMessage.askId = message.id
assistantMessages.push(assistantMessage)
}
setMessages((prev) => {
@@ -119,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)
@@ -143,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]
)
@@ -160,13 +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(), modelId: model.id, model: model, mentions: [model] })
lastUserMessage && onSendMessage({ ...lastUserMessage, id: uuid(), model: model, mentions: [model] })
}),
EventEmitter.on(EVENT_NAMES.AI_AUTO_RENAME, autoRenameTopic),
EventEmitter.on(EVENT_NAMES.CLEAR_MESSAGES, () => {
@@ -214,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) => {
@@ -229,6 +227,7 @@ const Messages: FC<Props> = ({ assistant, topic, setActiveTopic }) => {
assistant,
autoRenameTopic,
messages,
onAppendMessage,
onDeleteMessage,
onSendMessage,
scrollToBottom,
@@ -293,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
@@ -307,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}
/>
))}
@@ -327,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

@@ -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()
@@ -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

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

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

@@ -7,11 +7,10 @@ import {
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'
@@ -23,94 +22,145 @@ 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/quickAssistant">
<MenuItem className={isRoute('/settings/quickAssistant')}>
<RocketOutlined />
{t('settings.quickAssistant.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="quickAssistant" element={<QuickAssistantSettings />} />
<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;
@@ -118,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

@@ -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,6 +1,7 @@
import { CheckOutlined, SendOutlined, SettingOutlined, SwapOutlined, WarningOutlined } from '@ant-design/icons'
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
import CopyIcon from '@renderer/components/Icons/CopyIcon'
import SettingsPopup from '@renderer/components/Popups/SettingsPopup'
import { isLocalAi } from '@renderer/config/env'
import { TranslateLanguageOptions } from '@renderer/config/translate'
import db from '@renderer/databases'
@@ -55,7 +56,7 @@ const TranslatePage: FC = () => {
content: text,
assistantId: assistant.id,
topicId: uuid(),
modelId: translateModel.id,
model: translateModel,
createdAt: new Date().toISOString(),
type: 'text',
status: 'sending'
@@ -90,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 />} />}
/>
)
}

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 '.'
@@ -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

@@ -114,9 +114,9 @@ export default class GeminiProvider extends BaseProvider {
}
private getSafetySettings(modelId: string): SafetySetting[] {
const safetyThreshold = modelId.includes('gemini-exp-')
? HarmBlockThreshold.BLOCK_NONE
: ('OFF' as HarmBlockThreshold)
const safetyThreshold = modelId.includes('gemini-2.0-flash-exp')
? ('OFF' as HarmBlockThreshold)
: HarmBlockThreshold.BLOCK_NONE
return [
{

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,6 +142,12 @@ 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))
}
@@ -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,17 +197,28 @@ 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
}
})
}

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

@@ -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({
@@ -53,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 }
}

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

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

View File

@@ -6,7 +6,7 @@ import { getDefaultTopic } from './AssistantService'
import { getDefaultTranslateAssistant } from './AssistantService'
import { getUserMessage } from './MessagesService'
export const translateText = async (text: string, targetLanguage: string) => {
export const translateText = async (text: string, targetLanguage: string, onResponse?: (text: string) => void) => {
const translateModel = store.getState().llm.translateModel
if (!translateModel) {
@@ -25,7 +25,7 @@ export const translateText = async (text: string, targetLanguage: string) => {
content: text
})
const translatedText = await fetchTranslate({ message, assistant })
const translatedText = await fetchTranslate({ message, assistant, onResponse })
const trimmedText = translatedText.trim()

View File

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

View File

@@ -814,7 +814,7 @@ const migrateConfig = {
state.llm.providers.push({
id: 'qwenlm',
name: 'QwenLM',
type: 'openai',
type: 'qwenlm',
apiKey: '',
apiHost: 'https://chat.qwenlm.ai/api/',
models: SYSTEM_MODELS.qwenlm,
@@ -877,6 +877,18 @@ const migrateConfig = {
}
removeMiniAppIconsFromState(state)
return state
},
'60': (state: RootState) => {
state.settings.multiModelMessageStyle = 'fold'
return state
},
'61': (state: RootState) => {
state.llm.providers.forEach((provider) => {
if (provider.id === 'qwenlm') {
provider.type = 'qwenlm'
}
})
return state
}
}

View File

@@ -22,6 +22,7 @@ export interface RuntimeState {
minappShow: boolean
searching: boolean
filesPath: string
resourcesPath: string
update: UpdateState
webdavSync: WebDAVSyncState
}
@@ -32,6 +33,7 @@ const initialState: RuntimeState = {
minappShow: false,
searching: false,
filesPath: '',
resourcesPath: '',
update: {
info: null,
checking: false,
@@ -65,6 +67,9 @@ const runtimeSlice = createSlice({
setFilesPath: (state, action: PayloadAction<string>) => {
state.filesPath = action.payload
},
setResourcesPath: (state, action: PayloadAction<string>) => {
state.resourcesPath = action.payload
},
setUpdateState: (state, action: PayloadAction<Partial<UpdateState>>) => {
state.update = { ...state.update, ...action.payload }
},
@@ -80,6 +85,7 @@ export const {
setMinappShow,
setSearching,
setFilesPath,
setResourcesPath,
setUpdateState,
setWebDAVSyncState
} = runtimeSlice.actions

View File

@@ -63,8 +63,11 @@ export interface SettingsState {
narrowMode: boolean
enableQuickAssistant: boolean
clickTrayToShowQuickAssistant: boolean
multiModelMessageStyle: MultiModelMessageStyle
}
export type MultiModelMessageStyle = 'horizontal' | 'vertical' | 'fold'
const initialState: SettingsState = {
showAssistants: true,
showTopics: true,
@@ -109,7 +112,8 @@ const initialState: SettingsState = {
},
narrowMode: false,
enableQuickAssistant: false,
clickTrayToShowQuickAssistant: false
clickTrayToShowQuickAssistant: false,
multiModelMessageStyle: 'fold'
}
const settingsSlice = createSlice({
@@ -251,6 +255,9 @@ const settingsSlice = createSlice({
},
setEnableQuickAssistant: (state, action: PayloadAction<boolean>) => {
state.enableQuickAssistant = action.payload
},
setMultiModelMessageStyle: (state, action: PayloadAction<'horizontal' | 'vertical' | 'fold'>) => {
state.multiModelMessageStyle = action.payload
}
}
})
@@ -298,7 +305,8 @@ export const {
setSidebarIcons,
setNarrowMode,
setClickTrayToShowQuickAssistant,
setEnableQuickAssistant
setEnableQuickAssistant,
setMultiModelMessageStyle
} = settingsSlice.actions
export default settingsSlice.reducer

View File

@@ -47,11 +47,13 @@ export type Message = {
assistantId: string
role: 'user' | 'assistant'
content: string
reasoning_content?: string
translatedContent?: string
topicId: string
createdAt: string
status: 'sending' | 'pending' | 'success' | 'paused' | 'error'
modelId?: string
model?: Model
files?: FileType[]
images?: string[]
usage?: OpenAI.Completions.CompletionUsage
@@ -60,17 +62,19 @@ export type Message = {
type: 'text' | '@' | 'clear'
isPreset?: boolean
mentions?: Model[]
model?: Model
metadata?: {
// Gemini
groundingMetadata?: any
}
askId?: string
useful?: boolean
}
export type Metrics = {
completion_tokens?: number
time_completion_millsec?: number
time_first_token_millsec?: number
time_thinking_millsec?: number
}
export type Topic = {
@@ -187,6 +191,7 @@ export type AppInfo = {
isPackaged: boolean
appPath: string
appDataPath: string
resourcesPath: string
filesPath: string
logsPath: string
}
@@ -227,6 +232,8 @@ export interface KnowledgeBase {
created_at: number
updated_at: number
version: number
chunkSize?: number
chunkOverlap?: number
}
export type KnowledgeBaseParams = {
@@ -236,6 +243,8 @@ export type KnowledgeBaseParams = {
apiKey: string
apiVersion?: string
baseURL: string
chunkSize?: number
chunkOverlap?: number
}
export type GenerateImageParams = {

View File

@@ -80,3 +80,22 @@ export function withGeminiGrounding(message: Message) {
return content
}
export function withMessageThought(message: Message) {
const content = message.content
const thinkPattern = /<think>(.*?)<\/think>/s
const matches = content.match(thinkPattern)
if (matches) {
const reasoning_content = matches[1].trim()
const remainingContent = content.replace(thinkPattern, '').trim()
if (reasoning_content) {
message.reasoning_content = reasoning_content
message.content = remainingContent
return message
}
}
return message
}

View File

@@ -26,6 +26,18 @@ export function isJSON(str: any): boolean {
}
}
export function parseJSON(str: string) {
if (str === 'undefined') {
return undefined
}
try {
return JSON.parse(str)
} catch (e) {
return null
}
}
export const delay = (seconds: number) => {
return new Promise((resolve) => {
setTimeout(() => {

View File

@@ -5,6 +5,7 @@ import MessageContent from '@renderer/pages/home/Messages/MessageContent'
import MessageErrorBoundary from '@renderer/pages/home/Messages/MessageErrorBoundary'
import { fetchChatCompletion } from '@renderer/services/ApiService'
import { getDefaultAssistant, getDefaultModel } from '@renderer/services/AssistantService'
import { getMessageModelId } from '@renderer/services/MessagesService'
import { Message } from '@renderer/types'
import { isMiniWindow } from '@renderer/utils'
import { Dispatch, FC, memo, SetStateAction, useEffect, useMemo, useRef, useState } from 'react'
@@ -24,7 +25,7 @@ const getMessageBackground = (isBubbleStyle: boolean, isAssistantMessage: boolea
const MessageItem: FC<Props> = ({ message: _message, index, total, route, onSetMessages, onGetMessages }) => {
const [message, setMessage] = useState(_message)
const model = useModel(message.modelId)
const model = useModel(getMessageModelId(message))
const isBubbleStyle = true
const { messageFont, fontSize } = useSettings()
const messageContainerRef = useRef<HTMLDivElement>(null)

View File

@@ -1,7 +1,7 @@
import { BulbOutlined, FileTextOutlined, MessageOutlined, TranslationOutlined } from '@ant-design/icons'
import Scrollbar from '@renderer/components/Scrollbar'
import { Col } from 'antd'
import { Dispatch, FC, SetStateAction } from 'react'
import { Dispatch, FC, SetStateAction, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@@ -13,52 +13,78 @@ interface FeatureMenusProps {
const FeatureMenus: FC<FeatureMenusProps> = ({ text, setRoute, onSendMessage }) => {
const { t } = useTranslation()
const [selectedIndex, setSelectedIndex] = useState(0)
const features = [
{
icon: <MessageOutlined style={{ fontSize: '16px', color: 'var(--color-text)' }} />,
title: t('miniwindow.feature.chat'),
active: true,
onClick: () => {
if (text) {
setRoute('chat')
onSendMessage()
const features = useMemo(
() => [
{
icon: <MessageOutlined style={{ fontSize: '16px', color: 'var(--color-text)' }} />,
title: t('miniwindow.feature.chat'),
active: true,
onClick: () => {
if (text) {
setRoute('chat')
onSendMessage()
}
}
},
{
icon: <TranslationOutlined style={{ fontSize: '16px', color: 'var(--color-text)' }} />,
title: t('miniwindow.feature.translate'),
onClick: () => text && setRoute('translate')
},
{
icon: <FileTextOutlined style={{ fontSize: '16px', color: 'var(--color-text)' }} />,
title: t('miniwindow.feature.summary'),
onClick: () => {
if (text) {
setRoute('summary')
onSendMessage(t('prompts.summarize'))
}
}
},
{
icon: <BulbOutlined style={{ fontSize: '16px', color: 'var(--color-text)' }} />,
title: t('miniwindow.feature.explanation'),
onClick: () => {
if (text) {
setRoute('explanation')
onSendMessage(t('prompts.explanation'))
}
}
}
},
{
icon: <TranslationOutlined style={{ fontSize: '16px', color: 'var(--color-text)' }} />,
title: t('miniwindow.feature.translate'),
onClick: () => text && setRoute('translate')
},
{
icon: <FileTextOutlined style={{ fontSize: '16px', color: 'var(--color-text)' }} />,
title: t('miniwindow.feature.summary'),
onClick: () => {
if (text) {
setRoute('summary')
onSendMessage(t('prompts.summarize'))
}
}
},
{
icon: <BulbOutlined style={{ fontSize: '16px', color: 'var(--color-text)' }} />,
title: t('miniwindow.feature.explanation'),
onClick: () => {
if (text) {
setRoute('explanation')
onSendMessage(t('prompts.explanation'))
}
],
[onSendMessage, setRoute, t, text]
)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
case 'ArrowUp':
e.preventDefault()
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : features.length - 1))
break
case 'ArrowDown':
e.preventDefault()
setSelectedIndex((prev) => (prev < features.length - 1 ? prev + 1 : 0))
break
case 'Enter':
e.preventDefault()
features[selectedIndex].onClick?.()
break
}
}
]
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [features, selectedIndex])
return (
<FeatureList>
<FeatureListWrapper>
{features.map((feature, index) => (
<Col span={24} key={index}>
<FeatureItem onClick={feature.onClick} className={feature.active ? 'active' : ''}>
<FeatureItem onClick={feature.onClick} className={index === selectedIndex ? 'active' : ''}>
<FeatureIcon>{feature.icon}</FeatureIcon>
<FeatureTitle>{feature.title}</FeatureTitle>
</FeatureItem>

View File

@@ -13,9 +13,9 @@ const Footer: FC<FooterProps> = ({ route, onExit }) => {
const { t } = useTranslation()
return (
<WindowFooter onClick={() => onExit()}>
<WindowFooter>
<FooterText className="nodrag">
<Tag bordered={false} icon={<LoginOutlined />}>
<Tag bordered={false} icon={<LoginOutlined />} onClick={() => onExit()}>
{t('miniwindow.footer.esc', {
action: route === 'home' ? t('miniwindow.footer.esc_close') : t('miniwindow.footer.esc_back')
})}

View File

@@ -45,7 +45,7 @@ const Translate: FC<Props> = ({ text }) => {
content: text,
assistantId: assistant.id,
topicId: uuid(),
modelId: translateModel.id,
model: translateModel,
createdAt: new Date().toISOString(),
type: 'text',
status: 'sending'

View File

@@ -1693,7 +1693,7 @@ __metadata:
"@llm-tools/embedjs@patch:@llm-tools/embedjs@npm%3A0.1.25#~/.yarn/patches/@llm-tools-embedjs-npm-0.1.25-ec5645cf36.patch":
version: 0.1.25
resolution: "@llm-tools/embedjs@patch:@llm-tools/embedjs@npm%3A0.1.25#~/.yarn/patches/@llm-tools-embedjs-npm-0.1.25-ec5645cf36.patch::version=0.1.25&hash=7b05b5"
resolution: "@llm-tools/embedjs@patch:@llm-tools/embedjs@npm%3A0.1.25#~/.yarn/patches/@llm-tools-embedjs-npm-0.1.25-ec5645cf36.patch::version=0.1.25&hash=3b8a9c"
dependencies:
"@langchain/textsplitters": "npm:^0.1.0"
"@llm-tools/embedjs-interfaces": "npm:0.1.25"
@@ -1703,7 +1703,7 @@ __metadata:
md5: "npm:^2.3.0"
mime: "npm:^4.0.6"
stream-mime-type: "npm:^2.0.0"
checksum: 10c0/d0a37a5c7232571a71eff7e90ff4ba612bf33022a6eccd933c3a778844320f427a936d0851aae00092e34407c8c2f3555fe4444c6f2139f978ecfdd42fd89375
checksum: 10c0/3ef5fb0068e662d9fc3ff794c0c200fca91fba548d1989a628ad2c3576e3f97838f3abca683adc77b1774d57e09c6d155c1c4b9d69eb20aac26bd274148f72a1
languageName: node
linkType: hard