From fa17c70d85fe3a509c80b2e08d72dec44f74b558 Mon Sep 17 00:00:00 2001 From: SuYao Date: Tue, 15 Jul 2025 09:13:49 +0800 Subject: [PATCH 001/596] chore: update .gitignore to include .claude-code-router directory (#8156) --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 69c03e73e..7f15a6637 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,7 @@ local .claude/* .gemini/* .trae/* +.claude-code-router/* # vitest coverage From 06baaa15226023cf56c6324e475a431e530de76b Mon Sep 17 00:00:00 2001 From: SuYao Date: Tue, 15 Jul 2025 10:10:55 +0800 Subject: [PATCH 002/596] fix: openai api client (#8154) --- src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts b/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts index 5cc05274d..2034b3951 100644 --- a/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts +++ b/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts @@ -717,7 +717,12 @@ export class OpenAIAPIClient extends OpenAIBaseClient< contentSource = choice.message } - if (!contentSource) continue + if (!contentSource) { + if ('finish_reason' in choice && choice.finish_reason) { + emitCompletionSignals(controller) + } + continue + } const webSearchData = collectWebSearchData(chunk, contentSource, context) if (webSearchData) { From 72ae105166c6eac1094718aa228cc00445729436 Mon Sep 17 00:00:00 2001 From: LiuVaayne <10231735+vaayne@users.noreply.github.com> Date: Tue, 15 Jul 2025 10:24:41 +0800 Subject: [PATCH 003/596] [1.5.0-rc] Feat/memory (#7689) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Merge memory into main * Improvement/memory UI (#7655) * feat: add auto-dimension detection to memory settings - Add automatic embedding dimension detection for memory configuration - Add toggle switch to enable/disable auto-detection (enabled by default) - Detect dimensions by making test API call to embedding provider - Show dimension input field only when auto-detection is disabled - Add loading state and error handling during dimension detection - Maintain consistency with knowledge base dimension handling 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * ✨ feat: implement unified embedding dimensions for memory service - Add jaison dependency for robust JSON parsing - Normalize all embeddings to 1536 dimensions for consistency - Improve embedding dimension logging - Update memory processor to use jaison for better error handling - Handle various JSON response formats in fact extraction 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * feat: refactor MemoriesPage layout with new styled components and improved user management features --------- Co-authored-by: Claude * Improvement/memory UI (#7656) Co-authored-by: Claude * ✨ feat: add memory icon to sidebar for existing users - Add migration version 118 to enable memory feature visibility - Adds 'memory' icon to sidebar visible icons if not already present - Updates store version to trigger migration for existing users 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * fix(memory): include last user message ID in processor config * ✨ feat(memory): enhance memory settings UI and add new translations * Enhance memory management UI: Added settings, statistics, search, actions, and user management sections to the memory page. Updated translations for multiple languages to include new UI elements. Refactored component structure for improved layout and readability. * feat: add i18n * ui: Enhance memory modals and UI * refactor(memory): replace direct message calls with window.message for error and success notifications * fix: eslint error * feat(memory): enhance memory restoration logic and queries - Updated MemoryService to restore deleted memories instead of inserting new ones if a memory with the same hash exists. - Added new SQL queries to check for deleted memories and restore them. - Improved logging for memory restoration and embedding generation. - Refactored related API service methods to handle updated memory processing logic. * refactor: update memory configuration to use ApiClient structure - Refactored memory-related services and components to utilize the new ApiClient structure for embedding and reranking models. - Updated constructors and method signatures across multiple files to accept embedApiClient and rerankApiClient parameters. - Enhanced memory settings UI to reflect changes in memory configuration management. - Improved type definitions for KnowledgeBaseParams and MemoryConfig to align with the new structure. * ui: improve user interface for adding new users in memory page - Enhanced the button for adding new users by incorporating an icon and adjusting padding for better alignment. - Updated the user selection options to ensure consistent alignment of avatars and user names. - Refactored layout to improve overall user experience and visual consistency. * refactor(memory): streamline MemoryProcessor usage in ApiService - Removed the singleton instance of MemoryProcessor and instantiated it directly within the ApiService methods. - Updated relevant methods to utilize the new instance for searching and processing memories, improving clarity and encapsulation of memory handling logic. * chore: move knowledge dir * fix: correct import paths in KnowledgeService.ts * fix(Memory): memory deduplicate * fix(Memory): memory llm provider * fix: ci error * fix(Memory): update fact extraction prompt to focus on personal information * feat: Refactor memory fom sidebar to settings page - Removed MemoryStick icon from Sidebar component. - Updated navigation to point to the new memory settings page. - Introduced MemoriesSettingsModal for managing memory configurations. - Created MemorySettings component for comprehensive memory management. - Added user management features including adding, editing, and deleting users. - Implemented pagination and search functionality for memory items. - Updated sidebar settings to remove memory icon and ensure proper migration. - Adjusted Redux store settings to reflect changes in sidebar icons. * feat: redesign memory settings page with improved UI and layout * fix i18n * fix: update citation titles to include memory hash and increment version number * fix: remove unnecessary prop from KnowledgeCitation component * feat: enhance fact extraction prompt with clearer guidelines and examples * 🔧 feat: disable global memory by default and improve UI - Set globalMemoryEnabled default to false for better user experience - Remove manual localStorage handling to rely on redux-persist - Add Beta badge to memory settings section - Improve layout and styling of memory settings UI components 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * Simplify external tool completion handling * Fix whitespace in migrate config --------- Co-authored-by: Claude Co-authored-by: suyao Co-authored-by: eeee0717 Co-authored-by: kangfenmao Co-authored-by: 自由的世界人 <3196812536@qq.com> --- docs/features/memory-guide-zh.md | 222 ++++ package.json | 1 + packages/shared/IpcChannel.ts | 14 +- src/main/ipc.ts | 34 + .../embeddings/Embeddings.ts | 12 +- .../embeddings/EmbeddingsFactory.ts | 5 +- .../embeddings/VoyageEmbeddings.ts | 0 .../embeddings/utils.ts | 0 .../loader/draftsExportLoader.ts | 0 .../loader/epubLoader.ts | 0 .../{knowledage => knowledge}/loader/index.ts | 0 .../loader/noteLoader.ts | 0 .../loader/odLoader.ts | 0 .../reranker/BaseReranker.ts | 18 +- .../reranker/GeneralReranker.ts | 0 .../reranker/Reranker.ts | 0 src/main/services/KnowledgeService.ts | 22 +- src/main/services/memory/MemoryService.ts | 829 +++++++++++++ src/main/services/memory/queries.ts | 164 +++ src/preload/index.ts | 21 + .../src/aiCore/clients/BaseApiClient.ts | 18 +- src/renderer/src/hooks/useAppInit.ts | 15 + src/renderer/src/hooks/useModel.ts | 14 + src/renderer/src/i18n/locales/en-us.json | 298 +++++ src/renderer/src/i18n/locales/ja-jp.json | 300 ++++- src/renderer/src/i18n/locales/ru-ru.json | 298 +++++ src/renderer/src/i18n/locales/zh-cn.json | 300 ++++- src/renderer/src/i18n/locales/zh-tw.json | 298 +++++ .../home/Messages/Blocks/CitationBlock.tsx | 5 +- .../src/pages/home/Messages/CitationsList.tsx | 14 +- .../pages/knowledge/components/QuotaTag.tsx | 2 +- src/renderer/src/pages/memory/index.tsx | 1083 +++++++++++++++++ .../src/pages/memory/settings-modal.tsx | 236 ++++ .../AssistantMemorySettings.tsx | 178 +++ .../settings/AssistantSettings/index.tsx | 22 +- .../MemorySettings/MemoriesSettingsModal.tsx | 226 ++++ .../MemorySettings/MemorySettings.tsx | 909 ++++++++++++++ .../pages/settings/MemorySettings/index.tsx | 1 + .../src/pages/settings/SettingsPage.tsx | 21 + src/renderer/src/services/ApiService.ts | 197 ++- src/renderer/src/services/KnowledgeService.ts | 24 +- src/renderer/src/services/MemoryProcessor.ts | 274 +++++ src/renderer/src/services/MemoryService.ts | 220 ++++ .../src/services/StreamProcessingService.ts | 2 +- src/renderer/src/store/index.ts | 2 + src/renderer/src/store/memory.ts | 119 ++ src/renderer/src/store/messageBlock.ts | 17 +- src/renderer/src/store/migrate.ts | 20 +- src/renderer/src/types/index.ts | 97 +- src/renderer/src/types/newMessage.ts | 2 + src/renderer/src/utils/memory-prompts.ts | 301 +++++ src/renderer/src/utils/messageUtils/create.ts | 5 +- yarn.lock | 8 + 53 files changed, 6788 insertions(+), 80 deletions(-) create mode 100644 docs/features/memory-guide-zh.md rename src/main/{knowledage => knowledge}/embeddings/Embeddings.ts (71%) rename src/main/{knowledage => knowledge}/embeddings/EmbeddingsFactory.ts (88%) rename src/main/{knowledage => knowledge}/embeddings/VoyageEmbeddings.ts (100%) rename src/main/{knowledage => knowledge}/embeddings/utils.ts (100%) rename src/main/{knowledage => knowledge}/loader/draftsExportLoader.ts (100%) rename src/main/{knowledage => knowledge}/loader/epubLoader.ts (100%) rename src/main/{knowledage => knowledge}/loader/index.ts (100%) rename src/main/{knowledage => knowledge}/loader/noteLoader.ts (100%) rename src/main/{knowledage => knowledge}/loader/odLoader.ts (100%) rename src/main/{knowledage => knowledge}/reranker/BaseReranker.ts (87%) rename src/main/{knowledage => knowledge}/reranker/GeneralReranker.ts (100%) rename src/main/{knowledage => knowledge}/reranker/Reranker.ts (100%) create mode 100644 src/main/services/memory/MemoryService.ts create mode 100644 src/main/services/memory/queries.ts create mode 100644 src/renderer/src/pages/memory/index.tsx create mode 100644 src/renderer/src/pages/memory/settings-modal.tsx create mode 100644 src/renderer/src/pages/settings/AssistantSettings/AssistantMemorySettings.tsx create mode 100644 src/renderer/src/pages/settings/MemorySettings/MemoriesSettingsModal.tsx create mode 100644 src/renderer/src/pages/settings/MemorySettings/MemorySettings.tsx create mode 100644 src/renderer/src/pages/settings/MemorySettings/index.tsx create mode 100644 src/renderer/src/services/MemoryProcessor.ts create mode 100644 src/renderer/src/services/MemoryService.ts create mode 100644 src/renderer/src/store/memory.ts create mode 100644 src/renderer/src/utils/memory-prompts.ts diff --git a/docs/features/memory-guide-zh.md b/docs/features/memory-guide-zh.md new file mode 100644 index 000000000..6c8c37cbe --- /dev/null +++ b/docs/features/memory-guide-zh.md @@ -0,0 +1,222 @@ +# Cherry Studio 记忆功能指南 + +## 功能介绍 + +Cherry Studio 的记忆功能是一个强大的工具,能够帮助 AI 助手记住对话中的重要信息、用户偏好和上下文。通过记忆功能,您的 AI 助手可以: + +- 📝 **记住重要信息**:自动从对话中提取并存储关键事实和信息 +- 🧠 **个性化响应**:基于存储的记忆提供更加个性化和相关的回答 +- 🔍 **智能检索**:在需要时自动搜索相关记忆,增强对话的连贯性 +- 👥 **多用户支持**:为不同用户维护独立的记忆上下文 + +记忆功能特别适用于需要长期保持上下文的场景,例如个人助手、客户服务、教育辅导等。 + +## 如何启用记忆功能 + +### 1. 全局配置(首次设置) + +在使用记忆功能之前,您需要先进行全局配置: + +1. 点击侧边栏的 **记忆** 图标(记忆棒图标)进入记忆管理页面 +2. 点击右上角的 **更多** 按钮(三个点),选择 **设置** +3. 在设置弹窗中配置以下必要项: + - **LLM 模型**:选择用于处理记忆的语言模型(推荐使用 GPT-4 或 Claude 等高级模型) + - **嵌入模型**:选择用于生成向量嵌入的模型(如 text-embedding-3-small) + - **嵌入维度**:输入嵌入模型的维度(通常为 1536) +4. 点击 **确定** 保存配置 + +> ⚠️ **注意**:嵌入模型和维度一旦设置后无法更改,请谨慎选择。 + +### 2. 为助手启用记忆 + +完成全局配置后,您可以为特定助手启用记忆功能: + +1. 进入 **助手** 页面 +2. 选择要启用记忆的助手,点击 **编辑** +3. 在助手设置中找到 **记忆** 部分 +4. 打开记忆功能开关 +5. 保存助手设置 + +启用后,该助手将在对话过程中自动提取和使用记忆。 + +## 使用方法 + +### 查看记忆 + +1. 点击侧边栏的 **记忆** 图标进入记忆管理页面 +2. 您可以看到所有存储的记忆卡片,包括: + - 记忆内容 + - 创建时间 + - 所属用户 + +### 添加记忆 + +手动添加记忆有两种方式: + +**方式一:在记忆管理页面添加** + +1. 点击右上角的 **添加记忆** 按钮 +2. 在弹窗中输入记忆内容 +3. 点击 **添加** 保存 + +**方式二:在对话中自动提取** + +- 当助手启用记忆功能后,系统会自动从对话中提取重要信息并存储为记忆 + +### 编辑记忆 + +1. 在记忆卡片上点击 **更多** 按钮(三个点) +2. 选择 **编辑** +3. 修改记忆内容 +4. 点击 **保存** + +### 删除记忆 + +1. 在记忆卡片上点击 **更多** 按钮 +2. 选择 **删除** +3. 确认删除操作 + +## 记忆搜索 + +记忆管理页面提供了强大的搜索功能: + +1. 在页面顶部的搜索框中输入关键词 +2. 系统会实时过滤显示匹配的记忆 +3. 搜索支持模糊匹配,可以搜索记忆内容的任何部分 + +## 用户管理 + +记忆功能支持多用户,您可以为不同的用户维护独立的记忆库: + +### 切换用户 + +1. 在记忆管理页面,点击右上角的用户选择器 +2. 选择要切换到的用户 +3. 页面会自动加载该用户的记忆 + +### 添加新用户 + +1. 点击用户选择器 +2. 选择 **添加新用户** +3. 输入用户 ID(支持字母、数字、下划线和连字符) +4. 点击 **添加** + +### 删除用户 + +1. 切换到要删除的用户 +2. 点击右上角的 **更多** 按钮 +3. 选择 **删除用户** +4. 确认删除(注意:这将删除该用户的所有记忆) + +> 💡 **提示**:默认用户(default-user)无法删除。 + +## 设置说明 + +### LLM 模型 + +- 用于处理记忆提取和更新的语言模型 +- 建议选择能力较强的模型以获得更好的记忆提取效果 +- 可随时更改 + +### 嵌入模型 + +- 用于将文本转换为向量,支持语义搜索 +- 一旦设置后无法更改(为了保证现有记忆的兼容性) +- 推荐使用 OpenAI 的 text-embedding 系列模型 + +### 嵌入维度 + +- 嵌入向量的维度,需要与选择的嵌入模型匹配 +- 常见维度: + - text-embedding-3-small: 1536 + - text-embedding-3-large: 3072 + - text-embedding-ada-002: 1536 + +### 自定义提示词(可选) + +- **事实提取提示词**:自定义如何从对话中提取信息 +- **记忆更新提示词**:自定义如何更新现有记忆 + +## 最佳实践 + +### 1. 合理组织记忆 + +- 保持记忆简洁明了,每条记忆专注于一个具体信息 +- 使用清晰的语言描述事实,避免模糊表达 +- 定期审查和清理过时或不准确的记忆 + +### 2. 多用户场景 + +- 为不同的使用场景创建独立用户(如工作、个人、学习等) +- 使用有意义的用户 ID,便于识别和管理 +- 定期备份重要用户的记忆数据 + +### 3. 模型选择建议 + +- **LLM 模型**:GPT-4、Claude 3 等高级模型能更准确地提取和理解信息 +- **嵌入模型**:选择与您的主要使用语言匹配的模型 + +### 4. 性能优化 + +- 避免存储过多冗余记忆,这可能影响搜索性能 +- 定期整理和合并相似的记忆 +- 对于大量记忆的场景,考虑按主题或时间进行分类管理 + +## 常见问题 + +### Q: 为什么我无法启用记忆功能? + +A: 请确保您已经完成全局配置,包括选择 LLM 模型和嵌入模型。 + +### Q: 记忆会自动同步到所有助手吗? + +A: 不会。每个助手的记忆功能需要单独启用,且记忆是按用户隔离的。 + +### Q: 如何导出我的记忆数据? + +A: 目前系统暂不支持直接导出功能,但所有记忆都存储在本地数据库中。 + +### Q: 删除的记忆可以恢复吗? + +A: 删除操作是永久的,无法恢复。建议在删除前仔细确认。 + +### Q: 记忆功能会影响对话速度吗? + +A: 记忆功能在后台异步处理,不会明显影响对话响应速度。但过多的记忆可能会略微增加搜索时间。 + +### Q: 如何清空所有记忆? + +A: 您可以删除当前用户并重新创建,或者手动删除所有记忆条目。 + +## 注意事项 + +### 隐私保护 + +- 所有记忆数据都存储在您的本地设备上,不会上传到云端 +- 请勿在记忆中存储敏感信息(如密码、私钥等) +- 定期审查记忆内容,确保没有意外存储的隐私信息 + +### 数据安全 + +- 记忆数据存储在本地数据库中 +- 建议定期备份重要数据 +- 更换设备时请注意迁移记忆数据 + +### 使用限制 + +- 单条记忆的长度建议不超过 500 字 +- 每个用户的记忆数量建议控制在 1000 条以内 +- 过多的记忆可能影响系统性能 + +## 技术细节 + +记忆功能使用了先进的 RAG(检索增强生成)技术: + +1. **信息提取**:使用 LLM 从对话中智能提取关键信息 +2. **向量化存储**:通过嵌入模型将文本转换为向量,支持语义搜索 +3. **智能检索**:在对话时自动搜索相关记忆,提供给 AI 作为上下文 +4. **持续学习**:随着对话进行,不断更新和完善记忆库 + +--- + +💡 **提示**:记忆功能是 Cherry Studio 的高级特性,合理使用可以大大提升 AI 助手的智能程度和用户体验。如有更多问题,欢迎查阅文档或联系支持团队。 diff --git a/package.json b/package.json index 45a8537c8..0199b89a5 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "@libsql/win32-x64-msvc": "^0.4.7", "@strongtz/win32-arm64-msvc": "^0.4.7", "iconv-lite": "^0.6.3", + "jaison": "^2.0.2", "jschardet": "^3.1.4", "jsdom": "26.1.0", "macos-release": "^3.4.0", diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index 5389c7542..057e84ca7 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -244,5 +244,17 @@ export enum IpcChannel { Selection_ActionWindowMinimize = 'selection:action-window-minimize', Selection_ActionWindowPin = 'selection:action-window-pin', Selection_ProcessAction = 'selection:process-action', - Selection_UpdateActionData = 'selection:update-action-data' + Selection_UpdateActionData = 'selection:update-action-data', + + // Memory + Memory_Add = 'memory:add', + Memory_Search = 'memory:search', + Memory_List = 'memory:list', + Memory_Delete = 'memory:delete', + Memory_Update = 'memory:update', + Memory_Get = 'memory:get', + Memory_SetConfig = 'memory:set-config', + Memory_DeleteUser = 'memory:delete-user', + Memory_DeleteAllMemoriesForUser = 'memory:delete-all-memories-for-user', + Memory_GetUsersList = 'memory:get-users-list' } diff --git a/src/main/ipc.ts b/src/main/ipc.ts index b72615657..88c038a1f 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -23,6 +23,7 @@ import FileStorage from './services/FileStorage' import FileService from './services/FileSystemService' import KnowledgeService from './services/KnowledgeService' import mcpService from './services/MCPService' +import MemoryService from './services/memory/MemoryService' import NotificationService from './services/NotificationService' import * as NutstoreService from './services/NutstoreService' import ObsidianVaultService from './services/ObsidianVaultService' @@ -47,6 +48,7 @@ const backupManager = new BackupManager() const exportService = new ExportService(fileManager) const obsidianVaultService = new ObsidianVaultService() const vertexAIService = VertexAIService.getInstance() +const memoryService = MemoryService.getInstance() const dxtService = new DxtService() export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { @@ -455,6 +457,38 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { ipcMain.handle(IpcChannel.KnowledgeBase_Rerank, KnowledgeService.rerank) ipcMain.handle(IpcChannel.KnowledgeBase_Check_Quota, KnowledgeService.checkQuota) + // memory + ipcMain.handle(IpcChannel.Memory_Add, async (_, messages, config) => { + return await memoryService.add(messages, config) + }) + ipcMain.handle(IpcChannel.Memory_Search, async (_, query, config) => { + return await memoryService.search(query, config) + }) + ipcMain.handle(IpcChannel.Memory_List, async (_, config) => { + return await memoryService.list(config) + }) + ipcMain.handle(IpcChannel.Memory_Delete, async (_, id) => { + return await memoryService.delete(id) + }) + ipcMain.handle(IpcChannel.Memory_Update, async (_, id, memory, metadata) => { + return await memoryService.update(id, memory, metadata) + }) + ipcMain.handle(IpcChannel.Memory_Get, async (_, memoryId) => { + return await memoryService.get(memoryId) + }) + ipcMain.handle(IpcChannel.Memory_SetConfig, async (_, config) => { + memoryService.setConfig(config) + }) + ipcMain.handle(IpcChannel.Memory_DeleteUser, async (_, userId) => { + return await memoryService.deleteUser(userId) + }) + ipcMain.handle(IpcChannel.Memory_DeleteAllMemoriesForUser, async (_, userId) => { + return await memoryService.deleteAllMemoriesForUser(userId) + }) + ipcMain.handle(IpcChannel.Memory_GetUsersList, async () => { + return await memoryService.getUsersList() + }) + // window ipcMain.handle(IpcChannel.Windows_SetMinimumSize, (_, width: number, height: number) => { mainWindow?.setMinimumSize(width, height) diff --git a/src/main/knowledage/embeddings/Embeddings.ts b/src/main/knowledge/embeddings/Embeddings.ts similarity index 71% rename from src/main/knowledage/embeddings/Embeddings.ts rename to src/main/knowledge/embeddings/Embeddings.ts index 0701e7db2..0ec17691e 100644 --- a/src/main/knowledage/embeddings/Embeddings.ts +++ b/src/main/knowledge/embeddings/Embeddings.ts @@ -1,19 +1,15 @@ import type { BaseEmbeddings } from '@cherrystudio/embedjs-interfaces' -import { KnowledgeBaseParams } from '@types' +import { ApiClient } from '@types' import EmbeddingsFactory from './EmbeddingsFactory' export default class Embeddings { private sdk: BaseEmbeddings - constructor({ model, provider, apiKey, apiVersion, baseURL, dimensions }: KnowledgeBaseParams) { + constructor({ embedApiClient, dimensions }: { embedApiClient: ApiClient; dimensions?: number }) { this.sdk = EmbeddingsFactory.create({ - model, - provider, - apiKey, - apiVersion, - baseURL, + embedApiClient, dimensions - } as KnowledgeBaseParams) + }) } public async init(): Promise { return this.sdk.init() diff --git a/src/main/knowledage/embeddings/EmbeddingsFactory.ts b/src/main/knowledge/embeddings/EmbeddingsFactory.ts similarity index 88% rename from src/main/knowledage/embeddings/EmbeddingsFactory.ts rename to src/main/knowledge/embeddings/EmbeddingsFactory.ts index 5a7561cac..b0ecf360f 100644 --- a/src/main/knowledage/embeddings/EmbeddingsFactory.ts +++ b/src/main/knowledge/embeddings/EmbeddingsFactory.ts @@ -3,14 +3,15 @@ import { OllamaEmbeddings } from '@cherrystudio/embedjs-ollama' import { OpenAiEmbeddings } from '@cherrystudio/embedjs-openai' import { AzureOpenAiEmbeddings } from '@cherrystudio/embedjs-openai/src/azure-openai-embeddings' import { getInstanceName } from '@main/utils' -import { KnowledgeBaseParams } from '@types' +import { ApiClient } from '@types' import { VOYAGE_SUPPORTED_DIM_MODELS } from './utils' import { VoyageEmbeddings } from './VoyageEmbeddings' export default class EmbeddingsFactory { - static create({ model, provider, apiKey, apiVersion, baseURL, dimensions }: KnowledgeBaseParams): BaseEmbeddings { + static create({ embedApiClient, dimensions }: { embedApiClient: ApiClient; dimensions?: number }): BaseEmbeddings { const batchSize = 10 + const { model, provider, apiKey, apiVersion, baseURL } = embedApiClient if (provider === 'voyageai') { return new VoyageEmbeddings({ modelName: model, diff --git a/src/main/knowledage/embeddings/VoyageEmbeddings.ts b/src/main/knowledge/embeddings/VoyageEmbeddings.ts similarity index 100% rename from src/main/knowledage/embeddings/VoyageEmbeddings.ts rename to src/main/knowledge/embeddings/VoyageEmbeddings.ts diff --git a/src/main/knowledage/embeddings/utils.ts b/src/main/knowledge/embeddings/utils.ts similarity index 100% rename from src/main/knowledage/embeddings/utils.ts rename to src/main/knowledge/embeddings/utils.ts diff --git a/src/main/knowledage/loader/draftsExportLoader.ts b/src/main/knowledge/loader/draftsExportLoader.ts similarity index 100% rename from src/main/knowledage/loader/draftsExportLoader.ts rename to src/main/knowledge/loader/draftsExportLoader.ts diff --git a/src/main/knowledage/loader/epubLoader.ts b/src/main/knowledge/loader/epubLoader.ts similarity index 100% rename from src/main/knowledage/loader/epubLoader.ts rename to src/main/knowledge/loader/epubLoader.ts diff --git a/src/main/knowledage/loader/index.ts b/src/main/knowledge/loader/index.ts similarity index 100% rename from src/main/knowledage/loader/index.ts rename to src/main/knowledge/loader/index.ts diff --git a/src/main/knowledage/loader/noteLoader.ts b/src/main/knowledge/loader/noteLoader.ts similarity index 100% rename from src/main/knowledage/loader/noteLoader.ts rename to src/main/knowledge/loader/noteLoader.ts diff --git a/src/main/knowledage/loader/odLoader.ts b/src/main/knowledge/loader/odLoader.ts similarity index 100% rename from src/main/knowledage/loader/odLoader.ts rename to src/main/knowledge/loader/odLoader.ts diff --git a/src/main/knowledage/reranker/BaseReranker.ts b/src/main/knowledge/reranker/BaseReranker.ts similarity index 87% rename from src/main/knowledage/reranker/BaseReranker.ts rename to src/main/knowledge/reranker/BaseReranker.ts index 83d241fe8..c3ac979d2 100644 --- a/src/main/knowledage/reranker/BaseReranker.ts +++ b/src/main/knowledge/reranker/BaseReranker.ts @@ -5,7 +5,7 @@ export default abstract class BaseReranker { protected base: KnowledgeBaseParams constructor(base: KnowledgeBaseParams) { - if (!base.rerankModel) { + if (!base.rerankApiClient) { throw new Error('Rerank model is required') } this.base = base @@ -17,11 +17,11 @@ export default abstract class BaseReranker { * Get Rerank Request Url */ protected getRerankUrl() { - if (this.base.rerankModelProvider === 'bailian') { + if (this.base.rerankApiClient?.provider === 'bailian') { return 'https://dashscope.aliyuncs.com/api/v1/services/rerank/text-rerank/text-rerank' } - let baseURL = this.base.rerankBaseURL + let baseURL = this.base.rerankApiClient?.baseURL if (baseURL && baseURL.endsWith('/')) { // `/` 结尾强制使用rerankBaseURL @@ -39,20 +39,20 @@ export default abstract class BaseReranker { * Get Rerank Request Body */ protected getRerankRequestBody(query: string, searchResults: ExtractChunkData[]) { - const provider = this.base.rerankModelProvider + const provider = this.base.rerankApiClient?.provider const documents = searchResults.map((doc) => doc.pageContent) const topN = this.base.documentCount if (provider === 'voyageai') { return { - model: this.base.rerankModel, + model: this.base.rerankApiClient?.model, query, documents, top_k: topN } } else if (provider === 'bailian') { return { - model: this.base.rerankModel, + model: this.base.rerankApiClient?.model, input: { query, documents @@ -69,7 +69,7 @@ export default abstract class BaseReranker { } } else { return { - model: this.base.rerankModel, + model: this.base.rerankApiClient?.model, query, documents, top_n: topN @@ -81,7 +81,7 @@ export default abstract class BaseReranker { * Extract Rerank Result */ protected extractRerankResult(data: any) { - const provider = this.base.rerankModelProvider + const provider = this.base.rerankApiClient?.provider if (provider === 'bailian') { return data.output.results } else if (provider === 'voyageai') { @@ -129,7 +129,7 @@ export default abstract class BaseReranker { public defaultHeaders() { return { - Authorization: `Bearer ${this.base.rerankApiKey}`, + Authorization: `Bearer ${this.base.rerankApiClient?.apiKey}`, 'Content-Type': 'application/json' } } diff --git a/src/main/knowledage/reranker/GeneralReranker.ts b/src/main/knowledge/reranker/GeneralReranker.ts similarity index 100% rename from src/main/knowledage/reranker/GeneralReranker.ts rename to src/main/knowledge/reranker/GeneralReranker.ts diff --git a/src/main/knowledage/reranker/Reranker.ts b/src/main/knowledge/reranker/Reranker.ts similarity index 100% rename from src/main/knowledage/reranker/Reranker.ts rename to src/main/knowledge/reranker/Reranker.ts diff --git a/src/main/services/KnowledgeService.ts b/src/main/services/KnowledgeService.ts index 8b2f22fc2..736934cd1 100644 --- a/src/main/services/KnowledgeService.ts +++ b/src/main/services/KnowledgeService.ts @@ -21,12 +21,12 @@ import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces' import { LibSqlDb } from '@cherrystudio/embedjs-libsql' import { SitemapLoader } from '@cherrystudio/embedjs-loader-sitemap' import { WebLoader } from '@cherrystudio/embedjs-loader-web' -import Embeddings from '@main/knowledage/embeddings/Embeddings' -import { addFileLoader } from '@main/knowledage/loader' -import { NoteLoader } from '@main/knowledage/loader/noteLoader' import OcrProvider from '@main/knowledage/ocr/OcrProvider' import PreprocessProvider from '@main/knowledage/preprocess/PreprocessProvider' -import Reranker from '@main/knowledage/reranker/Reranker' +import Embeddings from '@main/knowledge/embeddings/Embeddings' +import { addFileLoader } from '@main/knowledge/loader' +import { NoteLoader } from '@main/knowledge/loader/noteLoader' +import Reranker from '@main/knowledge/reranker/Reranker' import { windowService } from '@main/services/WindowService' import { getDataPath } from '@main/utils' import { getAllFiles } from '@main/utils/file' @@ -120,23 +120,15 @@ class KnowledgeService { private getRagApplication = async ({ id, - model, - provider, - apiKey, - apiVersion, - baseURL, + embedApiClient, dimensions, documentCount }: KnowledgeBaseParams): Promise => { let ragApplication: RAGApplication const embeddings = new Embeddings({ - model, - provider, - apiKey, - apiVersion, - baseURL, + embedApiClient, dimensions - } as KnowledgeBaseParams) + }) try { ragApplication = await new RAGApplicationBuilder() .setModel('NO_MODEL') diff --git a/src/main/services/memory/MemoryService.ts b/src/main/services/memory/MemoryService.ts new file mode 100644 index 000000000..07f093252 --- /dev/null +++ b/src/main/services/memory/MemoryService.ts @@ -0,0 +1,829 @@ +import { Client, createClient } from '@libsql/client' +import Embeddings from '@main/knowledge/embeddings/Embeddings' +import type { + AddMemoryOptions, + AssistantMessage, + MemoryConfig, + MemoryHistoryItem, + MemoryItem, + MemoryListOptions, + MemorySearchOptions +} from '@types' +import crypto from 'crypto' +import { app } from 'electron' +import Logger from 'electron-log' +import path from 'path' + +import { MemoryQueries } from './queries' + +export interface EmbeddingOptions { + model: string + provider: string + apiKey: string + apiVersion?: string + baseURL: string + dimensions?: number + batchSize?: number +} + +export interface VectorSearchOptions { + limit?: number + threshold?: number + userId?: string + agentId?: string + filters?: Record +} + +export interface SearchResult { + memories: MemoryItem[] + count: number + error?: string +} + +export class MemoryService { + private static instance: MemoryService | null = null + private db: Client | null = null + private isInitialized = false + private embeddings: Embeddings | null = null + private config: MemoryConfig | null = null + private static readonly UNIFIED_DIMENSION = 1536 + private static readonly SIMILARITY_THRESHOLD = 0.85 + + private constructor() { + // Private constructor to enforce singleton pattern + } + + public static getInstance(): MemoryService { + if (!MemoryService.instance) { + MemoryService.instance = new MemoryService() + } + return MemoryService.instance + } + + public static reload(): MemoryService { + if (MemoryService.instance) { + MemoryService.instance.close() + } + MemoryService.instance = new MemoryService() + return MemoryService.instance + } + + /** + * Initialize the database connection and create tables + */ + private async init(): Promise { + if (this.isInitialized && this.db) { + return + } + + try { + const userDataPath = app.getPath('userData') + const dbPath = path.join(userDataPath, 'memories.db') + + this.db = createClient({ + url: `file:${dbPath}`, + intMode: 'number' + }) + + // Create tables + await this.createTables() + this.isInitialized = true + Logger.info('Memory database initialized successfully') + } catch (error) { + Logger.error('Failed to initialize memory database:', error) + throw new Error( + `Memory database initialization failed: ${error instanceof Error ? error.message : 'Unknown error'}` + ) + } + } + + private async createTables(): Promise { + if (!this.db) throw new Error('Database not initialized') + + // Create memories table with native vector support + await this.db.execute(MemoryQueries.createTables.memories) + + // Create memory history table + await this.db.execute(MemoryQueries.createTables.memoryHistory) + + // Create indexes + await this.db.execute(MemoryQueries.createIndexes.userId) + await this.db.execute(MemoryQueries.createIndexes.agentId) + await this.db.execute(MemoryQueries.createIndexes.createdAt) + await this.db.execute(MemoryQueries.createIndexes.hash) + await this.db.execute(MemoryQueries.createIndexes.memoryHistory) + + // Create vector index for similarity search + try { + await this.db.execute(MemoryQueries.createIndexes.vector) + } catch (error) { + // Vector index might not be supported in all versions + Logger.warn('Failed to create vector index, falling back to non-indexed search:', error) + } + } + + /** + * Add new memories from messages + */ + public async add(messages: string | AssistantMessage[], options: AddMemoryOptions): Promise { + await this.init() + if (!this.db) throw new Error('Database not initialized') + + const { userId, agentId, runId, metadata } = options + + try { + // Convert messages to memory strings + const memoryStrings = Array.isArray(messages) + ? messages.map((m) => (typeof m === 'string' ? m : m.content)) + : [messages] + const addedMemories: MemoryItem[] = [] + + for (const memory of memoryStrings) { + const trimmedMemory = memory.trim() + if (!trimmedMemory) continue + + // Generate hash for deduplication + const hash = crypto.createHash('sha256').update(trimmedMemory).digest('hex') + + // Check if memory already exists + const existing = await this.db.execute({ + sql: MemoryQueries.memory.checkExistsIncludeDeleted, + args: [hash] + }) + + if (existing.rows.length > 0) { + const existingRecord = existing.rows[0] as any + const isDeleted = existingRecord.is_deleted === 1 + + if (!isDeleted) { + // Active record exists, skip insertion + Logger.info(`Memory already exists with hash: ${hash}`) + continue + } else { + // Deleted record exists, restore it instead of inserting new one + Logger.info(`Restoring deleted memory with hash: ${hash}`) + + // Generate embedding if model is configured + let embedding: number[] | null = null + const embedderApiClient = this.config?.embedderApiClient + if (embedderApiClient) { + try { + embedding = await this.generateEmbedding(trimmedMemory) + Logger.info( + `Generated embedding for restored memory with dimension: ${embedding.length} (target: ${this.config?.embedderDimensions || MemoryService.UNIFIED_DIMENSION})` + ) + } catch (error) { + Logger.error('Failed to generate embedding for restored memory:', error) + } + } + + const now = new Date().toISOString() + + // Restore the deleted record + await this.db.execute({ + sql: MemoryQueries.memory.restoreDeleted, + args: [ + trimmedMemory, + embedding ? this.embeddingToVector(embedding) : null, + metadata ? JSON.stringify(metadata) : null, + now, + existingRecord.id + ] + }) + + // Add to history + await this.addHistory(existingRecord.id, null, trimmedMemory, 'ADD') + + addedMemories.push({ + id: existingRecord.id, + memory: trimmedMemory, + hash, + createdAt: now, + updatedAt: now, + metadata + }) + continue + } + } + + // Generate embedding if model is configured + let embedding: number[] | null = null + if (this.config?.embedderApiClient) { + try { + embedding = await this.generateEmbedding(trimmedMemory) + Logger.info( + `Generated embedding with dimension: ${embedding.length} (target: ${this.config?.embedderDimensions || MemoryService.UNIFIED_DIMENSION})` + ) + + // Check for similar memories using vector similarity + const similarMemories = await this.hybridSearch(trimmedMemory, embedding, { + limit: 5, + threshold: 0.1, // Lower threshold to get more candidates + userId, + agentId + }) + + // Check if any similar memory exceeds the similarity threshold + if (similarMemories.memories.length > 0) { + const highestSimilarity = Math.max(...similarMemories.memories.map((m) => m.score || 0)) + if (highestSimilarity >= MemoryService.SIMILARITY_THRESHOLD) { + Logger.info( + `Skipping memory addition due to high similarity: ${highestSimilarity.toFixed(3)} >= ${MemoryService.SIMILARITY_THRESHOLD}` + ) + Logger.info(`Similar memory found: "${similarMemories.memories[0].memory}"`) + continue + } + } + } catch (error) { + Logger.error('Failed to generate embedding:', error) + } + } + + // Insert new memory + const id = crypto.randomUUID() + const now = new Date().toISOString() + + await this.db.execute({ + sql: MemoryQueries.memory.insert, + args: [ + id, + trimmedMemory, + hash, + embedding ? this.embeddingToVector(embedding) : null, + metadata ? JSON.stringify(metadata) : null, + userId || null, + agentId || null, + runId || null, + now, + now + ] + }) + + // Add to history + await this.addHistory(id, null, trimmedMemory, 'ADD') + + addedMemories.push({ + id, + memory: trimmedMemory, + hash, + createdAt: now, + updatedAt: now, + metadata + }) + } + + return { + memories: addedMemories, + count: addedMemories.length + } + } catch (error) { + Logger.error('Failed to add memories:', error) + return { + memories: [], + count: 0, + error: error instanceof Error ? error.message : 'Unknown error' + } + } + } + + /** + * Search memories using text or vector similarity + */ + public async search(query: string, options: MemorySearchOptions = {}): Promise { + await this.init() + if (!this.db) throw new Error('Database not initialized') + + const { limit = 10, userId, agentId, filters = {} } = options + + try { + // If we have an embedder model configured, use vector search + if (this.config?.embedderApiClient) { + try { + const queryEmbedding = await this.generateEmbedding(query) + return await this.hybridSearch(query, queryEmbedding, { limit, userId, agentId, filters }) + } catch (error) { + Logger.error('Vector search failed, falling back to text search:', error) + } + } + + // Fallback to text search + const conditions: string[] = ['m.is_deleted = 0'] + const params: any[] = [] + + // Add search conditions + conditions.push('(m.memory LIKE ? OR m.memory LIKE ?)') + params.push(`%${query}%`, `%${query.split(' ').join('%')}%`) + + if (userId) { + conditions.push('m.user_id = ?') + params.push(userId) + } + + if (agentId) { + conditions.push('m.agent_id = ?') + params.push(agentId) + } + + // Add custom filters + for (const [key, value] of Object.entries(filters)) { + if (value !== undefined && value !== null) { + conditions.push(`json_extract(m.metadata, '$.${key}') = ?`) + params.push(value) + } + } + + const whereClause = conditions.join(' AND ') + params.push(limit) + + const result = await this.db.execute({ + sql: `${MemoryQueries.memory.list} ${whereClause} + ORDER BY m.created_at DESC + LIMIT ? + `, + args: params + }) + + const memories: MemoryItem[] = result.rows.map((row: any) => ({ + id: row.id as string, + memory: row.memory as string, + hash: (row.hash as string) || undefined, + metadata: row.metadata ? JSON.parse(row.metadata as string) : undefined, + createdAt: row.created_at as string, + updatedAt: row.updated_at as string + })) + + return { + memories, + count: memories.length + } + } catch (error) { + Logger.error('Search failed:', error) + return { + memories: [], + count: 0, + error: error instanceof Error ? error.message : 'Unknown error' + } + } + } + + /** + * List all memories with optional filters + */ + public async list(options: MemoryListOptions = {}): Promise { + await this.init() + if (!this.db) throw new Error('Database not initialized') + + const { userId, agentId, limit = 100, offset = 0 } = options + + try { + const conditions: string[] = ['m.is_deleted = 0'] + const params: any[] = [] + + if (userId) { + conditions.push('m.user_id = ?') + params.push(userId) + } + + if (agentId) { + conditions.push('m.agent_id = ?') + params.push(agentId) + } + + const whereClause = conditions.join(' AND ') + + // Get total count + const countResult = await this.db.execute({ + sql: `${MemoryQueries.memory.count} ${whereClause}`, + args: params + }) + const totalCount = (countResult.rows[0] as any).total as number + + // Get paginated results + params.push(limit, offset) + const result = await this.db.execute({ + sql: `${MemoryQueries.memory.list} ${whereClause} + ORDER BY m.created_at DESC + LIMIT ? OFFSET ? + `, + args: params + }) + + const memories: MemoryItem[] = result.rows.map((row: any) => ({ + id: row.id as string, + memory: row.memory as string, + hash: (row.hash as string) || undefined, + metadata: row.metadata ? JSON.parse(row.metadata as string) : undefined, + createdAt: row.created_at as string, + updatedAt: row.updated_at as string + })) + + return { + memories, + count: totalCount + } + } catch (error) { + Logger.error('List failed:', error) + return { + memories: [], + count: 0, + error: error instanceof Error ? error.message : 'Unknown error' + } + } + } + + /** + * Delete a memory (soft delete) + */ + public async delete(id: string): Promise { + await this.init() + if (!this.db) throw new Error('Database not initialized') + + try { + // Get current memory value for history + const current = await this.db.execute({ + sql: MemoryQueries.memory.getForDelete, + args: [id] + }) + + if (current.rows.length === 0) { + throw new Error('Memory not found') + } + + const currentMemory = (current.rows[0] as any).memory as string + + // Soft delete + await this.db.execute({ + sql: MemoryQueries.memory.softDelete, + args: [new Date().toISOString(), id] + }) + + // Add to history + await this.addHistory(id, currentMemory, null, 'DELETE') + + Logger.info(`Memory deleted: ${id}`) + } catch (error) { + Logger.error('Delete failed:', error) + throw new Error(`Failed to delete memory: ${error instanceof Error ? error.message : 'Unknown error'}`) + } + } + + /** + * Update a memory + */ + public async update(id: string, memory: string, metadata?: Record): Promise { + await this.init() + if (!this.db) throw new Error('Database not initialized') + + try { + // Get current memory + const current = await this.db.execute({ + sql: MemoryQueries.memory.getForUpdate, + args: [id] + }) + + if (current.rows.length === 0) { + throw new Error('Memory not found') + } + + const row = current.rows[0] as any + const previousMemory = row.memory as string + const previousMetadata = row.metadata ? JSON.parse(row.metadata as string) : {} + + // Generate new hash + const hash = crypto.createHash('sha256').update(memory.trim()).digest('hex') + + // Generate new embedding if model is configured + let embedding: number[] | null = null + if (this.config?.embedderApiClient) { + try { + embedding = await this.generateEmbedding(memory) + Logger.info( + `Updated embedding with dimension: ${embedding.length} (target: ${this.config?.embedderDimensions || MemoryService.UNIFIED_DIMENSION})` + ) + } catch (error) { + Logger.error('Failed to generate embedding for update:', error) + } + } + + // Merge metadata + const mergedMetadata = { ...previousMetadata, ...metadata } + + // Update memory + await this.db.execute({ + sql: MemoryQueries.memory.update, + args: [ + memory.trim(), + hash, + embedding ? this.embeddingToVector(embedding) : null, + JSON.stringify(mergedMetadata), + new Date().toISOString(), + id + ] + }) + + // Add to history + await this.addHistory(id, previousMemory, memory, 'UPDATE') + + Logger.info(`Memory updated: ${id}`) + } catch (error) { + Logger.error('Update failed:', error) + throw new Error(`Failed to update memory: ${error instanceof Error ? error.message : 'Unknown error'}`) + } + } + + /** + * Get memory history + */ + public async get(memoryId: string): Promise { + await this.init() + if (!this.db) throw new Error('Database not initialized') + + try { + const result = await this.db.execute({ + sql: MemoryQueries.history.getByMemoryId, + args: [memoryId] + }) + + return result.rows.map((row: any) => ({ + id: row.id as number, + memoryId: row.memory_id as string, + previousValue: row.previous_value as string | undefined, + newValue: row.new_value as string, + action: row.action as 'ADD' | 'UPDATE' | 'DELETE', + createdAt: row.created_at as string, + updatedAt: row.updated_at as string, + isDeleted: row.is_deleted === 1 + })) + } catch (error) { + Logger.error('Get history failed:', error) + throw new Error(`Failed to get memory history: ${error instanceof Error ? error.message : 'Unknown error'}`) + } + } + + /** + * Delete all memories for a user without deleting the user (hard delete) + */ + public async deleteAllMemoriesForUser(userId: string): Promise { + await this.init() + if (!this.db) throw new Error('Database not initialized') + + if (!userId) { + throw new Error('User ID is required') + } + + try { + // Get count of memories to be deleted + const countResult = await this.db.execute({ + sql: MemoryQueries.users.countMemoriesForUser, + args: [userId] + }) + const totalCount = (countResult.rows[0] as any).total as number + + // Delete history entries for this user's memories + await this.db.execute({ + sql: MemoryQueries.users.deleteHistoryForUser, + args: [userId] + }) + + // Hard delete all memories for this user + await this.db.execute({ + sql: MemoryQueries.users.deleteAllMemoriesForUser, + args: [userId] + }) + + Logger.info(`Reset all memories for user ${userId} (${totalCount} memories deleted)`) + } catch (error) { + Logger.error('Reset user memories failed:', error) + throw new Error(`Failed to reset user memories: ${error instanceof Error ? error.message : 'Unknown error'}`) + } + } + + /** + * Delete a user and all their memories (hard delete) + */ + public async deleteUser(userId: string): Promise { + await this.init() + if (!this.db) throw new Error('Database not initialized') + + if (!userId) { + throw new Error('User ID is required') + } + + if (userId === 'default-user') { + throw new Error('Cannot delete the default user') + } + + try { + // Get count of memories to be deleted + const countResult = await this.db.execute({ + sql: `SELECT COUNT(*) as total FROM memories WHERE user_id = ?`, + args: [userId] + }) + const totalCount = (countResult.rows[0] as any).total as number + + // Delete history entries for this user's memories + await this.db.execute({ + sql: `DELETE FROM memory_history WHERE memory_id IN (SELECT id FROM memories WHERE user_id = ?)`, + args: [userId] + }) + + // Delete all memories for this user (hard delete) + await this.db.execute({ + sql: `DELETE FROM memories WHERE user_id = ?`, + args: [userId] + }) + + Logger.info(`Deleted user ${userId} and ${totalCount} memories`) + } catch (error) { + Logger.error('Delete user failed:', error) + throw new Error(`Failed to delete user: ${error instanceof Error ? error.message : 'Unknown error'}`) + } + } + + /** + * Get list of unique user IDs with their memory counts + */ + public async getUsersList(): Promise<{ userId: string; memoryCount: number; lastMemoryDate: string }[]> { + await this.init() + if (!this.db) throw new Error('Database not initialized') + + try { + const result = await this.db.execute({ + sql: MemoryQueries.users.getUniqueUsers, + args: [] + }) + + return result.rows.map((row: any) => ({ + userId: row.user_id as string, + memoryCount: row.memory_count as number, + lastMemoryDate: row.last_memory_date as string + })) + } catch (error) { + Logger.error('Get users list failed:', error) + throw new Error(`Failed to get users list: ${error instanceof Error ? error.message : 'Unknown error'}`) + } + } + + /** + * Update configuration + */ + public setConfig(config: MemoryConfig): void { + this.config = config + // Reset embeddings instance when config changes + this.embeddings = null + } + + /** + * Close database connection + */ + public async close(): Promise { + if (this.db) { + await this.db.close() + this.db = null + this.isInitialized = false + } + } + + // ========== EMBEDDING OPERATIONS (Previously EmbeddingService) ========== + + /** + * Normalize embedding dimensions to unified size + */ + private normalizeEmbedding(embedding: number[]): number[] { + if (embedding.length === MemoryService.UNIFIED_DIMENSION) { + return embedding + } + + if (embedding.length < MemoryService.UNIFIED_DIMENSION) { + // Pad with zeros + return [...embedding, ...new Array(MemoryService.UNIFIED_DIMENSION - embedding.length).fill(0)] + } else { + // Truncate + return embedding.slice(0, MemoryService.UNIFIED_DIMENSION) + } + } + + /** + * Generate embedding for text + */ + private async generateEmbedding(text: string): Promise { + if (!this.config?.embedderApiClient) { + throw new Error('Embedder model not configured') + } + + try { + // Initialize embeddings instance if needed + if (!this.embeddings) { + if (!this.config.embedderApiClient) { + throw new Error('Embedder provider not configured') + } + + this.embeddings = new Embeddings({ + embedApiClient: this.config.embedderApiClient, + dimensions: this.config.embedderDimensions + }) + await this.embeddings.init() + } + + const embedding = await this.embeddings.embedQuery(text) + + // Normalize to unified dimension + return this.normalizeEmbedding(embedding) + } catch (error) { + Logger.error('Embedding generation failed:', error) + throw new Error(`Failed to generate embedding: ${error instanceof Error ? error.message : 'Unknown error'}`) + } + } + + // ========== VECTOR SEARCH OPERATIONS (Previously VectorSearch) ========== + + /** + * Convert embedding array to libsql vector format + */ + private embeddingToVector(embedding: number[]): string { + return `[${embedding.join(',')}]` + } + + /** + * Hybrid search combining text and vector similarity (currently vector-only) + */ + private async hybridSearch( + _: string, + queryEmbedding: number[], + options: VectorSearchOptions = {} + ): Promise { + if (!this.db) throw new Error('Database not initialized') + + const { limit = 10, threshold = 0.5, userId } = options + + try { + const queryVector = this.embeddingToVector(queryEmbedding) + + const conditions: string[] = ['m.is_deleted = 0'] + const params: any[] = [] + + // Vector search only - three vector parameters for distance, vector_similarity, and combined_score + params.push(queryVector, queryVector, queryVector) + + if (userId) { + conditions.push('m.user_id = ?') + params.push(userId) + } + + const whereClause = conditions.join(' AND ') + + const hybridQuery = `${MemoryQueries.search.hybridSearch} ${whereClause} + ) AS results + WHERE vector_similarity >= ? + ORDER BY vector_similarity DESC + LIMIT ?` + + params.push(threshold, limit) + + const result = await this.db.execute({ + sql: hybridQuery, + args: params + }) + + const memories: MemoryItem[] = result.rows.map((row: any) => ({ + id: row.id as string, + memory: row.memory as string, + hash: (row.hash as string) || undefined, + metadata: row.metadata ? JSON.parse(row.metadata as string) : undefined, + createdAt: row.created_at as string, + updatedAt: row.updated_at as string, + score: row.vector_similarity as number + })) + + return { + memories, + count: memories.length + } + } catch (error) { + Logger.error('Hybrid search failed:', error) + throw new Error(`Hybrid search failed: ${error instanceof Error ? error.message : 'Unknown error'}`) + } + } + + // ========== HELPER METHODS ========== + + /** + * Add entry to memory history + */ + private async addHistory( + memoryId: string, + previousValue: string | null, + newValue: string | null, + action: 'ADD' | 'UPDATE' | 'DELETE' + ): Promise { + if (!this.db) throw new Error('Database not initialized') + + const now = new Date().toISOString() + await this.db.execute({ + sql: MemoryQueries.history.insert, + args: [memoryId, previousValue, newValue, action, now, now] + }) + } +} + +export default MemoryService diff --git a/src/main/services/memory/queries.ts b/src/main/services/memory/queries.ts new file mode 100644 index 000000000..cbb1b8176 --- /dev/null +++ b/src/main/services/memory/queries.ts @@ -0,0 +1,164 @@ +/** + * SQL queries for MemoryService + * All SQL queries are centralized here for better maintainability + */ + +export const MemoryQueries = { + // Table creation queries + createTables: { + memories: ` + CREATE TABLE IF NOT EXISTS memories ( + id TEXT PRIMARY KEY, + memory TEXT NOT NULL, + hash TEXT UNIQUE, + embedding F32_BLOB(1536), -- Native vector column (1536 dimensions for OpenAI embeddings) + metadata TEXT, -- JSON string + user_id TEXT, + agent_id TEXT, + run_id TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + is_deleted INTEGER DEFAULT 0 + ) + `, + + memoryHistory: ` + CREATE TABLE IF NOT EXISTS memory_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + memory_id TEXT NOT NULL, + previous_value TEXT, + new_value TEXT, + action TEXT NOT NULL, -- ADD, UPDATE, DELETE + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + is_deleted INTEGER DEFAULT 0, + FOREIGN KEY (memory_id) REFERENCES memories (id) + ) + ` + }, + + // Index creation queries + createIndexes: { + userId: 'CREATE INDEX IF NOT EXISTS idx_memories_user_id ON memories(user_id)', + agentId: 'CREATE INDEX IF NOT EXISTS idx_memories_agent_id ON memories(agent_id)', + createdAt: 'CREATE INDEX IF NOT EXISTS idx_memories_created_at ON memories(created_at)', + hash: 'CREATE INDEX IF NOT EXISTS idx_memories_hash ON memories(hash)', + memoryHistory: 'CREATE INDEX IF NOT EXISTS idx_memory_history_memory_id ON memory_history(memory_id)', + vector: 'CREATE INDEX IF NOT EXISTS idx_memories_vector ON memories (libsql_vector_idx(embedding))' + }, + + // Memory operations + memory: { + checkExists: 'SELECT id FROM memories WHERE hash = ? AND is_deleted = 0', + + checkExistsIncludeDeleted: 'SELECT id, is_deleted FROM memories WHERE hash = ?', + + restoreDeleted: ` + UPDATE memories + SET is_deleted = 0, memory = ?, embedding = ?, metadata = ?, updated_at = ? + WHERE id = ? + `, + + insert: ` + INSERT INTO memories (id, memory, hash, embedding, metadata, user_id, agent_id, run_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + + getForDelete: 'SELECT memory FROM memories WHERE id = ? AND is_deleted = 0', + + softDelete: 'UPDATE memories SET is_deleted = 1, updated_at = ? WHERE id = ?', + + getForUpdate: 'SELECT memory, metadata FROM memories WHERE id = ? AND is_deleted = 0', + + update: ` + UPDATE memories + SET memory = ?, hash = ?, embedding = ?, metadata = ?, updated_at = ? + WHERE id = ? + `, + + count: 'SELECT COUNT(*) as total FROM memories m WHERE', + + list: ` + SELECT + m.id, + m.memory, + m.hash, + m.metadata, + m.user_id, + m.agent_id, + m.run_id, + m.created_at, + m.updated_at + FROM memories m + WHERE + ` + }, + + // History operations + history: { + insert: ` + INSERT INTO memory_history (memory_id, previous_value, new_value, action, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + `, + + getByMemoryId: ` + SELECT * FROM memory_history + WHERE memory_id = ? AND is_deleted = 0 + ORDER BY created_at DESC + ` + }, + + // Search operations + search: { + hybridSearch: ` + SELECT * FROM ( + SELECT + m.id, + m.memory, + m.hash, + m.metadata, + m.user_id, + m.agent_id, + m.run_id, + m.created_at, + m.updated_at, + CASE + WHEN m.embedding IS NULL THEN 2.0 + ELSE vector_distance_cos(m.embedding, vector32(?)) + END as distance, + CASE + WHEN m.embedding IS NULL THEN 0.0 + ELSE (1 - vector_distance_cos(m.embedding, vector32(?))) + END as vector_similarity, + 0.0 as text_similarity, + ( + CASE + WHEN m.embedding IS NULL THEN 0.0 + ELSE (1 - vector_distance_cos(m.embedding, vector32(?))) + END + ) as combined_score + FROM memories m + WHERE + ` + }, + + // User operations + users: { + getUniqueUsers: ` + SELECT DISTINCT + user_id, + COUNT(*) as memory_count, + MAX(created_at) as last_memory_date + FROM memories + WHERE user_id IS NOT NULL AND is_deleted = 0 + GROUP BY user_id + ORDER BY last_memory_date DESC + `, + + countMemoriesForUser: 'SELECT COUNT(*) as total FROM memories WHERE user_id = ?', + + deleteAllMemoriesForUser: 'DELETE FROM memories WHERE user_id = ?', + + deleteHistoryForUser: 'DELETE FROM memory_history WHERE memory_id IN (SELECT id FROM memories WHERE user_id = ?)' + } +} as const diff --git a/src/preload/index.ts b/src/preload/index.ts index 17493df72..ab27e37eb 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -3,12 +3,17 @@ import { electronAPI } from '@electron-toolkit/preload' import { UpgradeChannel } from '@shared/config/constant' import { IpcChannel } from '@shared/IpcChannel' import { + AddMemoryOptions, + AssistantMessage, FileListResponse, FileMetadata, FileUploadResponse, KnowledgeBaseParams, KnowledgeItem, MCPServer, + MemoryConfig, + MemoryListOptions, + MemorySearchOptions, Provider, S3Config, Shortcut, @@ -184,6 +189,22 @@ const api = { checkQuota: ({ base, userId }: { base: KnowledgeBaseParams; userId: string }) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Check_Quota, base, userId) }, + memory: { + add: (messages: string | AssistantMessage[], options?: AddMemoryOptions) => + ipcRenderer.invoke(IpcChannel.Memory_Add, messages, options), + search: (query: string, options: MemorySearchOptions) => + ipcRenderer.invoke(IpcChannel.Memory_Search, query, options), + list: (options?: MemoryListOptions) => ipcRenderer.invoke(IpcChannel.Memory_List, options), + delete: (id: string) => ipcRenderer.invoke(IpcChannel.Memory_Delete, id), + update: (id: string, memory: string, metadata?: Record) => + ipcRenderer.invoke(IpcChannel.Memory_Update, id, memory, metadata), + get: (id: string) => ipcRenderer.invoke(IpcChannel.Memory_Get, id), + setConfig: (config: MemoryConfig) => ipcRenderer.invoke(IpcChannel.Memory_SetConfig, config), + deleteUser: (userId: string) => ipcRenderer.invoke(IpcChannel.Memory_DeleteUser, userId), + deleteAllMemoriesForUser: (userId: string) => + ipcRenderer.invoke(IpcChannel.Memory_DeleteAllMemoriesForUser, userId), + getUsersList: () => ipcRenderer.invoke(IpcChannel.Memory_GetUsersList) + }, window: { setMinimumSize: (width: number, height: number) => ipcRenderer.invoke(IpcChannel.Windows_SetMinimumSize, width, height), diff --git a/src/renderer/src/aiCore/clients/BaseApiClient.ts b/src/renderer/src/aiCore/clients/BaseApiClient.ts index 876c4e605..c23c34351 100644 --- a/src/renderer/src/aiCore/clients/BaseApiClient.ts +++ b/src/renderer/src/aiCore/clients/BaseApiClient.ts @@ -16,6 +16,7 @@ import { MCPCallToolResponse, MCPTool, MCPToolResponse, + MemoryItem, Model, OpenAIServiceTier, Provider, @@ -217,6 +218,7 @@ export abstract class BaseApiClient< const webSearchReferences = await this.getWebSearchReferencesFromCache(message) const knowledgeReferences = await this.getKnowledgeBaseReferencesFromCache(message) + const memoryReferences = this.getMemoryReferencesFromCache(message) // 添加偏移量以避免ID冲突 const reindexedKnowledgeReferences = knowledgeReferences.map((ref) => ({ @@ -224,7 +226,7 @@ export abstract class BaseApiClient< id: ref.id + webSearchReferences.length // 为知识库引用的ID添加网络搜索引用的数量作为偏移量 })) - const allReferences = [...webSearchReferences, ...reindexedKnowledgeReferences] + const allReferences = [...webSearchReferences, ...reindexedKnowledgeReferences, ...memoryReferences] Logger.log(`Found ${allReferences.length} references for ID: ${message.id}`, allReferences) @@ -266,6 +268,20 @@ export abstract class BaseApiClient< return '' } + private getMemoryReferencesFromCache(message: Message) { + const memories = window.keyv.get(`memory-search-${message.id}`) as MemoryItem[] | undefined + if (memories) { + const memoryReferences: KnowledgeReference[] = memories.map((mem, index) => ({ + id: index + 1, + content: `${mem.memory} -- Created at: ${mem.createdAt}`, + sourceUrl: '', + type: 'memory' + })) + return memoryReferences + } + return [] + } + private async getWebSearchReferencesFromCache(message: Message) { const content = getMainTextContent(message) if (isEmpty(content)) { diff --git a/src/renderer/src/hooks/useAppInit.ts b/src/renderer/src/hooks/useAppInit.ts index 8b5fe0ade..140219ad7 100644 --- a/src/renderer/src/hooks/useAppInit.ts +++ b/src/renderer/src/hooks/useAppInit.ts @@ -4,7 +4,10 @@ import { useTheme } from '@renderer/context/ThemeProvider' import db from '@renderer/databases' import i18n from '@renderer/i18n' import KnowledgeQueue from '@renderer/queue/KnowledgeQueue' +import MemoryService from '@renderer/services/MemoryService' import { useAppDispatch } from '@renderer/store' +import { useAppSelector } from '@renderer/store' +import { selectMemoryConfig } from '@renderer/store/memory' import { setAvatar, setFilesPath, setResourcesPath, setUpdateState } from '@renderer/store/runtime' import { delay, runAsyncFunction } from '@renderer/utils' import { defaultLanguage } from '@shared/config/constant' @@ -24,10 +27,14 @@ export function useAppInit() { const { setDefaultModel, setTopicNamingModel, setTranslateModel } = useDefaultModel() const avatar = useLiveQuery(() => db.settings.get('image://avatar')) const { theme } = useTheme() + const memoryConfig = useAppSelector(selectMemoryConfig) useEffect(() => { document.getElementById('spinner')?.remove() console.timeEnd('init') + + // Initialize MemoryService after app is ready + MemoryService.getInstance() }, []) useEffect(() => { @@ -121,4 +128,12 @@ export function useAppInit() { useEffect(() => { // TODO: init data collection }, [enableDataCollection]) + + // Update memory service configuration when it changes + useEffect(() => { + const memoryService = MemoryService.getInstance() + memoryService.updateConfig().catch((error) => { + console.error('Failed to update memory config:', error) + }) + }, [memoryConfig]) } diff --git a/src/renderer/src/hooks/useModel.ts b/src/renderer/src/hooks/useModel.ts index 69fa983f9..27962e7ce 100644 --- a/src/renderer/src/hooks/useModel.ts +++ b/src/renderer/src/hooks/useModel.ts @@ -1,3 +1,5 @@ +import store from '@renderer/store' + import { useProviders } from './useProvider' export function useModel(id?: string, providerId?: string) { @@ -11,3 +13,15 @@ export function useModel(id?: string, providerId?: string) { } }) } + +export function getModel(id?: string, providerId?: string) { + const providers = store.getState().llm.providers + const allModels = providers.map((p) => p.models).flat() + return allModels.find((m) => { + if (providerId) { + return m.id === id && m.provider === providerId + } else { + return m.id === id + } + }) +} diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 4c985c895..e24f0c0ea 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -2457,6 +2457,304 @@ "quit": "Quit", "show_window": "Show Window", "visualization": "Visualization" + }, + "update": { + "title": "Update", + "message": "New version {{version}} is ready, do you want to install it now?", + "later": "Later", + "install": "Install", + "noReleaseNotes": "No release notes" + }, + "selection": { + "name": "Selection Assistant", + "action": { + "builtin": { + "translate": "Translate", + "explain": "Explain", + "summary": "Summarize", + "search": "Search", + "refine": "Refine", + "copy": "Copy", + "quote": "Quote" + }, + "window": { + "pin": "Pin", + "pinned": "Pinned", + "opacity": "Window Opacity", + "original_show": "Show Original", + "original_hide": "Hide Original", + "original_copy": "Copy Original", + "esc_close": "Esc: Close", + "esc_stop": "Esc: Stop", + "c_copy": "C: Copy", + "r_regenerate": "R: Regenerate" + }, + "translate": { + "smart_translate_tips": "Smart Translation: Content will be translated to the target language first; content already in the target language will be translated to the alternative language" + } + }, + "settings": { + "experimental": "Experimental Features", + "enable": { + "title": "Enable", + "description": "Currently only supported on Windows & macOS", + "mac_process_trust_hint": { + "title": "Accessibility Permission", + "description": [ + "Selection Assistant requires Accessibility Permission to work properly.", + "Please click \"Go to Settings\" and click the \"Open System Settings\" button in the permission request popup that appears later. Then find \"Cherry Studio\" in the application list that appears later and turn on the permission switch.", + "After completing the settings, please reopen the selection assistant." + ], + "button": { + "open_accessibility_settings": "Open Accessibility Settings", + "go_to_settings": "Go to Settings" + } + } + }, + "toolbar": { + "title": "Toolbar", + "trigger_mode": { + "title": "Trigger Mode", + "description": "The way to trigger the selection assistant and show the toolbar", + "description_note": { + "windows": "Some applications do not support selecting text with the Ctrl key. If you have remapped the Ctrl key using tools like AHK, it may cause some applications to fail to select text.", + "mac": "If you have remapped the ⌘ key using shortcuts or keyboard mapping tools, it may cause some applications to fail to select text." + }, + "selected": "Selection", + "selected_note": "Show toolbar immediately when text is selected", + "ctrlkey": "Ctrl Key", + "ctrlkey_note": "After selection, hold down the Ctrl key to show the toolbar", + "shortcut": "Shortcut", + "shortcut_note": "After selection, use shortcut to show the toolbar. Please set the shortcut in the shortcut settings page and enable it. ", + "shortcut_link": "Go to Shortcut Settings" + }, + "compact_mode": { + "title": "Compact Mode", + "description": "In compact mode, only icons are displayed without text" + } + }, + "window": { + "title": "Action Window", + "follow_toolbar": { + "title": "Follow Toolbar", + "description": "Window position will follow the toolbar. When disabled, it will always be centered." + }, + "remember_size": { + "title": "Remember Size", + "description": "Window will display at the last adjusted size during the application running" + }, + "auto_close": { + "title": "Auto Close", + "description": "Automatically close the window when it's not pinned and loses focus" + }, + "auto_pin": { + "title": "Auto Pin", + "description": "Pin the window by default" + }, + "opacity": { + "title": "Opacity", + "description": "Set the default opacity of the window, 100% is fully opaque" + } + }, + "actions": { + "title": "Actions", + "custom": "Custom Action", + "reset": { + "button": "Reset", + "tooltip": "Reset to default actions. Custom actions will not be deleted.", + "confirm": "Are you sure you want to reset to default actions? Custom actions will not be deleted." + }, + "add_tooltip": { + "enabled": "Add Custom Action", + "disabled": "Maximum number of custom actions reached ({{max}})" + }, + "delete_confirm": "Are you sure you want to delete this custom action?", + "drag_hint": "Drag to reorder. Move above to enable action ({{enabled}}/{{max}})" + }, + "advanced": { + "title": "Advanced", + "filter_mode": { + "title": "Application Filter", + "description": "Can limit the selection assistant to only work in specific applications (whitelist) or not work (blacklist)", + "default": "Off", + "whitelist": "Whitelist", + "blacklist": "Blacklist" + }, + "filter_list": { + "title": "Filter List", + "description": "Advanced feature, recommended for users with experience" + } + }, + "user_modal": { + "title": { + "add": "Add Custom Action", + "edit": "Edit Custom Action" + }, + "name": { + "label": "Name", + "hint": "Please enter action name" + }, + "icon": { + "label": "Icon", + "placeholder": "Enter Lucide icon name", + "error": "Invalid icon name, please check your input", + "tooltip": "Lucide icon names are lowercase, e.g. arrow-right", + "view_all": "View All Icons", + "random": "Random Icon" + }, + "model": { + "label": "Model", + "tooltip": "Using Assistant: Will use both the assistant's system prompt and model parameters", + "default": "Default Model", + "assistant": "Use Assistant" + }, + "assistant": { + "label": "Select Assistant", + "default": "Default" + }, + "prompt": { + "label": "User Prompt", + "tooltip": "User prompt serves as a supplement to user input and won't override the assistant's system prompt", + "placeholder": "Use placeholder {{text}} to represent selected text. When empty, selected text will be appended to this prompt", + "placeholder_text": "Placeholder", + "copy_placeholder": "Copy Placeholder" + } + }, + "search_modal": { + "title": "Set Search Engine", + "engine": { + "label": "Search Engine", + "custom": "Custom" + }, + "custom": { + "name": { + "label": "Custom Name", + "hint": "Please enter search engine name", + "max_length": "Name cannot exceed 16 characters" + }, + "url": { + "label": "Custom Search URL", + "hint": "Use {{queryString}} to represent the search term", + "required": "Please enter search URL", + "invalid_format": "Please enter a valid URL starting with http:// or https://", + "missing_placeholder": "URL must contain {{queryString}} placeholder" + }, + "test": "Test" + } + }, + "filter_modal": { + "title": "Application Filter List", + "user_tips": { + "windows": "Please enter the executable file name of the application, one per line, case insensitive, can be fuzzy matched. For example: chrome.exe, weixin.exe, Cherry Studio.exe, etc.", + "mac": "Please enter the Bundle ID of the application, one per line, case insensitive, can be fuzzy matched. For example: com.google.Chrome, com.apple.mail, etc." + } + } + } + }, + "memory": { + "title": "Memories", + "actions": "Actions", + "description": "Memory allows you to store and manage information about your interactions with the assistant. You can add, edit, and delete memories, as well as filter and search through them.", + "add_memory": "Add Memory", + "edit_memory": "Edit Memory", + "memory_content": "Memory Content", + "please_enter_memory": "Please enter memory content", + "memory_placeholder": "Enter memory content...", + "user_id": "User ID", + "user_id_placeholder": "Enter user ID (optional)", + "load_failed": "Failed to load memories", + "add_success": "Memory added successfully", + "add_failed": "Failed to add memory", + "update_success": "Memory updated successfully", + "update_failed": "Failed to update memory", + "delete_success": "Memory deleted successfully", + "delete_failed": "Failed to delete memory", + "delete_confirm_title": "Delete Memories", + "delete_confirm_content": "Are you sure you want to delete {{count}} memories?", + "delete_confirm": "Are you sure you want to delete this memory?", + "time": "Time", + "user": "User", + "content": "Content", + "score": "Score", + "memories_description": "Showing {{count}} of {{total}} memories", + "search_placeholder": "Search memories...", + "start_date": "Start Date", + "end_date": "End Date", + "all_users": "All Users", + "users": "users", + "delete_selected": "Delete Selected", + "reset_filters": "Reset Filters", + "pagination_total": "{{start}}-{{end}} of {{total}} items", + "current_user": "Current User", + "select_user": "Select User", + "default_user": "Default User", + "switch_user": "Switch User", + "user_switched": "User context switched to {{user}}", + "switch_user_confirm": "Switch user context to {{user}}?", + "add_user": "Add User", + "add_new_user": "Add New User", + "new_user_id": "New User ID", + "new_user_id_placeholder": "Enter a unique user ID", + "user_id_required": "User ID is required", + "user_id_reserved": "'default-user' is reserved, please use a different ID", + "user_id_exists": "This user ID already exists", + "user_id_too_long": "User ID cannot exceed 50 characters", + "user_id_invalid_chars": "User ID can only contain letters, numbers, hyphens and underscores", + "user_id_rules": "User ID must be unique and contain only letters, numbers, hyphens (-) and underscores (_)", + "user_created": "User {{user}} created and switched successfully", + "add_user_failed": "Failed to add user", + "memory": "memory", + "reset_user_memories": "Reset User Memories", + "reset_memories": "Reset Memories", + "delete_user": "Delete User", + "loading_memories": "Loading memories...", + "no_memories": "No memories yet", + "no_matching_memories": "No matching memories found", + "no_memories_description": "Start by adding your first memory to get started", + "try_different_filters": "Try adjusting your search criteria", + "add_first_memory": "Add Your First Memory", + "user_switch_failed": "Failed to switch user", + "cannot_delete_default_user": "Cannot delete the default user", + "delete_user_confirm_title": "Delete User", + "delete_user_confirm_content": "Are you sure you want to delete user {{user}} and all their memories?", + "user_deleted": "User {{user}} deleted successfully", + "delete_user_failed": "Failed to delete user", + "reset_user_memories_confirm_title": "Reset User Memories", + "reset_user_memories_confirm_content": "Are you sure you want to reset all memories for {{user}}?", + "user_memories_reset": "All memories for {{user}} have been reset", + "reset_user_memories_failed": "Failed to reset user memories", + "reset_memories_confirm_title": "Reset All Memories", + "reset_memories_confirm_content": "Are you sure you want to permanently delete all memories for {{user}}? This action cannot be undone.", + "memories_reset_success": "All memories for {{user}} have been reset successfully", + "reset_memories_failed": "Failed to reset memories", + "delete_confirm_single": "Are you sure you want to delete this memory?", + "total_memories": "total memories", + "default": "Default", + "custom": "Custom", + "global_memory_enabled": "Global memory enabled", + "global_memory": "Global Memory", + "enable_global_memory_first": "Please enable global memory first", + "configure_memory_first": "Please configure memory settings first", + "global_memory_disabled_title": "Global Memory Disabled", + "global_memory_disabled_desc": "To use memory features, please enable global memory in assistant settings first.", + "not_configured_title": "Memory Not Configured", + "not_configured_desc": "Please configure embedding and LLM models in memory settings to enable memory functionality.", + "go_to_memory_page": "Go to Memory Page", + "settings": "Settings", + "user_management": "User Management", + "statistics": "Statistics", + "search": "Search", + "initial_memory_content": "Welcome! This is your first memory.", + "loading": "Loading memories...", + "settings_title": "Memory Settings", + "llm_model": "LLM Model", + "please_select_llm_model": "Please select an LLM model", + "select_llm_model_placeholder": "Select LLM Model", + "embedding_model": "Embedding Model", + "please_select_embedding_model": "Please select an embedding model", + "select_embedding_model_placeholder": "Select Embedding Model", + "embedding_dimensions": "Embedding Dimensions", + "stored_memories": "Stored Memories" } } } diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 776c06ad1..76ba1c3da 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -2450,6 +2450,304 @@ "quit": "終了", "show_window": "ウィンドウを表示", "visualization": "可視化" + }, + "update": { + "title": "更新", + "message": "新バージョン {{version}} が利用可能です。今すぐインストールしますか?", + "later": "後で", + "install": "今すぐインストール", + "noReleaseNotes": "暫無更新日誌" + }, + "selection": { + "name": "テキスト選択ツール", + "action": { + "builtin": { + "translate": "翻訳", + "explain": "解説", + "summary": "要約", + "search": "検索", + "refine": "最適化", + "copy": "コピー", + "quote": "引用" + }, + "window": { + "pin": "最前面に固定", + "pinned": "固定中", + "opacity": "ウィンドウの透過度", + "original_show": "原文を表示", + "original_hide": "原文を非表示", + "original_copy": "原文をコピー", + "esc_close": "Escで閉じる", + "esc_stop": "Escで停止", + "c_copy": "Cでコピー", + "r_regenerate": "Rで再生成" + }, + "translate": { + "smart_translate_tips": "スマート翻訳:内容は優先的に目標言語に翻訳されます。すでに目標言語の場合は、備用言語に翻訳されます。" + } + }, + "settings": { + "experimental": "実験的機能", + "enable": { + "title": "有効化", + "description": "現在Windows & macOSのみ対応", + "mac_process_trust_hint": { + "title": "アクセシビリティー権限", + "description": [ + "テキスト選択ツールは、アクセシビリティー権限が必要です。", + "「設定に移動」をクリックし、後で表示される権限要求ポップアップで「システム設定を開く」ボタンをクリックします。その後、表示されるアプリケーションリストで「Cherry Studio」を見つけ、権限スイッチをオンにしてください。", + "設定が完了したら、テキスト選択ツールを再起動してください。" + ], + "button": { + "open_accessibility_settings": "アクセシビリティー設定を開く", + "go_to_settings": "設定に移動" + } + } + }, + "toolbar": { + "title": "ツールバー", + "trigger_mode": { + "title": "単語の取り出し方", + "description": "テキスト選択後、取詞ツールバーを表示する方法", + "description_note": { + "windows": "一部のアプリケーションでは、Ctrl キーでテキストを選択できません。AHK などのツールを使用して Ctrl キーを再マップした場合、一部のアプリケーションでテキスト選択が失敗する可能性があります。", + "mac": "一部のアプリケーションでは、⌘ キーでテキストを選択できません。ショートカットキーまたはキーボードマッピングツールを使用して ⌘ キーを再マップした場合、一部のアプリケーションでテキスト選択が失敗する可能性があります。" + }, + "selected": "選択時", + "selected_note": "テキスト選択時に即時表示", + "ctrlkey": "Ctrlキー", + "ctrlkey_note": "テキスト選択後、Ctrlキーを押下して表示", + "shortcut": "ショートカットキー", + "shortcut_note": "テキスト選択後、ショートカットキーを押下して表示。ショートカットキーを設定するには、ショートカット設定ページで有効にしてください。", + "shortcut_link": "ショートカット設定ページに移動" + }, + "compact_mode": { + "title": "コンパクトモード", + "description": "アイコンのみ表示(テキスト非表示)" + } + }, + "window": { + "title": "機能ウィンドウ", + "follow_toolbar": { + "title": "ツールバーに追従", + "description": "ウィンドウ位置をツールバーに連動(無効時は中央表示)" + }, + "remember_size": { + "title": "サイズを記憶", + "description": "アプリケーション実行中、ウィンドウは最後に調整されたサイズで表示されます" + }, + "auto_close": { + "title": "自動閉じる", + "description": "最前面固定されていない場合、フォーカス喪失時に自動閉じる" + }, + "auto_pin": { + "title": "自動で最前面に固定", + "description": "デフォルトで最前面表示" + }, + "opacity": { + "title": "透明度", + "description": "デフォルトの透明度を設定(100%は完全不透明)" + } + }, + "actions": { + "title": "機能設定", + "custom": "カスタム機能", + "reset": { + "button": "リセット", + "tooltip": "デフォルト機能にリセット(カスタム機能は保持)", + "confirm": "デフォルト機能にリセットしますか?\nカスタム機能は削除されません" + }, + "add_tooltip": { + "enabled": "カスタム機能を追加", + "disabled": "カスタム機能の上限に達しました (最大{{max}}個)" + }, + "delete_confirm": "このカスタム機能を削除しますか?", + "drag_hint": "ドラッグで並べ替え (有効{{enabled}}/最大{{max}})" + }, + "advanced": { + "title": "進階", + "filter_mode": { + "title": "アプリケーションフィルター", + "description": "特定のアプリケーションでのみ選択ツールを有効にするか、無効にするかを選択できます。", + "default": "オフ", + "whitelist": "ホワイトリスト", + "blacklist": "ブラックリスト" + }, + "filter_list": { + "title": "フィルターリスト", + "description": "進階機能です。経験豊富なユーザー向けです。" + } + }, + "user_modal": { + "title": { + "add": "カスタム機能追加", + "edit": "カスタム機能編集" + }, + "name": { + "label": "機能名", + "hint": "機能名を入力" + }, + "icon": { + "label": "アイコン", + "placeholder": "Lucideアイコン名を入力", + "error": "無効なアイコン名です", + "tooltip": "例: arrow-right(小文字で入力)", + "view_all": "全アイコンを表示", + "random": "ランダム選択" + }, + "model": { + "label": "モデル", + "tooltip": "アシスタント使用時はシステムプロンプトとモデルパラメータも適用", + "default": "デフォルトモデル", + "assistant": "アシスタントを使用" + }, + "assistant": { + "label": "アシスタント選択", + "default": "デフォルト" + }, + "prompt": { + "label": "ユーザープロンプト", + "tooltip": "アシスタントのシステムプロンプトを上書きせず、入力補助として機能", + "placeholder": "{{text}}で選択テキストを参照(未入力時は末尾に追加)", + "placeholder_text": "プレースホルダー", + "copy_placeholder": "プレースホルダーをコピー" + } + }, + "search_modal": { + "title": "検索エンジン設定", + "engine": { + "label": "検索エンジン", + "custom": "カスタム" + }, + "custom": { + "name": { + "label": "表示名", + "hint": "検索エンジン名(16文字以内)", + "max_length": "16文字以内で入力" + }, + "url": { + "label": "検索URL", + "hint": "{{queryString}}で検索語を表す", + "required": "URLを入力してください", + "invalid_format": "http:// または https:// で始まるURLを入力", + "missing_placeholder": "{{queryString}}を含めてください" + }, + "test": "テスト" + } + }, + "filter_modal": { + "title": "アプリケーションフィルターリスト", + "user_tips": { + "windows": "アプリケーションの実行ファイル名を1行ずつ入力してください。大文字小文字は区別しません。例: chrome.exe, weixin.exe, Cherry Studio.exe, など。", + "mac": "アプリケーションのBundle IDを1行ずつ入力してください。大文字小文字は区別しません。例: com.google.Chrome, com.apple.mail, など。" + } + } + } + }, + "memory": { + "add_memory": "メモリーを追加", + "edit_memory": "メモリーを編集", + "memory_content": "メモリー内容", + "please_enter_memory": "メモリー内容を入力してください", + "memory_placeholder": "メモリー内容を入力...", + "user_id": "ユーザーID", + "user_id_placeholder": "ユーザーIDを入力(オプション)", + "load_failed": "メモリーの読み込みに失敗しました", + "add_success": "メモリーが正常に追加されました", + "add_failed": "メモリーの追加に失敗しました", + "update_success": "メモリーが正常に更新されました", + "update_failed": "メモリーの更新に失敗しました", + "delete_success": "メモリーが正常に削除されました", + "delete_failed": "メモリーの削除に失敗しました", + "delete_confirm_title": "メモリーを削除", + "delete_confirm_content": "{{count}}件のメモリーを削除してもよろしいですか?", + "delete_confirm": "このメモリーを削除してもよろしいですか?", + "time": "時間", + "user": "ユーザー", + "content": "内容", + "score": "スコア", + "title": "メモリー", + "memories_description": "{{total}}件中{{count}}件のメモリーを表示", + "search_placeholder": "メモリーを検索...", + "start_date": "開始日", + "end_date": "終了日", + "all_users": "すべてのユーザー", + "users": "ユーザー", + "delete_selected": "選択したものを削除", + "reset_filters": "フィルターをリセット", + "pagination_total": "{{total}}件中{{start}}-{{end}}件", + "current_user": "現在のユーザー", + "select_user": "ユーザーを選択", + "default_user": "デフォルトユーザー", + "switch_user": "ユーザーを切り替え", + "user_switched": "ユーザーコンテキストが{{user}}に切り替わりました", + "switch_user_confirm": "ユーザーコンテキストを{{user}}に切り替えますか?", + "add_user": "ユーザーを追加", + "add_new_user": "新しいユーザーを追加", + "new_user_id": "新しいユーザーID", + "new_user_id_placeholder": "一意のユーザーIDを入力", + "user_id_required": "ユーザーIDは必須です", + "user_id_reserved": "'default-user'は予約済みです。別のIDを使用してください", + "user_id_exists": "このユーザーIDはすでに存在します", + "user_id_too_long": "ユーザーIDは50文字を超えられません", + "user_id_invalid_chars": "ユーザーIDには文字、数字、ハイフン、アンダースコアのみ使用できます", + "user_id_rules": "ユーザーIDは一意であり、文字、数字、ハイフン(-)、アンダースコア(_)のみ含む必要があります", + "user_created": "ユーザー{{user}}が作成され、切り替えが成功しました", + "add_user_failed": "ユーザーの追加に失敗しました", + "memory": "個のメモリ", + "reset_user_memories": "ユーザーメモリをリセット", + "reset_memories": "メモリをリセット", + "delete_user": "ユーザーを削除", + "loading_memories": "メモリを読み込み中...", + "no_memories": "メモリがありません", + "no_matching_memories": "一致するメモリが見つかりません", + "no_memories_description": "最初のメモリを追加してください", + "try_different_filters": "検索条件を調整してください", + "add_first_memory": "最初のメモリを追加", + "user_switch_failed": "ユーザーの切り替えに失敗しました", + "cannot_delete_default_user": "デフォルトユーザーは削除できません", + "delete_user_confirm_title": "ユーザーを削除", + "delete_user_confirm_content": "ユーザー{{user}}とそのすべてのメモリを削除してもよろしいですか?", + "user_deleted": "ユーザー{{user}}が正常に削除されました", + "delete_user_failed": "ユーザーの削除に失敗しました", + "reset_user_memories_confirm_title": "ユーザーメモリをリセット", + "reset_user_memories_confirm_content": "{{user}}のすべてのメモリをリセットしてもよろしいですか?", + "user_memories_reset": "{{user}}のすべてのメモリがリセットされました", + "reset_user_memories_failed": "ユーザーメモリのリセットに失敗しました", + "reset_memories_confirm_title": "すべてのメモリをリセット", + "reset_memories_confirm_content": "{{user}}のすべてのメモリを完全に削除してもよろしいですか?この操作は元に戻せません。", + "memories_reset_success": "{{user}}のすべてのメモリが正常にリセットされました", + "reset_memories_failed": "メモリのリセットに失敗しました", + "delete_confirm_single": "このメモリを削除してもよろしいですか?", + "total_memories": "個のメモリ", + "default": "デフォルト", + "custom": "カスタム", + "description": "メモリは、アシスタントとのやりとりに関する情報を保存・管理する機能です。メモリの追加、編集、削除のほか、フィルタリングや検索を行うことができます。", + "global_memory_enabled": "グローバルメモリが有効化されました", + "global_memory": "グローバルメモリ", + "enable_global_memory_first": "最初にグローバルメモリを有効にしてください", + "configure_memory_first": "最初にメモリ設定を構成してください", + "global_memory_disabled_title": "グローバルメモリが無効です", + "global_memory_disabled_desc": "メモリ機能を使用するには、まずアシスタント設定でグローバルメモリを有効にしてください。", + "not_configured_title": "メモリが設定されていません", + "not_configured_desc": "メモリ機能を有効にするには、メモリ設定で埋め込みとLLMモデルを設定してください。", + "go_to_memory_page": "メモリページに移動", + "settings": "設定", + "statistics": "統計", + "search": "検索", + "actions": "アクション", + "user_management": "ユーザー管理", + "initial_memory_content": "ようこそ!これはあなたの最初の記憶です。", + "loading": "思い出を読み込み中...", + "settings_title": "メモリ設定", + "llm_model": "LLMモデル", + "please_select_llm_model": "LLMモデルを選択してください", + "select_llm_model_placeholder": "LLMモデルを選択", + "embedding_model": "埋め込みモデル", + "please_select_embedding_model": "埋め込みモデルを選択してください", + "select_embedding_model_placeholder": "埋め込みモデルを選択", + "embedding_dimensions": "埋め込み次元", + "stored_memories": "保存された記憶" } } -} +} \ No newline at end of file diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 1b5a500b9..9dbe4698e 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -2450,6 +2450,304 @@ "quit": "Выйти", "show_window": "Показать окно", "visualization": "Визуализация" + }, + "update": { + "title": "Обновление", + "message": "Новая версия {{version}} готова, установить сейчас?", + "later": "Позже", + "install": "Установить", + "noReleaseNotes": "Нет заметок об обновлении" + }, + "selection": { + "name": "Помощник выбора", + "action": { + "builtin": { + "translate": "Перевести", + "explain": "Объяснить", + "summary": "Суммаризировать", + "search": "Поиск", + "refine": "Уточнить", + "copy": "Копировать", + "quote": "Цитировать" + }, + "window": { + "pin": "Закрепить", + "pinned": "Закреплено", + "opacity": "Прозрачность окна", + "original_show": "Показать оригинал", + "original_hide": "Скрыть оригинал", + "original_copy": "Копировать оригинал", + "esc_close": "Esc - закрыть", + "esc_stop": "Esc - остановить", + "c_copy": "C - копировать", + "r_regenerate": "R - перегенерировать" + }, + "translate": { + "smart_translate_tips": "Смарт-перевод: содержимое будет переведено на целевой язык; содержимое уже на целевом языке будет переведено на альтернативный язык" + } + }, + "settings": { + "experimental": "Экспериментальные функции", + "enable": { + "title": "Включить", + "description": "Поддерживается только в Windows & macOS", + "mac_process_trust_hint": { + "title": "Права доступа", + "description": [ + "Помощник выбора требует Права доступа для правильной работы.", + "Пожалуйста, перейдите в \"Настройки\" и нажмите \"Открыть системные настройки\" в запросе разрешения, который появится позже. Затем найдите \"Cherry Studio\" в списке приложений, который появится позже, и включите переключатель разрешения.", + "После завершения настроек, пожалуйста, перезапустите помощник выбора." + ], + "button": { + "open_accessibility_settings": "Открыть системные настройки", + "go_to_settings": "Настройки" + } + } + }, + "toolbar": { + "title": "Панель инструментов", + "trigger_mode": { + "title": "Режим активации", + "description": "Показывать панель сразу при выделении, или только при удержании Ctrl, или только при нажатии на сочетание клавиш", + "description_note": { + "windows": "В некоторых приложениях Ctrl может не работать. Если вы используете AHK или другие инструменты для переназначения Ctrl, это может привести к тому, что некоторые приложения не смогут выделить текст.", + "mac": "В некоторых приложениях ⌘ может не работать. Если вы используете сочетания клавиш или инструменты для переназначения ⌘, это может привести к тому, что некоторые приложения не смогут выделить текст." + }, + "selected": "При выделении", + "selected_note": "После выделения", + "ctrlkey": "По Ctrl", + "ctrlkey_note": "После выделения, удерживайте Ctrl для показа панели. Пожалуйста, установите Ctrl в настройках клавиатуры и активируйте его.", + "shortcut": "По сочетанию клавиш", + "shortcut_note": "После выделения, используйте сочетание клавиш для показа панели. Пожалуйста, установите сочетание клавиш в настройках клавиатуры и активируйте его.", + "shortcut_link": "Перейти к настройкам клавиатуры" + }, + "compact_mode": { + "title": "Компактный режим", + "description": "Отображать только иконки без текста" + } + }, + "window": { + "title": "Окно действий", + "follow_toolbar": { + "title": "Следовать за панелью", + "description": "Окно будет следовать за панелью. Иначе - по центру." + }, + "remember_size": { + "title": "Запомнить размер", + "description": "При отключенном режиме, окно будет восстанавливаться до последнего размера при запуске приложения" + }, + "auto_close": { + "title": "Автозакрытие", + "description": "Закрывать окно при потере фокуса (если не закреплено)" + }, + "auto_pin": { + "title": "Автозакрепление", + "description": "Закреплять окно по умолчанию" + }, + "opacity": { + "title": "Прозрачность", + "description": "Установить прозрачность окна по умолчанию" + } + }, + "actions": { + "title": "Действия", + "custom": "Пользовательское действие", + "reset": { + "button": "Сбросить", + "tooltip": "Сбросить стандартные действия. Пользовательские останутся.", + "confirm": "Сбросить стандартные действия? Пользовательские останутся." + }, + "add_tooltip": { + "enabled": "Добавить действие", + "disabled": "Достигнут лимит ({{max}})" + }, + "delete_confirm": "Удалить это действие?", + "drag_hint": "Перетащите для сортировки. Включено: {{enabled}}/{{max}}" + }, + "advanced": { + "title": "Расширенные", + "filter_mode": { + "title": "Режим фильтрации", + "description": "Можно ограничить выборку по определенным приложениям (белый список) или исключить их (черный список)", + "default": "Выключено", + "whitelist": "Белый список", + "blacklist": "Черный список" + }, + "filter_list": { + "title": "Список фильтрации", + "description": "Расширенная функция, рекомендуется для пользователей с опытом" + } + }, + "user_modal": { + "title": { + "add": "Добавить действие", + "edit": "Редактировать действие" + }, + "name": { + "label": "Название", + "hint": "Введите название" + }, + "icon": { + "label": "Иконка", + "placeholder": "Название иконки Lucide", + "error": "Некорректное название", + "tooltip": "Названия в lowercase, например arrow-right", + "view_all": "Все иконки", + "random": "Случайная" + }, + "model": { + "label": "Модель", + "tooltip": "Использовать ассистента: будут применены его системные настройки", + "default": "По умолчанию", + "assistant": "Ассистент" + }, + "assistant": { + "label": "Ассистент", + "default": "По умолчанию" + }, + "prompt": { + "label": "Промпт", + "tooltip": "Дополняет ввод пользователя, не заменяя системный промпт ассистента", + "placeholder": "Используйте {{text}} для выделенного текста. Если пусто - текст будет добавлен", + "placeholder_text": "Плейсхолдер", + "copy_placeholder": "Копировать плейсхолдер" + } + }, + "search_modal": { + "title": "Поисковая система", + "engine": { + "label": "Поисковик", + "custom": "Свой" + }, + "custom": { + "name": { + "label": "Название", + "hint": "Название поисковика", + "max_length": "Не более 16 символов" + }, + "url": { + "label": "URL поиска", + "hint": "Используйте {{queryString}} для представления поискового запроса", + "required": "Введите URL", + "invalid_format": "URL должен начинаться с http:// или https://", + "missing_placeholder": "Должен содержать {{queryString}}" + }, + "test": "Тест" + } + }, + "filter_modal": { + "title": "Список фильтрации", + "user_tips": { + "windows": "Введите имя исполняемого файла приложения, один на строку, не учитывая регистр, можно использовать подстановку *", + "mac": "Введите Bundle ID приложения, один на строку, не учитывая регистр, можно использовать подстановку *" + } + } + } + }, + "memory": { + "add_memory": "Добавить память", + "edit_memory": "Редактировать память", + "memory_content": "Содержимое памяти", + "please_enter_memory": "Пожалуйста, введите содержимое памяти", + "memory_placeholder": "Введите содержимое памяти...", + "user_id": "ID пользователя", + "user_id_placeholder": "Введите ID пользователя (необязательно)", + "load_failed": "Не удалось загрузить память", + "add_success": "Память успешно добавлена", + "add_failed": "Не удалось добавить память", + "update_success": "Память успешно обновлена", + "update_failed": "Не удалось обновить память", + "delete_success": "Память успешно удалена", + "delete_failed": "Не удалось удалить память", + "delete_confirm_title": "Удалить память", + "delete_confirm_content": "Вы уверены, что хотите удалить {{count}} записей памяти?", + "delete_confirm": "Вы уверены, что хотите удалить эту запись памяти?", + "time": "Время", + "user": "Пользователь", + "content": "Содержимое", + "score": "Оценка", + "memories_description": "Показано {{count}} из {{total}} записей памяти", + "search_placeholder": "Поиск памяти...", + "start_date": "Дата начала", + "end_date": "Дата окончания", + "all_users": "Все пользователи", + "users": "пользователи", + "delete_selected": "Удалить выбранные", + "reset_filters": "Сбросить фильтры", + "pagination_total": "{{start}}-{{end}} из {{total}} элементов", + "current_user": "Текущий пользователь", + "select_user": "Выбрать пользователя", + "default_user": "Пользователь по умолчанию", + "switch_user": "Переключить пользователя", + "user_switched": "Контекст пользователя переключен на {{user}}", + "switch_user_confirm": "Переключить контекст пользователя на {{user}}?", + "add_user": "Добавить пользователя", + "add_new_user": "Добавить нового пользователя", + "new_user_id": "Новый ID пользователя", + "new_user_id_placeholder": "Введите уникальный ID пользователя", + "user_id_required": "ID пользователя обязателен", + "user_id_reserved": "'default-user' зарезервирован, используйте другой ID", + "user_id_exists": "Этот ID пользователя уже существует", + "user_id_too_long": "ID пользователя не может превышать 50 символов", + "user_id_invalid_chars": "ID пользователя может содержать только буквы, цифры, дефисы и подчёркивания", + "user_id_rules": "ID пользователя должен быть уникальным и содержать только буквы, цифры, дефисы (-) и подчёркивания (_)", + "user_created": "Пользователь {{user}} создан и переключен успешно", + "add_user_failed": "Не удалось добавить пользователя", + "memory": "воспоминаний", + "reset_user_memories": "Сбросить воспоминания пользователя", + "reset_memories": "Сбросить воспоминания", + "delete_user": "Удалить пользователя", + "loading_memories": "Загрузка воспоминаний...", + "no_memories": "Нет воспоминаний", + "no_matching_memories": "Подходящие воспоминания не найдены", + "no_memories_description": "Начните с добавления вашего первого воспоминания", + "try_different_filters": "Попробуйте изменить критерии поиска", + "add_first_memory": "Добавить первое воспоминание", + "user_switch_failed": "Не удалось переключить пользователя", + "cannot_delete_default_user": "Нельзя удалить пользователя по умолчанию", + "delete_user_confirm_title": "Удалить пользователя", + "delete_user_confirm_content": "Вы уверены, что хотите удалить пользователя {{user}} и все его воспоминания?", + "user_deleted": "Пользователь {{user}} успешно удален", + "delete_user_failed": "Не удалось удалить пользователя", + "reset_user_memories_confirm_title": "Сбросить воспоминания пользователя", + "reset_user_memories_confirm_content": "Вы уверены, что хотите сбросить все воспоминания пользователя {{user}}?", + "user_memories_reset": "Все воспоминания пользователя {{user}} сброшены", + "reset_user_memories_failed": "Не удалось сбросить воспоминания пользователя", + "reset_memories_confirm_title": "Сбросить все воспоминания", + "reset_memories_confirm_content": "Вы уверены, что хотите навсегда удалить все воспоминания пользователя {{user}}? Это действие нельзя отменить.", + "memories_reset_success": "Все воспоминания пользователя {{user}} успешно сброшены", + "reset_memories_failed": "Не удалось сбросить воспоминания", + "delete_confirm_single": "Вы уверены, что хотите удалить это воспоминание?", + "total_memories": "всего воспоминаний", + "default": "По умолчанию", + "custom": "Пользовательский", + "title": "Воспоминания", + "description": "Память позволяет хранить и управлять информацией о ваших взаимодействиях с ассистентом. Вы можете добавлять, редактировать и удалять воспоминания, а также фильтровать и искать их.", + "global_memory_enabled": "Глобальная память включена", + "global_memory": "Глобальная память", + "enable_global_memory_first": "Сначала включите глобальную память", + "configure_memory_first": "Сначала настройте параметры памяти", + "global_memory_disabled_title": "Глобальная память отключена", + "global_memory_disabled_desc": "Чтобы использовать функции памяти, сначала включите глобальную память в настройках ассистента.", + "not_configured_title": "Память не настроена", + "not_configured_desc": "Пожалуйста, настройте модели встраивания и LLM в настройках памяти, чтобы включить функциональность памяти.", + "go_to_memory_page": "Перейти на страницу памяти", + "settings": "Настройки", + "statistics": "Статистика", + "search": "Поиск", + "actions": "Действия", + "user_management": "Управление пользователями", + "initial_memory_content": "Добро пожаловать! Это ваше первое воспоминание.", + "loading": "Загрузка воспоминаний...", + "settings_title": "Настройки памяти", + "llm_model": "Модель LLM", + "please_select_llm_model": "Пожалуйста, выберите модель LLM", + "select_llm_model_placeholder": "Выбор модели LLM", + "embedding_model": "Модель встраивания", + "please_select_embedding_model": "Пожалуйста, выберите модель для внедрения", + "select_embedding_model_placeholder": "Выберите модель внедрения", + "embedding_dimensions": "Размерность вложения", + "stored_memories": "Запасённые воспоминания" } } } diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 902b2362b..5c636eb8b 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -2457,6 +2457,304 @@ "quit": "退出", "show_window": "显示窗口", "visualization": "可视化" + }, + "update": { + "title": "更新提示", + "message": "发现新版本 {{version}},是否立即安装?", + "later": "稍后", + "install": "立即安装", + "noReleaseNotes": "暂无更新日志" + }, + "selection": { + "name": "划词助手", + "action": { + "builtin": { + "translate": "翻译", + "explain": "解释", + "summary": "总结", + "search": "搜索", + "refine": "优化", + "copy": "复制", + "quote": "引用" + }, + "window": { + "pin": "置顶", + "pinned": "已置顶", + "opacity": "窗口透明度", + "original_show": "显示原文", + "original_hide": "隐藏原文", + "original_copy": "复制原文", + "esc_close": "Esc 关闭", + "esc_stop": "Esc 停止", + "c_copy": "C 复制", + "r_regenerate": "R 重新生成" + }, + "translate": { + "smart_translate_tips": "智能翻译:内容将优先翻译为目标语言;内容已是目标语言的,将翻译为备选语言" + } + }, + "settings": { + "experimental": "实验性功能", + "enable": { + "title": "启用", + "description": "当前仅支持 Windows & macOS", + "mac_process_trust_hint": { + "title": "辅助功能权限", + "description": [ + "划词助手需「辅助功能权限」才能正常工作。", + "请点击「去设置」,并在稍后弹出的权限请求弹窗中点击 「打开系统设置」 按钮,然后在之后的应用列表中找到 「Cherry Studio」,并打开权限开关。", + "完成设置后,请再次开启划词助手。" + ], + "button": { + "open_accessibility_settings": "打开辅助功能设置", + "go_to_settings": "去设置" + } + } + }, + "toolbar": { + "title": "工具栏", + "trigger_mode": { + "title": "取词方式", + "description": "划词后,触发取词并显示工具栏的方式", + "description_note": { + "windows": "少数应用不支持通过 Ctrl 键划词。若使用了AHK等按键映射工具对 Ctrl 键进行了重映射,可能导致部分应用无法划词。", + "mac": "若使用了快捷键或键盘映射工具对 ⌘ 键进行了重映射,可能导致部分应用无法划词。" + }, + "selected": "划词", + "selected_note": "划词后立即显示工具栏", + "ctrlkey": "Ctrl 键", + "ctrlkey_note": "划词后,再 长按 Ctrl 键,才显示工具栏", + "shortcut": "快捷键", + "shortcut_note": "划词后,使用快捷键显示工具栏。请在快捷键设置页面中设置取词快捷键并启用。", + "shortcut_link": "前往快捷键设置" + }, + "compact_mode": { + "title": "紧凑模式", + "description": "紧凑模式下,只显示图标,不显示文字" + } + }, + "window": { + "title": "功能窗口", + "follow_toolbar": { + "title": "跟随工具栏", + "description": "窗口位置将跟随工具栏显示,禁用后则始终居中显示" + }, + "remember_size": { + "title": "记住大小", + "description": "应用运行期间,窗口会按上次调整的大小显示" + }, + "auto_close": { + "title": "自动关闭", + "description": "当窗口未置顶且失去焦点时,将自动关闭该窗口" + }, + "auto_pin": { + "title": "自动置顶", + "description": "默认将窗口置于顶部" + }, + "opacity": { + "title": "透明度", + "description": "设置窗口的默认透明度,100% 为完全不透明" + } + }, + "actions": { + "title": "功能", + "custom": "自定义功能", + "reset": { + "button": "重置", + "tooltip": "重置为默认功能,自定义功能不会被删除", + "confirm": "确定要重置为默认功能吗?自定义功能不会被删除。" + }, + "add_tooltip": { + "enabled": "添加自定义功能", + "disabled": "自定义功能已达上限 ({{max}} 个)" + }, + "delete_confirm": "确定要删除这个自定义功能吗?", + "drag_hint": "拖拽排序,移动到上方以启用功能 ({{enabled}}/{{max}})" + }, + "advanced": { + "title": "高级", + "filter_mode": { + "title": "应用筛选", + "description": "可以限制划词助手只在特定应用中生效(白名单)或不生效(黑名单)", + "default": "关闭", + "whitelist": "白名单", + "blacklist": "黑名单" + }, + "filter_list": { + "title": "筛选名单", + "description": "高级功能,建议有经验的用户在了解的情况下再进行设置" + } + }, + "user_modal": { + "title": { + "add": "添加自定义功能", + "edit": "编辑自定义功能" + }, + "name": { + "label": "名称", + "hint": "请输入功能名称" + }, + "icon": { + "label": "图标", + "placeholder": "输入 Lucide 图标名称", + "error": "无效的图标名称,请检查输入", + "tooltip": "Lucide 图标名称为小写,如 arrow-right", + "view_all": "查看所有图标", + "random": "随机图标" + }, + "model": { + "label": "模型", + "tooltip": "使用助手:会同时使用助手的系统提示词和模型参数", + "default": "默认模型", + "assistant": "使用助手" + }, + "assistant": { + "label": "选择助手", + "default": "默认" + }, + "prompt": { + "label": "用户提示词 (Prompt)", + "tooltip": "用户提示词,作为用户输入的补充,不会覆盖助手的系统提示词", + "placeholder": "使用占位符 {{text}} 代表选中的文本,不填写时,选中的文本将添加到本提示词的末尾", + "placeholder_text": "占位符", + "copy_placeholder": "复制占位符" + } + }, + "search_modal": { + "title": "设置搜索引擎", + "engine": { + "label": "搜索引擎", + "custom": "自定义" + }, + "custom": { + "name": { + "label": "自定义名称", + "hint": "请输入搜索引擎名称", + "max_length": "名称不能超过 16 个字符" + }, + "url": { + "label": "自定义搜索 URL", + "hint": "用 {{queryString}} 代表搜索词", + "required": "请输入搜索 URL", + "invalid_format": "请输入以 http:// 或 https:// 开头的有效 URL", + "missing_placeholder": "URL 必须包含 {{queryString}} 占位符" + }, + "test": "测试" + } + }, + "filter_modal": { + "title": "应用筛选名单", + "user_tips": { + "windows": "请输入应用的执行文件名,每行一个,不区分大小写,可以模糊匹配。例如:chrome.exe、weixin.exe、Cherry Studio.exe等", + "mac": "请输入应用的Bundle ID,每行一个,不区分大小写,可以模糊匹配。例如:com.google.Chrome、com.apple.mail等" + } + } + } + }, + "memory": { + "settings": "设置", + "statistics": "统计", + "search": "搜索", + "actions": "操作", + "add_memory": "添加记忆", + "edit_memory": "编辑记忆", + "memory_content": "记忆内容", + "please_enter_memory": "请输入记忆内容", + "memory_placeholder": "输入记忆内容...", + "user_id": "用户ID", + "user_id_placeholder": "输入用户ID(可选)", + "load_failed": "加载记忆失败", + "add_success": "记忆添加成功", + "add_failed": "添加记忆失败", + "update_success": "记忆更新成功", + "update_failed": "更新记忆失败", + "delete_success": "记忆删除成功", + "delete_failed": "删除记忆失败", + "delete_confirm_title": "删除记忆", + "delete_confirm_content": "确定要删除 {{count}} 条记忆吗?", + "delete_confirm": "确定要删除这条记忆吗?", + "time": "时间", + "user": "用户", + "content": "内容", + "score": "分数", + "title": "记忆", + "memories_description": "显示 {{count}} / {{total}} 条记忆", + "search_placeholder": "搜索记忆...", + "start_date": "开始日期", + "end_date": "结束日期", + "all_users": "所有用户", + "users": "用户", + "delete_selected": "删除选中", + "reset_filters": "重置筛选", + "pagination_total": "第 {{start}}-{{end}} 项,共 {{total}} 项", + "current_user": "当前用户", + "select_user": "选择用户", + "default_user": "默认用户", + "switch_user": "切换用户", + "user_switched": "用户上下文已切换到 {{user}}", + "switch_user_confirm": "将用户上下文切换到 {{user}}?", + "add_user": "添加用户", + "add_new_user": "添加新用户", + "new_user_id": "新用户ID", + "new_user_id_placeholder": "输入唯一的用户ID", + "user_management": "用户管理", + "user_id_required": "用户ID为必填项", + "user_id_reserved": "'default-user' 为保留字,请使用其他ID", + "user_id_exists": "该用户ID已存在", + "user_id_too_long": "用户ID不能超过50个字符", + "user_id_invalid_chars": "用户ID只能包含字母、数字、连字符和下划线", + "user_id_rules": "用户ID必须唯一,只能包含字母、数字、连字符(-)和下划线(_)", + "user_created": "用户 {{user}} 创建并切换成功", + "add_user_failed": "添加用户失败", + "memory": "条记忆", + "reset_user_memories": "重置用户记忆", + "reset_memories": "重置记忆", + "delete_user": "删除用户", + "loading_memories": "正在加载记忆...", + "no_memories": "暂无记忆", + "no_matching_memories": "未找到匹配的记忆", + "no_memories_description": "开始添加您的第一条记忆吧", + "try_different_filters": "尝试调整搜索条件", + "add_first_memory": "添加您的第一条记忆", + "user_switch_failed": "切换用户失败", + "cannot_delete_default_user": "不能删除默认用户", + "delete_user_confirm_title": "删除用户", + "delete_user_confirm_content": "确定要删除用户 {{user}} 及其所有记忆吗?", + "user_deleted": "用户 {{user}} 删除成功", + "delete_user_failed": "删除用户失败", + "reset_user_memories_confirm_title": "重置用户记忆", + "reset_user_memories_confirm_content": "确定要重置 {{user}} 的所有记忆吗?", + "user_memories_reset": "{{user}} 的所有记忆已重置", + "reset_user_memories_failed": "重置用户记忆失败", + "reset_memories_confirm_title": "重置所有记忆", + "reset_memories_confirm_content": "确定要永久删除 {{user}} 的所有记忆吗?此操作无法撤销。", + "memories_reset_success": "{{user}} 的所有记忆已成功重置", + "reset_memories_failed": "重置记忆失败", + "delete_confirm_single": "确定要删除这条记忆吗?", + "total_memories": "条记忆", + "default": "默认", + "custom": "自定义", + "description": "记忆功能允许您存储和管理与助手交互的信息。您可以添加、编辑和删除记忆,也可以对它们进行过滤和搜索。", + "global_memory_enabled": "全局记忆已启用", + "global_memory": "全局记忆", + "enable_global_memory_first": "请先启用全局记忆", + "configure_memory_first": "请先配置记忆设置", + "global_memory_disabled_title": "全局记忆已禁用", + "global_memory_disabled_desc": "要使用记忆功能,请先在助手设置中启用全局记忆。", + "not_configured_title": "记忆未配置", + "not_configured_desc": "请在记忆设置中配置嵌入和LLM模型以启用记忆功能。", + "go_to_memory_page": "前往记忆页面", + "initial_memory_content": "欢迎!这是您的第一条记忆。", + "loading": "正在加载记忆...", + "settings_title": "记忆设置", + "llm_model": "LLM 模型", + "please_select_llm_model": "请选择 LLM 模型", + "select_llm_model_placeholder": "选择 LLM 模型", + "embedding_model": "嵌入模型", + "please_select_embedding_model": "请选择嵌入模型", + "select_embedding_model_placeholder": "选择嵌入模型", + "embedding_dimensions": "嵌入维度", + "stored_memories": "已存储记忆" } } -} +} \ No newline at end of file diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 334634954..af1b21fe2 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -2450,6 +2450,304 @@ "quit": "結束", "show_window": "顯示視窗", "visualization": "視覺化" + }, + "update": { + "title": "更新提示", + "message": "新版本 {{version}} 已準備就緒,是否立即安裝?", + "later": "稍後", + "install": "立即安裝", + "noReleaseNotes": "暫無更新日誌" + }, + "selection": { + "name": "劃詞助手", + "action": { + "builtin": { + "translate": "翻譯", + "explain": "解釋", + "summary": "總結", + "search": "搜尋", + "refine": "優化", + "copy": "複製", + "quote": "引用" + }, + "window": { + "pin": "置頂", + "pinned": "已置頂", + "opacity": "視窗透明度", + "original_show": "顯示原文", + "original_hide": "隱藏原文", + "original_copy": "複製原文", + "esc_close": "Esc 關閉", + "esc_stop": "Esc 停止", + "c_copy": "C 複製", + "r_regenerate": "R 重新生成" + }, + "translate": { + "smart_translate_tips": "智能翻譯:內容將優先翻譯為目標語言;內容已是目標語言的,將翻譯為備用語言" + } + }, + "settings": { + "experimental": "實驗性功能", + "enable": { + "title": "啟用", + "description": "目前僅支援 Windows & macOS", + "mac_process_trust_hint": { + "title": "輔助使用權限", + "description": [ + "劃詞助手需「輔助使用權限」才能正常工作。", + "請點擊「去設定」,並在稍後彈出的權限請求彈窗中點擊 「打開系統設定」 按鈕,然後在之後的應用程式列表中找到 「Cherry Studio」,並開啟權限開關。", + "完成設定後,請再次開啟劃詞助手。" + ], + "button": { + "open_accessibility_settings": "打開輔助使用設定", + "go_to_settings": "去設定" + } + } + }, + "toolbar": { + "title": "工具列", + "trigger_mode": { + "title": "取詞方式", + "description": "劃詞後,觸發取詞並顯示工具列的方式", + "description_note": { + "windows": "在某些應用中可能無法透過 Ctrl 鍵劃詞。若使用了 AHK 等工具對 Ctrl 鍵進行了重新對應,可能導致部分應用程式無法劃詞。", + "mac": "若使用了快捷鍵或鍵盤映射工具對 ⌘ 鍵進行了重新對應,可能導致部分應用程式無法劃詞。" + }, + "selected": "劃詞", + "selected_note": "劃詞後,立即顯示工具列", + "ctrlkey": "Ctrl 鍵", + "ctrlkey_note": "劃詞後,再 按住 Ctrl 鍵,才顯示工具列", + "shortcut": "快捷鍵", + "shortcut_note": "劃詞後,使用快捷鍵顯示工具列。請在快捷鍵設定頁面中設置取詞快捷鍵並啟用。", + "shortcut_link": "前往快捷鍵設定" + }, + "compact_mode": { + "title": "緊湊模式", + "description": "緊湊模式下,只顯示圖示,不顯示文字" + } + }, + "window": { + "title": "功能視窗", + "follow_toolbar": { + "title": "跟隨工具列", + "description": "視窗位置將跟隨工具列顯示,停用後則始終置中顯示" + }, + "remember_size": { + "title": "記住大小", + "description": "應用運行期間,視窗會按上次調整的大小顯示" + }, + "auto_close": { + "title": "自動關閉", + "description": "當視窗未置頂且失去焦點時,將自動關閉該視窗" + }, + "auto_pin": { + "title": "自動置頂", + "description": "預設將視窗置於頂部" + }, + "opacity": { + "title": "透明度", + "description": "設置視窗的預設透明度,100% 為完全不透明" + } + }, + "actions": { + "title": "功能", + "custom": "自訂功能", + "reset": { + "button": "重設", + "tooltip": "重設為預設功能,自訂功能不會被刪除", + "confirm": "確定要重設為預設功能嗎?自訂功能不會被刪除。" + }, + "add_tooltip": { + "enabled": "新增自訂功能", + "disabled": "自訂功能已達上限 ({{max}} 個)" + }, + "delete_confirm": "確定要刪除這個自訂功能嗎?", + "drag_hint": "拖曳排序,移動到上方以啟用功能 ({{enabled}}/{{max}})" + }, + "advanced": { + "title": "進階", + "filter_mode": { + "title": "應用篩選", + "description": "可以限制劃詞助手只在特定應用中生效(白名單)或不生效(黑名單)", + "default": "關閉", + "whitelist": "白名單", + "blacklist": "黑名單" + }, + "filter_list": { + "title": "篩選名單", + "description": "進階功能,建議有經驗的用戶在了解情況下再進行設置" + } + }, + "user_modal": { + "title": { + "add": "新增自訂功能", + "edit": "編輯自訂功能" + }, + "name": { + "label": "名稱", + "hint": "請輸入功能名稱" + }, + "icon": { + "label": "圖示", + "placeholder": "輸入 Lucide 圖示名稱", + "error": "無效的圖示名稱,請檢查輸入", + "tooltip": "Lucide 圖示名稱為小寫,如 arrow-right", + "view_all": "檢視所有圖示", + "random": "隨機圖示" + }, + "model": { + "label": "模型", + "tooltip": "使用助手:會同時使用助手的系統提示詞和模型參數", + "default": "預設模型", + "assistant": "使用助手" + }, + "assistant": { + "label": "選擇助手", + "default": "預設" + }, + "prompt": { + "label": "使用者提示詞 (Prompt)", + "tooltip": "使用者提示詞,作為使用者輸入的補充,不會覆蓋助手的系統提示詞", + "placeholder": "使用佔位符 {{text}} 代表選取的文字,不填寫時,選取的文字將加到本提示詞的末尾", + "placeholder_text": "佔位符", + "copy_placeholder": "複製佔位符" + } + }, + "search_modal": { + "title": "設定搜尋引擎", + "engine": { + "label": "搜尋引擎", + "custom": "自訂" + }, + "custom": { + "name": { + "label": "自訂名稱", + "hint": "請輸入搜尋引擎名稱", + "max_length": "名稱不能超過 16 個字元" + }, + "url": { + "label": "自訂搜尋 URL", + "hint": "使用 {{queryString}} 代表搜尋詞", + "required": "請輸入搜尋 URL", + "invalid_format": "請輸入以 http:// 或 https:// 開頭的有效 URL", + "missing_placeholder": "URL 必須包含 {{queryString}} 佔位符" + }, + "test": "測試" + } + }, + "filter_modal": { + "title": "應用篩選名單", + "user_tips": { + "windows": "請輸入應用的執行檔名稱,每行一個,不區分大小寫,可以模糊匹配。例如:chrome.exe、weixin.exe、Cherry Studio.exe等", + "mac": "請輸入應用的 Bundle ID,每行一個,不區分大小寫,可以模糊匹配。例如:com.google.Chrome、com.apple.mail等" + } + } + } + }, + "memory": { + "add_memory": "新增記憶", + "edit_memory": "編輯記憶", + "memory_content": "記憶內容", + "please_enter_memory": "請輸入記憶內容", + "memory_placeholder": "輸入記憶內容...", + "user_id": "使用者ID", + "user_id_placeholder": "輸入使用者ID(可選)", + "load_failed": "載入記憶失敗", + "add_success": "記憶新增成功", + "add_failed": "新增記憶失敗", + "update_success": "記憶更新成功", + "update_failed": "更新記憶失敗", + "delete_success": "記憶刪除成功", + "delete_failed": "刪除記憶失敗", + "delete_confirm_title": "刪除記憶", + "delete_confirm_content": "確定要刪除 {{count}} 條記憶嗎?", + "delete_confirm": "確定要刪除這條記憶嗎?", + "time": "時間", + "user": "使用者", + "content": "內容", + "score": "分數", + "title": "記憶", + "memories_description": "顯示 {{count}} / {{total}} 條記憶", + "search_placeholder": "搜尋記憶...", + "start_date": "開始日期", + "end_date": "結束日期", + "all_users": "所有使用者", + "users": "使用者", + "delete_selected": "刪除選取", + "reset_filters": "重設篩選", + "pagination_total": "第 {{start}}-{{end}} 項,共 {{total}} 項", + "current_user": "目前使用者", + "select_user": "選擇使用者", + "default_user": "預設使用者", + "switch_user": "切換使用者", + "user_switched": "使用者內容已切換至 {{user}}", + "switch_user_confirm": "將使用者內容切換至 {{user}}?", + "add_user": "新增使用者", + "add_new_user": "新增新使用者", + "new_user_id": "新使用者ID", + "new_user_id_placeholder": "輸入唯一的使用者ID", + "user_id_required": "使用者ID為必填欄位", + "user_id_reserved": "'default-user' 為保留字,請使用其他ID", + "user_id_exists": "此使用者ID已存在", + "user_id_too_long": "使用者ID不能超過50個字元", + "user_id_invalid_chars": "使用者ID只能包含字母、數字、連字符和底線", + "user_id_rules": "使用者ID必须唯一,只能包含字母、數字、連字符(-)和底線(_)", + "user_created": "使用者 {{user}} 建立並切換成功", + "add_user_failed": "新增使用者失敗", + "memory": "個記憶", + "reset_user_memories": "重置使用者記憶", + "reset_memories": "重置記憶", + "delete_user": "刪除使用者", + "loading_memories": "正在載入記憶...", + "no_memories": "暫無記憶", + "no_matching_memories": "未找到符合的記憶", + "no_memories_description": "開始新增您的第一個記憶吧", + "try_different_filters": "嘗試調整搜尋條件", + "add_first_memory": "新增您的第一個記憶", + "user_switch_failed": "切換使用者失敗", + "cannot_delete_default_user": "不能刪除預設使用者", + "delete_user_confirm_title": "刪除使用者", + "delete_user_confirm_content": "確定要刪除使用者 {{user}} 及其所有記憶嗎?", + "user_deleted": "使用者 {{user}} 刪除成功", + "delete_user_failed": "刪除使用者失敗", + "reset_user_memories_confirm_title": "重置使用者記憶", + "reset_user_memories_confirm_content": "確定要重置 {{user}} 的所有記憶嗎?", + "user_memories_reset": "{{user}} 的所有記憶已重置", + "reset_user_memories_failed": "重置使用者記憶失敗", + "reset_memories_confirm_title": "重置所有記憶", + "reset_memories_confirm_content": "確定要永久刪除 {{user}} 的所有記憶嗎?此操作無法復原。", + "memories_reset_success": "{{user}} 的所有記憶已成功重置", + "reset_memories_failed": "重置記憶失敗", + "delete_confirm_single": "確定要刪除這個記憶嗎?", + "total_memories": "個記憶", + "default": "預設", + "custom": "自定義", + "description": "記憶功能讓您儲存和管理與助手互動的資訊。您可以新增、編輯和刪除記憶,也可以對它們進行篩選和搜尋。", + "global_memory_enabled": "全域記憶已啟用", + "global_memory": "全域記憶", + "enable_global_memory_first": "請先啟用全域記憶", + "configure_memory_first": "請先配置記憶設定", + "global_memory_disabled_title": "全域記憶已停用", + "global_memory_disabled_desc": "要使用記憶功能,請先在助手設定中啟用全域記憶。", + "not_configured_title": "記憶未配置", + "not_configured_desc": "請在記憶設定中配置嵌入和LLM模型以啟用記憶功能。", + "go_to_memory_page": "前往記憶頁面", + "settings": "設定", + "statistics": "統計", + "search": "搜尋", + "actions": "操作", + "user_management": "使用者管理", + "initial_memory_content": "歡迎!這是你的第一個記憶。", + "loading": "載入記憶中...", + "settings_title": "記憶體設定", + "llm_model": "LLM 模型", + "please_select_llm_model": "請選擇一個LLM模型", + "select_llm_model_placeholder": "選擇LLM模型", + "embedding_model": "嵌入模型", + "please_select_embedding_model": "請選擇一個嵌入模型", + "select_embedding_model_placeholder": "選擇嵌入模型", + "embedding_dimensions": "嵌入維度", + "stored_memories": "儲存的記憶" } } } diff --git a/src/renderer/src/pages/home/Messages/Blocks/CitationBlock.tsx b/src/renderer/src/pages/home/Messages/Blocks/CitationBlock.tsx index f86dd6de1..3f96c6f99 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/CitationBlock.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/CitationBlock.tsx @@ -23,9 +23,10 @@ function CitationBlock({ block }: { block: CitationMessageBlock }) { return ( (formattedCitations && formattedCitations.length > 0) || hasGeminiBlock || - (block.knowledge && block.knowledge.length > 0) + (block.knowledge && block.knowledge.length > 0) || + (block.memories && block.memories.length > 0) ) - }, [formattedCitations, block.knowledge, hasGeminiBlock]) + }, [formattedCitations, block.knowledge, block.memories, hasGeminiBlock]) const getWebSearchStatusText = (requestId: string) => { const status = websearch.activeSearches[requestId] ?? { phase: 'default' } diff --git a/src/renderer/src/pages/home/Messages/CitationsList.tsx b/src/renderer/src/pages/home/Messages/CitationsList.tsx index b3dcffebd..8174f556b 100644 --- a/src/renderer/src/pages/home/Messages/CitationsList.tsx +++ b/src/renderer/src/pages/home/Messages/CitationsList.tsx @@ -46,14 +46,20 @@ const CitationsList: React.FC = ({ citations }) => { const popoverContent = ( {citations.map((citation) => ( - - {citation.type === 'websearch' ? ( + + {citation.type === 'websearch' && ( - ) : ( + )} + {citation.type === 'memory' && ( - + + + )} + {citation.type === 'knowledge' && ( + + )} diff --git a/src/renderer/src/pages/knowledge/components/QuotaTag.tsx b/src/renderer/src/pages/knowledge/components/QuotaTag.tsx index 2f811d363..fb3f60fe5 100644 --- a/src/renderer/src/pages/knowledge/components/QuotaTag.tsx +++ b/src/renderer/src/pages/knowledge/components/QuotaTag.tsx @@ -43,7 +43,7 @@ const QuotaTag: FC<{ base: KnowledgeBase; providerId: string; quota?: number }> return } checkQuota() - }, [_quota, base, provider.id, provider.apiKey]) + }, [_quota, base, provider.id, provider.apiKey, provider, quota, updateProvider]) return ( <> diff --git a/src/renderer/src/pages/memory/index.tsx b/src/renderer/src/pages/memory/index.tsx new file mode 100644 index 000000000..bd5af9ad5 --- /dev/null +++ b/src/renderer/src/pages/memory/index.tsx @@ -0,0 +1,1083 @@ +import { + CalendarOutlined, + DeleteOutlined, + EditOutlined, + ExclamationCircleOutlined, + MoreOutlined, + PlusOutlined, + ReloadOutlined, + SettingOutlined, + UserAddOutlined, + UserDeleteOutlined, + UserOutlined +} from '@ant-design/icons' +import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar' +import { Center } from '@renderer/components/Layout' +import Scrollbar from '@renderer/components/Scrollbar' +import MemoryService from '@renderer/services/MemoryService' +import { + selectCurrentUserId, + selectGlobalMemoryEnabled, + setCurrentUserId, + setGlobalMemoryEnabled +} from '@renderer/store/memory' +import { MemoryItem, ThemeMode } from '@types' +import { + Avatar, + Button, + Card, + Dropdown, + Empty, + Form, + Input, + Modal, + Pagination, + Select, + Space, + Spin, + Switch +} from 'antd' +import dayjs from 'dayjs' +import relativeTime from 'dayjs/plugin/relativeTime' +import { Brain, Users } from 'lucide-react' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useDispatch, useSelector } from 'react-redux' +import styled from 'styled-components' + +import { SettingDivider, SettingRow, SettingRowTitle, SettingTitle } from '../settings' +import MemoriesSettingsModal from './settings-modal' + +dayjs.extend(relativeTime) + +const DEFAULT_USER_ID = 'default-user' + +const { Option } = Select +const { TextArea } = Input + +interface AddMemoryModalProps { + visible: boolean + onCancel: () => void + onAdd: (memory: string) => Promise +} + +interface EditMemoryModalProps { + visible: boolean + memory: MemoryItem | null + onCancel: () => void + onUpdate: (id: string, memory: string, metadata?: Record) => Promise +} + +interface AddUserModalProps { + visible: boolean + onCancel: () => void + onAdd: (userId: string) => void + existingUsers: string[] +} + +const AddMemoryModal: React.FC = ({ visible, onCancel, onAdd }) => { + const [form] = Form.useForm() + const [loading, setLoading] = useState(false) + const { t } = useTranslation() + + const handleSubmit = async (values: { memory: string }) => { + setLoading(true) + try { + await onAdd(values.memory) + form.resetFields() + onCancel() + } finally { + setLoading(false) + } + } + + return ( + form.submit()} + okButtonProps={{ loading: loading }} + title={ + + + {t('memory.add_memory')} + + } + styles={{ + header: { + borderBottom: '0.5px solid var(--color-border)', + paddingBottom: 16, + borderBottomLeftRadius: 0, + borderBottomRightRadius: 0 + }, + body: { + paddingTop: 20 + } + }}> +
+ + + ))} {(editedBlocks.some((block) => block.type === MessageBlockType.FILE || block.type === MessageBlockType.IMAGE) || files.length > 0) && ( @@ -359,14 +353,9 @@ const MessageBlockEditor: FC = ({ message, topicId, onSave, onResend, onC ) } -const EditorContainer = styled.div` - padding: 18px 0; - padding-bottom: 5px; - border: 0.5px solid var(--color-border); +const EditorContainer = styled(Space)` + margin: 15px 0 5px 0; transition: all 0.2s ease; - border-radius: 15px; - margin-top: 18px; - background-color: var(--color-background-opacity); width: 100%; &.file-dragging { @@ -385,6 +374,22 @@ const EditorContainer = styled.div` pointer-events: none; } } + + .editing-message { + background-color: var(--color-background-opacity); + border: 0.5px solid var(--color-border); + border-radius: 15px; + padding: 0.5em 1em; + flex: 1; + font-family: Ubuntu; + resize: none !important; + overflow: auto; + width: 100%; + box-sizing: border-box; + &.ant-input { + line-height: 1.4; + } + } ` const FileBlocksContainer = styled.div` @@ -397,21 +402,6 @@ const FileBlocksContainer = styled.div` border-radius: 4px; ` -const Textarea = styled(TextArea)` - padding: 0; - border-radius: 0; - display: flex; - flex: 1; - font-family: Ubuntu; - resize: none !important; - overflow: auto; - width: 100%; - box-sizing: border-box; - &.ant-input { - line-height: 1.4; - } -` - const ActionBar = styled.div` display: flex; padding: 0 8px; From d0649d29fb12d4b7ecdef931542c4707bad7582b Mon Sep 17 00:00:00 2001 From: SuYao Date: Wed, 23 Jul 2025 10:12:00 +0800 Subject: [PATCH 088/596] feat(MCP): outputschema (#7881) * feat(MCP): outputschema * fix: mark outputschema optional * fix: upgrade zod v4 --- package.json | 3 ++- src/main/mcpServers/dify-knowledge.ts | 11 +++----- src/main/mcpServers/filesystem.ts | 29 +++++++++------------- src/renderer/src/i18n/translate/el-gr.json | 16 ++++++------ src/renderer/src/i18n/translate/es-es.json | 16 ++++++------ src/renderer/src/i18n/translate/fr-fr.json | 16 ++++++------ src/renderer/src/i18n/translate/pt-pt.json | 16 ++++++------ src/renderer/src/types/index.ts | 8 ++++++ yarn.lock | 8 ++++++ 9 files changed, 65 insertions(+), 58 deletions(-) diff --git a/package.json b/package.json index c36b0e36f..6ddbaccc6 100644 --- a/package.json +++ b/package.json @@ -257,7 +257,8 @@ "winston": "^3.17.0", "winston-daily-rotate-file": "^5.0.0", "word-extractor": "^1.0.4", - "zipread": "^1.3.3" + "zipread": "^1.3.3", + "zod": "^3.25.74" }, "optionalDependencies": { "@cherrystudio/mac-system-ocr": "^0.2.2" diff --git a/src/main/mcpServers/dify-knowledge.ts b/src/main/mcpServers/dify-knowledge.ts index db62f6cdd..e3f8c84b2 100644 --- a/src/main/mcpServers/dify-knowledge.ts +++ b/src/main/mcpServers/dify-knowledge.ts @@ -1,9 +1,8 @@ // inspired by https://dify.ai/blog/turn-your-dify-app-into-an-mcp-server import { loggerService } from '@logger' import { Server } from '@modelcontextprotocol/sdk/server/index.js' -import { CallToolRequestSchema, ListToolsRequestSchema, ToolSchema } from '@modelcontextprotocol/sdk/types.js' -import { z } from 'zod' -import { zodToJsonSchema } from 'zod-to-json-schema' +import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js' +import * as z from 'zod/v4' const logger = loggerService.withContext('DifyKnowledgeServer') @@ -39,10 +38,6 @@ interface DifySearchKnowledgeResponse { }> } -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const ToolInputSchema = ToolSchema.shape.inputSchema -type ToolInput = z.infer - const SearchKnowledgeArgsSchema = z.object({ id: z.string().describe('Knowledge ID'), query: z.string().describe('Query string'), @@ -96,7 +91,7 @@ class DifyKnowledgeServer { { name: 'search_knowledge', description: 'Search knowledge by id and query', - inputSchema: zodToJsonSchema(SearchKnowledgeArgsSchema) as ToolInput + inputSchema: SearchKnowledgeArgsSchema } ] } diff --git a/src/main/mcpServers/filesystem.ts b/src/main/mcpServers/filesystem.ts index ff72973c2..6858b8d82 100644 --- a/src/main/mcpServers/filesystem.ts +++ b/src/main/mcpServers/filesystem.ts @@ -2,14 +2,13 @@ import { loggerService } from '@logger' import { Server } from '@modelcontextprotocol/sdk/server/index.js' -import { CallToolRequestSchema, ListToolsRequestSchema, ToolSchema } from '@modelcontextprotocol/sdk/types.js' +import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js' import { createTwoFilesPatch } from 'diff' import fs from 'fs/promises' import { minimatch } from 'minimatch' import os from 'os' import path from 'path' -import { z } from 'zod' -import { zodToJsonSchema } from 'zod-to-json-schema' +import * as z from 'zod/v4' const logger = loggerService.withContext('MCP:FileSystemServer') @@ -120,10 +119,6 @@ const GetFileInfoArgsSchema = z.object({ path: z.string() }) -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const ToolInputSchema = ToolSchema.shape.inputSchema -type ToolInput = z.infer - interface FileInfo { size: number created: Date @@ -345,7 +340,7 @@ class FileSystemServer { 'Handles various text encodings and provides detailed error messages ' + 'if the file cannot be read. Use this tool when you need to examine ' + 'the contents of a single file. Only works within allowed directories.', - inputSchema: zodToJsonSchema(ReadFileArgsSchema) as ToolInput + inputSchema: ReadFileArgsSchema }, { name: 'read_multiple_files', @@ -355,7 +350,7 @@ class FileSystemServer { "or compare multiple files. Each file's content is returned with its " + "path as a reference. Failed reads for individual files won't stop " + 'the entire operation. Only works within allowed directories.', - inputSchema: zodToJsonSchema(ReadMultipleFilesArgsSchema) as ToolInput + inputSchema: ReadMultipleFilesArgsSchema }, { name: 'write_file', @@ -363,7 +358,7 @@ class FileSystemServer { 'Create a new file or completely overwrite an existing file with new content. ' + 'Use with caution as it will overwrite existing files without warning. ' + 'Handles text content with proper encoding. Only works within allowed directories.', - inputSchema: zodToJsonSchema(WriteFileArgsSchema) as ToolInput + inputSchema: WriteFileArgsSchema }, { name: 'edit_file', @@ -371,7 +366,7 @@ class FileSystemServer { 'Make line-based edits to a text file. Each edit replaces exact line sequences ' + 'with new content. Returns a git-style diff showing the changes made. ' + 'Only works within allowed directories.', - inputSchema: zodToJsonSchema(EditFileArgsSchema) as ToolInput + inputSchema: EditFileArgsSchema }, { name: 'create_directory', @@ -380,7 +375,7 @@ class FileSystemServer { 'nested directories in one operation. If the directory already exists, ' + 'this operation will succeed silently. Perfect for setting up directory ' + 'structures for projects or ensuring required paths exist. Only works within allowed directories.', - inputSchema: zodToJsonSchema(CreateDirectoryArgsSchema) as ToolInput + inputSchema: CreateDirectoryArgsSchema }, { name: 'list_directory', @@ -389,7 +384,7 @@ class FileSystemServer { 'Results clearly distinguish between files and directories with [FILE] and [DIR] ' + 'prefixes. This tool is essential for understanding directory structure and ' + 'finding specific files within a directory. Only works within allowed directories.', - inputSchema: zodToJsonSchema(ListDirectoryArgsSchema) as ToolInput + inputSchema: ListDirectoryArgsSchema }, { name: 'directory_tree', @@ -398,7 +393,7 @@ class FileSystemServer { "Each entry includes 'name', 'type' (file/directory), and 'children' for directories. " + 'Files have no children array, while directories always have a children array (which may be empty). ' + 'The output is formatted with 2-space indentation for readability. Only works within allowed directories.', - inputSchema: zodToJsonSchema(DirectoryTreeArgsSchema) as ToolInput + inputSchema: DirectoryTreeArgsSchema }, { name: 'move_file', @@ -407,7 +402,7 @@ class FileSystemServer { 'and rename them in a single operation. If the destination exists, the ' + 'operation will fail. Works across different directories and can be used ' + 'for simple renaming within the same directory. Both source and destination must be within allowed directories.', - inputSchema: zodToJsonSchema(MoveFileArgsSchema) as ToolInput + inputSchema: MoveFileArgsSchema }, { name: 'search_files', @@ -417,7 +412,7 @@ class FileSystemServer { 'is case-insensitive and matches partial names. Returns full paths to all ' + "matching items. Great for finding files when you don't know their exact location. " + 'Only searches within allowed directories.', - inputSchema: zodToJsonSchema(SearchFilesArgsSchema) as ToolInput + inputSchema: SearchFilesArgsSchema }, { name: 'get_file_info', @@ -426,7 +421,7 @@ class FileSystemServer { 'information including size, creation time, last modified time, permissions, ' + 'and type. This tool is perfect for understanding file characteristics ' + 'without reading the actual content. Only works within allowed directories.', - inputSchema: zodToJsonSchema(GetFileInfoArgsSchema) as ToolInput + inputSchema: GetFileInfoArgsSchema }, { name: 'list_allowed_directories', diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index 94869ef5f..38d62cd6d 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -289,12 +289,6 @@ "topics.unpinned": "Αποστέλλω", "translate": "Μετάφραση" }, - "html_artifacts": { - "code": "Κώδικας", - "generating": "Δημιουργία", - "preview": "Προεπισκόπηση", - "split": "Διαχωρισμός" - }, "code_block": { "collapse": "συμπεριληφθείς", "disable_wrap": "ακύρωση αλλαγής γραμμής", @@ -428,6 +422,12 @@ "search.topics.empty": "Δεν βρέθηκαν σχετικά θέματα, πατήστε Enter για να αναζητήσετε όλα τα μηνύματα", "title": "Αναζήτηση θεμάτων" }, + "html_artifacts": { + "code": "Κώδικας", + "generating": "Δημιουργία", + "preview": "Προεπισκόπηση", + "split": "Διαχωρισμός" + }, "knowledge": { "add": { "title": "Προσθήκη βιβλιοθήκης γνώσεων" @@ -645,9 +645,9 @@ "close": "Κλείσιμο της εφαρμογής", "devtools": "Εργαλεία προγραμματιστή", "minimize": "Ελαχιστοποίηση της εφαρμογής", + "openExternal": "Άνοιγμα στον περιηγητή", "open_link_external_off": "Τρέχον: Άνοιγμα συνδέσμου χρησιμοποιώντας το προεπιλεγμένο παράθυρο", "open_link_external_on": "Τρέχον: Άνοιγμα συνδέσμου στον περιηγητή", - "openExternal": "Άνοιγμα στον περιηγητή", "refresh": "Ανανέωση", "rightclick_copyurl": "Αντιγραφή URL με δεξί κλικ" }, @@ -905,9 +905,9 @@ }, "settings": { "about": "Περί μας", - "about.checkingUpdate": "Ελέγχω ενημερώσεις...", "about.checkUpdate": "Έλεγχος ενημερώσεων", "about.checkUpdate.available": "Άμεση ενημέρωση", + "about.checkingUpdate": "Ελέγχω ενημερώσεις...", "about.contact.button": "Ταχυδρομείο", "about.contact.title": "Επικοινωνία μέσω ταχυδρομείου", "about.description": "Ένα AI ασιστάντα που έχει σχεδιαστεί για δημιουργούς", diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index a14d35a89..1fd8428c7 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -290,12 +290,6 @@ "topics.unpinned": "Quitar fijación", "translate": "Traducir" }, - "html_artifacts": { - "code": "Código", - "generating": "Generando", - "preview": "Vista previa", - "split": "Dividir" - }, "code_block": { "collapse": "Replegar", "disable_wrap": "Deshabilitar salto de línea", @@ -429,6 +423,12 @@ "search.topics.empty": "No se encontraron temas relacionados, presione Enter para buscar todos los mensajes", "title": "Búsqueda de temas" }, + "html_artifacts": { + "code": "Código", + "generating": "Generando", + "preview": "Vista previa", + "split": "Dividir" + }, "knowledge": { "add": { "title": "Agregar base de conocimientos" @@ -646,9 +646,9 @@ "close": "Cerrar la aplicación", "devtools": "Herramientas de desarrollo", "minimize": "Minimizar la aplicación", + "openExternal": "Abrir en el navegador", "open_link_external_off": "Actual: Abrir enlaces en ventana predeterminada", "open_link_external_on": "Actual: Abrir enlaces en el navegador", - "openExternal": "Abrir en el navegador", "refresh": "Actualizar", "rightclick_copyurl": "Copiar URL con clic derecho" }, @@ -906,9 +906,9 @@ }, "settings": { "about": "Acerca de nosotros", - "about.checkingUpdate": "Verificando actualizaciones...", "about.checkUpdate": "Comprobar actualizaciones", "about.checkUpdate.available": "Actualizar ahora", + "about.checkingUpdate": "Verificando actualizaciones...", "about.contact.button": "Correo electrónico", "about.contact.title": "Contacto por correo electrónico", "about.description": "Una asistente de IA creada para los creadores", diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index 1dcd5731f..4e2c2a609 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -289,12 +289,6 @@ "topics.unpinned": "Annuler le fixage", "translate": "Traduire" }, - "html_artifacts": { - "code": "Code", - "generating": "Génération", - "preview": "Aperçu", - "split": "Diviser" - }, "code_block": { "collapse": "Réduire", "disable_wrap": "Désactiver le retour à la ligne", @@ -428,6 +422,12 @@ "search.topics.empty": "Aucun sujet correspondant trouvé, appuyez sur Entrée pour rechercher tous les messages", "title": "Recherche de sujets" }, + "html_artifacts": { + "code": "Code", + "generating": "Génération", + "preview": "Aperçu", + "split": "Diviser" + }, "knowledge": { "add": { "title": "Ajouter une base de connaissances" @@ -645,9 +645,9 @@ "close": "Закрыть мини-программу", "devtools": "Инструменты разработчика", "minimize": "Свернуть мини-программу", + "openExternal": "Открыть в браузере", "open_link_external_off": "Текущий: открывать ссылки в окне по умолчанию", "open_link_external_on": "Текущий: открывать ссылки в браузере", - "openExternal": "Открыть в браузере", "refresh": "Обновить", "rightclick_copyurl": "Скопировать URL через правую кнопку мыши" }, @@ -905,9 +905,9 @@ }, "settings": { "about": "À propos de nous", - "about.checkingUpdate": "Vérification des mises à jour en cours...", "about.checkUpdate": "Vérifier les mises à jour", "about.checkUpdate.available": "Mettre à jour maintenant", + "about.checkingUpdate": "Vérification des mises à jour en cours...", "about.contact.button": "Courriel", "about.contact.title": "Contactez-nous par courriel", "about.description": "Un assistant IA conçu pour les créateurs", diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index f062782dd..9a89762c2 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -291,12 +291,6 @@ "topics.unpinned": "Desfixar", "translate": "Traduzir" }, - "html_artifacts": { - "code": "Código", - "generating": "Gerando", - "preview": "Visualizar", - "split": "Dividir" - }, "code_block": { "collapse": "Recolher", "disable_wrap": "Desativar quebra de linha", @@ -430,6 +424,12 @@ "search.topics.empty": "Nenhum tópico relacionado encontrado, clique em Enter para procurar todas as mensagens", "title": "Procurar Tópicos" }, + "html_artifacts": { + "code": "Código", + "generating": "Gerando", + "preview": "Visualizar", + "split": "Dividir" + }, "knowledge": { "add": { "title": "Adicionar Base de Conhecimento" @@ -647,9 +647,9 @@ "close": "Fechar aplicativo", "devtools": "Ferramentas de Desenvolvedor", "minimize": "Minimizar aplicativo", + "openExternal": "Abrir no navegador", "open_link_external_off": "Atual: Abrir links em janela padrão", "open_link_external_on": "Atual: Abrir links no navegador", - "openExternal": "Abrir no navegador", "refresh": "Atualizar", "rightclick_copyurl": "Copiar URL com botão direito" }, @@ -906,9 +906,9 @@ }, "settings": { "about": "Sobre Nós", - "about.checkingUpdate": "Verificando atualizações...", "about.checkUpdate": "Verificar atualizações", "about.checkUpdate.available": "Atualizar agora", + "about.checkingUpdate": "Verificando atualizações...", "about.contact.button": "E-mail", "about.contact.title": "Contato por e-mail", "about.description": "Um assistente de IA criado para criadores", diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index 77305c74c..8b45d67f7 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -2,6 +2,7 @@ import type { WebSearchResultBlock } from '@anthropic-ai/sdk/resources' import type { GenerateImagesConfig, GroundingMetadata, PersonGeneration } from '@google/genai' import type OpenAI from 'openai' import type { CSSProperties } from 'react' +import * as z from 'zod/v4' export * from './file' import type { FileMetadata } from './file' @@ -657,6 +658,12 @@ export interface MCPToolInputSchema { properties: Record } +export const MCPToolOutputSchema = z.object({ + type: z.literal('object'), + properties: z.record(z.string(), z.unknown()), + required: z.array(z.string()) +}) + export interface MCPTool { id: string serverId: string @@ -664,6 +671,7 @@ export interface MCPTool { name: string description?: string inputSchema: MCPToolInputSchema + outputSchema?: z.infer } export interface MCPPromptArguments { diff --git a/yarn.lock b/yarn.lock index 37b7bd3e1..e8f72b551 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7307,6 +7307,7 @@ __metadata: winston-daily-rotate-file: "npm:^5.0.0" word-extractor: "npm:^1.0.4" zipread: "npm:^1.3.3" + zod: "npm:^3.25.74" dependenciesMeta: "@cherrystudio/mac-system-ocr": optional: true @@ -21068,6 +21069,13 @@ __metadata: languageName: node linkType: hard +"zod@npm:^3.25.74": + version: 3.25.74 + resolution: "zod@npm:3.25.74" + checksum: 10c0/59e38b046ac333b5bd1ba325a83b6798721227cbfb1e69dfc7159bd7824b904241ab923026edb714fafefec3624265ae374a70aee9a5a45b365bd31781ffa105 + languageName: node + linkType: hard + "zustand@npm:^4.4.0": version: 4.5.6 resolution: "zustand@npm:4.5.6" From 736f73a72679de603167c8ea07bfbef6b1bf1559 Mon Sep 17 00:00:00 2001 From: one Date: Wed, 23 Jul 2025 10:45:09 +0800 Subject: [PATCH 089/596] refactor: match provider and model using a consistent method (#7933) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: match provider and model using a consistent method * refactor: use keywords matching across model selectors * refactor: update match, reuse getFancyProviderName * refactor: use modelSelectFilter in knowledgebase settings * refactor: use filter in ModelList * refactor: add filterModelsByKeywords * refactor: add getModelSelectOptions * style: better function names * fix: update effect dependencies in popup and panel components Adjusted dependency arrays in HtmlArtifactsPopup and QuickPanelView to ensure correct effect execution. This change improves state synchronization and prevents unnecessary updates. * refactor: use match in memory settings * refactor: add avatar to model selector * refactor: simplify utils, move select options to components * docs: add comments * refactor: move filter to SelectOptions * test: add tests for SelectOptions * test: remove type mock * refactor: use match in EditModelsPopup * refactor: use SelectOptions in SelectProviderModelPopup, add more tests * fix: api check model select * refactor: improve websearch rag model select style * refactor: add a ModelSelector * test: update tests for ModelSelector * docs: comments --------- Co-authored-by: 自由的世界人 <3196812536@qq.com> --- README.md | 2 +- docs/technical/how-to-use-logger-en.md | 7 +- docs/technical/how-to-use-logger-zh.md | 13 +- .../components/ModelList/EditModelsPopup.tsx | 62 ++--- .../src/components/ModelList/ModelList.tsx | 5 +- src/renderer/src/components/ModelSelector.tsx | 123 ++++++++++ .../Popups/SelectModelPopup/popup.tsx | 20 +- .../__tests__/ModelSelector.test.tsx | 225 ++++++++++++++++++ .../home/Inputbar/MentionModelsButton.tsx | 9 +- .../home/Inputbar/MentionModelsInput.tsx | 5 +- .../components/AddKnowledgePopup.tsx | 49 +--- .../components/KnowledgeSettings.tsx | 40 +--- .../src/pages/memory/settings-modal.tsx | 47 ++-- .../MemorySettings/MemoriesSettingsModal.tsx | 47 ++-- .../settings/ModelSettings/ModelSettings.tsx | 48 ++-- .../SelectProviderModelPopup.tsx | 34 ++- .../pages/settings/ProviderSettings/index.tsx | 28 +-- .../CompressionSettings/RagSettings.tsx | 66 ++--- .../CompressionSettings/index.tsx | 5 +- .../src/pages/translate/TranslatePage.tsx | 55 ++--- src/renderer/src/services/ModelService.ts | 4 +- .../src/utils/__tests__/match.test.ts | 140 +++++++++++ src/renderer/src/utils/index.ts | 1 + src/renderer/src/utils/match.ts | 80 +++++++ 24 files changed, 763 insertions(+), 352 deletions(-) create mode 100644 src/renderer/src/components/ModelSelector.tsx create mode 100644 src/renderer/src/components/__tests__/ModelSelector.test.tsx create mode 100644 src/renderer/src/utils/__tests__/match.test.ts create mode 100644 src/renderer/src/utils/match.ts diff --git a/README.md b/README.md index 98a8bdafa..763ff5e54 100644 --- a/README.md +++ b/README.md @@ -247,7 +247,7 @@ The Enterprise Edition addresses core challenges in team collaboration by centra | Feature | Community Edition | Enterprise Edition | | :---------------- | :----------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------- | -| **Open Source** | ✅ Yes | ⭕️ Partially released to customers | +| **Open Source** | ✅ Yes | ⭕️ Partially released to customers | | **Cost** | Free for Personal Use / Commercial License | Buyout / Subscription Fee | | **Admin Backend** | — | ● Centralized **Model** Access
● **Employee** Management
● Shared **Knowledge Base**
● **Access** Control
● **Data** Backup | | **Server** | — | ✅ Dedicated Private Deployment | diff --git a/docs/technical/how-to-use-logger-en.md b/docs/technical/how-to-use-logger-en.md index d208b7c55..4af08d25e 100644 --- a/docs/technical/how-to-use-logger-en.md +++ b/docs/technical/how-to-use-logger-en.md @@ -145,8 +145,8 @@ In a development environment, you can define environment variables to filter dis Environment variables can be set in the terminal or defined in the `.env` file in the project's root directory. The available variables are as follows: -| Variable Name | Description | -| ------- | ------- | +| Variable Name | Description | +| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | CSLOGGER_MAIN_LEVEL | Log level for the `main` process. Logs below this level will not be displayed. | | CSLOGGER_MAIN_SHOW_MODULES | Filters log modules for the `main` process. Use a comma (`,`) to separate modules. The filter is case-sensitive. Only logs from modules in this list will be displayed. | | CSLOGGER_RENDERER_LEVEL | Log level for the `renderer` process. Logs below this level will not be displayed. | @@ -160,6 +160,7 @@ CSLOGGER_MAIN_SHOW_MODULES=MCPService,SelectionService ``` Note: + - Environment variables are only effective in the development environment. - These variables only affect the logs displayed in the terminal or DevTools. They do not affect file logging or the `logToMain` recording logic. @@ -168,7 +169,7 @@ Note: There are many log levels. The following are the guidelines that should be followed in CherryStudio for when to use each level: (Arranged from highest to lowest log level) -| Log Level | Core Definition & Use case | Example | +| Log Level | Core Definition & Use case | Example | | :------------ | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **`error`** | **Critical error causing the program to crash or core functionality to become unusable.**
This is the highest-priority log, usually requiring immediate reporting or user notification. | - Main or renderer process crash.
- Failure to read/write critical user data files (e.g., database, configuration files), preventing the application from running.
- All unhandled exceptions. | | **`warn`** | **Potential issue or unexpected situation that does not affect the program's core functionality.**
The program can recover or use a fallback. | - Configuration file `settings.json` is missing; started with default settings.
- Auto-update check failed, but does not affect the use of the current version.
- A non-essential plugin failed to load. | diff --git a/docs/technical/how-to-use-logger-zh.md b/docs/technical/how-to-use-logger-zh.md index 9082027b9..04cf80fe0 100644 --- a/docs/technical/how-to-use-logger-zh.md +++ b/docs/technical/how-to-use-logger-zh.md @@ -145,12 +145,12 @@ const logger = loggerService.initWindowSource('Worker').withContext('LetsWork') 环境变量可以在终端中自行设置,或者在开发根目录的`.env`文件中进行定义,可以定义的变量如下: -| 变量名 | 含义 | -| ----- | ----- | -| CSLOGGER_MAIN_LEVEL | 用于`main`进程的日志级别,低于该级别的日志将不显示 | -| CSLOGGER_MAIN_SHOW_MODULES | 用于`main`进程的日志module筛选,用`,`分隔,区分大小写。只有在该列表中的module的日志才会显示 | -| CSLOGGER_RENDERER_LEVEL | 用于`renderer`进程的日志级别,低于该级别的日志将不显示 | -| CSLOGGER_RENDERER_SHOW_MODULES | 用于`renderer`进程的日志module筛选,用`,`分隔,区分大小写。只有在该列表中的module的日志才会显示 | +| 变量名 | 含义 | +| ------------------------------ | ----------------------------------------------------------------------------------------------- | +| CSLOGGER_MAIN_LEVEL | 用于`main`进程的日志级别,低于该级别的日志将不显示 | +| CSLOGGER_MAIN_SHOW_MODULES | 用于`main`进程的日志module筛选,用`,`分隔,区分大小写。只有在该列表中的module的日志才会显示 | +| CSLOGGER_RENDERER_LEVEL | 用于`renderer`进程的日志级别,低于该级别的日志将不显示 | +| CSLOGGER_RENDERER_SHOW_MODULES | 用于`renderer`进程的日志module筛选,用`,`分隔,区分大小写。只有在该列表中的module的日志才会显示 | 示例: @@ -160,6 +160,7 @@ CSLOGGER_MAIN_SHOW_MODULES=MCPService,SelectionService ``` 注意: + - 环境变量仅在开发环境中生效 - 该变量仅会改变在终端或在devTools中显示的日志,不会影响文件日志和`logToMain`的记录逻辑 diff --git a/src/renderer/src/components/ModelList/EditModelsPopup.tsx b/src/renderer/src/components/ModelList/EditModelsPopup.tsx index ecd8694d8..9431a9c2e 100644 --- a/src/renderer/src/components/ModelList/EditModelsPopup.tsx +++ b/src/renderer/src/components/ModelList/EditModelsPopup.tsx @@ -23,7 +23,13 @@ import { useProvider } from '@renderer/hooks/useProvider' import FileItem from '@renderer/pages/files/FileItem' import { fetchModels } from '@renderer/services/ApiService' import { Model, Provider } from '@renderer/types' -import { getDefaultGroupName, isFreeModel, runAsyncFunction } from '@renderer/utils' +import { + filterModelsByKeywords, + getDefaultGroupName, + getFancyProviderName, + isFreeModel, + runAsyncFunction +} from '@renderer/utils' import { Avatar, Button, Empty, Flex, Modal, Spin, Tabs, Tooltip } from 'antd' import Input from 'antd/es/input/Input' import { groupBy, isEmpty, uniqBy } from 'lodash' @@ -86,34 +92,30 @@ const PopupContainer: React.FC = ({ provider: _provider, resolve }) => { const systemModels = SYSTEM_MODELS[_provider.id] || [] const allModels = uniqBy([...systemModels, ...listModels, ...models], 'id') - const list = allModels.filter((model) => { - if ( - filterSearchText && - !model.id.toLocaleLowerCase().includes(filterSearchText.toLocaleLowerCase()) && - !model.name?.toLocaleLowerCase().includes(filterSearchText.toLocaleLowerCase()) - ) { - return false - } - - switch (actualFilterType) { - case 'reasoning': - return isReasoningModel(model) - case 'vision': - return isVisionModel(model) - case 'websearch': - return isWebSearchModel(model) - case 'free': - return isFreeModel(model) - case 'embedding': - return isEmbeddingModel(model) - case 'function_calling': - return isFunctionCallingModel(model) - case 'rerank': - return isRerankModel(model) - default: - return true - } - }) + const list = useMemo( + () => + filterModelsByKeywords(filterSearchText, allModels).filter((model) => { + switch (actualFilterType) { + case 'reasoning': + return isReasoningModel(model) + case 'vision': + return isVisionModel(model) + case 'websearch': + return isWebSearchModel(model) + case 'free': + return isFreeModel(model) + case 'embedding': + return isEmbeddingModel(model) + case 'function_calling': + return isFunctionCallingModel(model) + case 'rerank': + return isRerankModel(model) + default: + return true + } + }), + [filterSearchText, actualFilterType, allModels] + ) const modelGroups = useMemo( () => @@ -202,7 +204,7 @@ const PopupContainer: React.FC = ({ provider: _provider, resolve }) => { return ( - {provider.isSystem ? t(`provider.${provider.id}`) : provider.name} + {getFancyProviderName(provider)} {i18n.language.startsWith('zh') ? '' : ' '} {t('common.models')} diff --git a/src/renderer/src/components/ModelList/ModelList.tsx b/src/renderer/src/components/ModelList/ModelList.tsx index fa629c022..b9c54d0c9 100644 --- a/src/renderer/src/components/ModelList/ModelList.tsx +++ b/src/renderer/src/components/ModelList/ModelList.tsx @@ -12,6 +12,7 @@ import { SettingHelpLink, SettingHelpText, SettingHelpTextRow, SettingSubtitle } import { useAppDispatch } from '@renderer/store' import { setModel } from '@renderer/store/assistants' import { Model } from '@renderer/types' +import { filterModelsByKeywords } from '@renderer/utils' import { Button, Flex, Tooltip } from 'antd' import { groupBy, sortBy, toPairs } from 'lodash' import { ListCheck, Plus } from 'lucide-react' @@ -51,9 +52,7 @@ const ModelList: React.FC = ({ providerId }) => { }, []) const modelGroups = useMemo(() => { - const filteredModels = searchText - ? models.filter((model) => model.name.toLowerCase().includes(searchText.toLowerCase())) - : models + const filteredModels = searchText ? filterModelsByKeywords(searchText, models) : models return groupBy(filteredModels, 'group') }, [searchText, models]) diff --git a/src/renderer/src/components/ModelSelector.tsx b/src/renderer/src/components/ModelSelector.tsx new file mode 100644 index 000000000..f8b1a37ca --- /dev/null +++ b/src/renderer/src/components/ModelSelector.tsx @@ -0,0 +1,123 @@ +import ModelAvatar from '@renderer/components/Avatar/ModelAvatar' +import { getModelUniqId } from '@renderer/services/ModelService' +import { Model, Provider } from '@renderer/types' +import { matchKeywordsInString } from '@renderer/utils' +import { getFancyProviderName } from '@renderer/utils/naming' +import { Select, SelectProps } from 'antd' +import { sortBy } from 'lodash' +import { BaseSelectRef } from 'rc-select' +import { memo, useCallback, useMemo } from 'react' + +interface ModelOption { + label: React.ReactNode + title: string + value: string +} + +interface GroupedModelOption { + label: string + title: string + options: ModelOption[] +} + +type SelectOption = ModelOption | GroupedModelOption + +interface ModelSelectorProps extends SelectProps { + providers?: Provider[] + predicate?: (model: Model) => boolean + grouped?: boolean + showAvatar?: boolean + showSuffix?: boolean +} + +/** + * 模型选择器,封装了 antd Select + * - 通过传入模型服务商列表和模型 predicate 来构造选项 + * - 支持按服务商分组 + * - 可以控制 avatar 和 suffix 显示与否 + * @param providers 服务商列表 + * @param predicate 模型过滤条件 + * @param grouped 是否按服务商分组 + * @param showAvatar 是否显示模型图标 + * @param showSuffix 是否在模型名称后显示服务商作为后缀 + */ +const ModelSelector = ({ + providers, + predicate, + grouped = true, + showAvatar = true, + showSuffix = true, + ref, + ...props +}: ModelSelectorProps & { ref?: React.Ref | null }) => { + // 单个 provider 的模型选项 + const getModelOptions = useCallback( + (p: Provider, fancyName: string) => { + const suffix = showSuffix ? {` | ${fancyName}`} : null + return sortBy(p.models, 'name') + .filter((model) => predicate?.(model) ?? true) + .map((m) => ({ + label: ( +
+ {showAvatar && } + + {m.name} + {suffix} + +
+ ), + title: `${m.name} | ${fancyName}`, + value: getModelUniqId(m) + })) + }, + [predicate, showAvatar, showSuffix] + ) + + // 所有 provider 的模型选项 + const options = useMemo((): SelectOption[] => { + if (!providers) return [] + + if (grouped) { + return providers.flatMap((p) => { + const fancyName = getFancyProviderName(p) + const modelOptions = getModelOptions(p, fancyName) + return modelOptions.length > 0 + ? [ + { + label: fancyName, + title: p.name, + options: modelOptions + } as GroupedModelOption + ] + : [] + }) + } + return providers.flatMap((p) => getModelOptions(p, getFancyProviderName(p))) + }, [providers, grouped, getModelOptions]) + + return { const model = value @@ -313,9 +279,10 @@ const PopupContainer: React.FC = ({ title, resolve }) => { - = ({ base: _base, resolve }) => { - +
- + - setDefaultModel(find(allModels, JSON.parse(value)) as Model)} - options={selectOptions} - showSearch placeholder={t('settings.models.empty')} /> + ) + )} diff --git a/src/renderer/src/pages/home/Chat.tsx b/src/renderer/src/pages/home/Chat.tsx index 0860440b6..d3aec9df9 100644 --- a/src/renderer/src/pages/home/Chat.tsx +++ b/src/renderer/src/pages/home/Chat.tsx @@ -1,20 +1,22 @@ import { loggerService } from '@logger' import { ContentSearch, ContentSearchRef } from '@renderer/components/ContentSearch' +import { HStack } from '@renderer/components/Layout' import MultiSelectActionPopup from '@renderer/components/Popups/MultiSelectionPopup' import { QuickPanelProvider } from '@renderer/components/QuickPanel' import { useAssistant } from '@renderer/hooks/useAssistant' import { useChatContext } from '@renderer/hooks/useChatContext' -import { useSettings } from '@renderer/hooks/useSettings' +import { useNavbarPosition, useSettings } from '@renderer/hooks/useSettings' import { useShortcut } from '@renderer/hooks/useShortcuts' -import { useShowTopics } from '@renderer/hooks/useStore' +import { useShowAssistants, useShowTopics } from '@renderer/hooks/useStore' import { Assistant, Topic } from '@renderer/types' import { classNames } from '@renderer/utils' import { Flex } from 'antd' import { debounce } from 'lodash' -import React, { FC, useMemo, useState } from 'react' +import React, { FC, useState } from 'react' import { useHotkeys } from 'react-hotkeys-hook' import styled from 'styled-components' +import ChatNavbar from './ChatNavbar' import Inputbar from './Inputbar/Inputbar' import Messages from './Messages/Messages' import Tabs from './Tabs' @@ -30,20 +32,16 @@ interface Props { const Chat: FC = (props) => { const { assistant } = useAssistant(props.assistant.id) - const { topicPosition, messageStyle, showAssistants } = useSettings() + const { topicPosition, messageStyle } = useSettings() const { showTopics } = useShowTopics() const { isMultiSelectMode } = useChatContext(props.activeTopic) + const { isTopNavbar } = useNavbarPosition() const mainRef = React.useRef(null) const contentSearchRef = React.useRef(null) const [filterIncludeUser, setFilterIncludeUser] = useState(false) - const maxWidth = useMemo(() => { - const showRightTopics = showTopics && topicPosition === 'right' - const minusAssistantsWidth = showAssistants ? '- var(--assistants-width)' : '' - const minusRightTopicsWidth = showRightTopics ? '- var(--assistants-width)' : '' - return `calc(100vw - var(--sidebar-width) ${minusAssistantsWidth} ${minusRightTopicsWidth})` - }, [showAssistants, showTopics, topicPosition]) + const maxWidth = useChatMaxWidth() useHotkeys('esc', () => { contentSearchRef.current?.disable() @@ -92,61 +90,103 @@ const Chat: FC = (props) => { const firstUpdateOrNoFirstUpdateHandler = debounce(() => { contentSearchRef.current?.silentSearch() }, 10) + const messagesComponentUpdateHandler = () => { if (firstUpdateCompleted) { firstUpdateOrNoFirstUpdateHandler() } } + const messagesComponentFirstUpdateHandler = () => { setTimeout(() => (firstUpdateCompleted = true), 300) firstUpdateOrNoFirstUpdateHandler() } + const mainHeight = isTopNavbar + ? 'calc(100vh - var(--navbar-height) - var(--navbar-height) - 12px)' + : 'calc(100vh - var(--navbar-height))' + return ( -
- - } - filter={contentSearchFilter} - includeUser={filterIncludeUser} - onIncludeUserChange={userOutlinedItemClickHandler} - /> - - - {isMultiSelectMode && } - -
- {topicPosition === 'right' && showTopics && ( - )} + +
+ + } + filter={contentSearchFilter} + includeUser={filterIncludeUser} + onIncludeUserChange={userOutlinedItemClickHandler} + /> + + + {isMultiSelectMode && } + +
+ {topicPosition === 'right' && showTopics && ( + + )} +
) } +export const useChatMaxWidth = () => { + const { showTopics, topicPosition } = useSettings() + const { isLeftNavbar } = useNavbarPosition() + const { showAssistants } = useShowAssistants() + const showRightTopics = showTopics && topicPosition === 'right' + const minusAssistantsWidth = showAssistants ? '- var(--assistants-width)' : '' + const minusRightTopicsWidth = showRightTopics ? '- var(--assistants-width)' : '' + return `calc(100vw - ${isLeftNavbar ? 'var(--sidebar-width)' : '0'} ${minusAssistantsWidth} ${minusRightTopicsWidth})` +} + const Container = styled.div` display: flex; - flex-direction: row; - height: 100%; + flex-direction: column; + height: calc(100vh - var(--navbar-height)); flex: 1; + [navbar-position='top'] & { + height: calc(100vh - var(--navbar-height) -6px); + background-color: var(--color-background); + border-top-left-radius: 10px; + border-bottom-left-radius: 10px; + overflow: hidden; + } ` const Main = styled(Flex)` - height: calc(100vh - var(--navbar-height)); + [navbar-position='left'] & { + height: calc(100vh - var(--navbar-height)); + } transform: translateZ(0); position: relative; ` diff --git a/src/renderer/src/pages/home/ChatNavbar.tsx b/src/renderer/src/pages/home/ChatNavbar.tsx new file mode 100644 index 000000000..0172153e3 --- /dev/null +++ b/src/renderer/src/pages/home/ChatNavbar.tsx @@ -0,0 +1,183 @@ +import { NavbarHeader } from '@renderer/components/app/Navbar' +import { HStack } from '@renderer/components/Layout' +import SearchPopup from '@renderer/components/Popups/SearchPopup' +import { isMac } from '@renderer/config/constant' +import { useAssistant } from '@renderer/hooks/useAssistant' +import { useFullscreen } from '@renderer/hooks/useFullscreen' +import { modelGenerating } from '@renderer/hooks/useRuntime' +import { useSettings } from '@renderer/hooks/useSettings' +import { useShortcut } from '@renderer/hooks/useShortcuts' +import { useShowAssistants, useShowTopics } from '@renderer/hooks/useStore' +import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' +import { useAppDispatch } from '@renderer/store' +import { setNarrowMode } from '@renderer/store/settings' +import { Assistant, Topic } from '@renderer/types' +import { Tooltip } from 'antd' +import { t } from 'i18next' +import { Menu, PanelLeftClose, PanelRightClose, Search } from 'lucide-react' +import { FC, useCallback } from 'react' +import styled from 'styled-components' + +import AssistantsDrawer from './components/AssistantsDrawer' +import SelectModelButton from './components/SelectModelButton' +import UpdateAppButton from './components/UpdateAppButton' + +interface Props { + activeAssistant: Assistant + activeTopic: Topic + setActiveTopic: (topic: Topic) => void + setActiveAssistant: (assistant: Assistant) => void + position: 'left' | 'right' +} + +const HeaderNavbar: FC = ({ activeAssistant, setActiveAssistant, activeTopic, setActiveTopic }) => { + const { assistant } = useAssistant(activeAssistant.id) + const { showAssistants, toggleShowAssistants } = useShowAssistants() + const isFullscreen = useFullscreen() + const { topicPosition, narrowMode } = useSettings() + const { showTopics, toggleShowTopics } = useShowTopics() + const dispatch = useAppDispatch() + + // Function to toggle assistants with cooldown + const handleToggleShowAssistants = useCallback(() => { + if (showAssistants) { + toggleShowAssistants() + } else { + toggleShowAssistants() + } + }, [showAssistants, toggleShowAssistants]) + + const handleToggleShowTopics = useCallback(() => { + if (showTopics) { + toggleShowTopics() + } else { + toggleShowTopics() + } + }, [showTopics, toggleShowTopics]) + + useShortcut('toggle_show_assistants', handleToggleShowAssistants) + + useShortcut('toggle_show_topics', () => { + if (topicPosition === 'right') { + toggleShowTopics() + } else { + EventEmitter.emit(EVENT_NAMES.SHOW_TOPIC_SIDEBAR) + } + }) + + useShortcut('search_message', () => { + SearchPopup.show() + }) + + const handleNarrowModeToggle = async () => { + await modelGenerating() + dispatch(setNarrowMode(!narrowMode)) + } + + const onShowAssistantsDrawer = () => { + AssistantsDrawer.show({ + activeAssistant, + setActiveAssistant, + activeTopic, + setActiveTopic + }) + } + + return ( + + + {showAssistants && ( + + + + + + )} + {!showAssistants && ( + + toggleShowAssistants()} + style={{ marginRight: 8, marginLeft: isMac && !isFullscreen ? 4 : -12 }}> + + + + )} + {!showAssistants && ( + + + + )} + + + + + + SearchPopup.show()}> + + + + + + + + + {topicPosition === 'right' && !showTopics && ( + + toggleShowTopics()}> + + + + )} + {topicPosition === 'right' && showTopics && ( + + handleToggleShowTopics()}> + + + + )} + + + ) +} + +export const NavbarIcon = styled.div` + -webkit-app-region: none; + border-radius: 8px; + height: 30px; + padding: 0 7px; + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + transition: all 0.2s ease-in-out; + cursor: pointer; + .iconfont { + font-size: 18px; + color: var(--color-icon); + &.icon-a-addchat { + font-size: 20px; + } + &.icon-a-darkmode { + font-size: 20px; + } + &.icon-appstore { + font-size: 20px; + } + } + .anticon { + color: var(--color-icon); + font-size: 16px; + } + &:hover { + background-color: var(--color-background-mute); + color: var(--color-icon-white); + } +` + +const NarrowIcon = styled(NavbarIcon)` + @media (max-width: 1000px) { + display: none; + } +` + +export default HeaderNavbar diff --git a/src/renderer/src/pages/home/HomePage.tsx b/src/renderer/src/pages/home/HomePage.tsx index 78ac37bde..66aa73c85 100644 --- a/src/renderer/src/pages/home/HomePage.tsx +++ b/src/renderer/src/pages/home/HomePage.tsx @@ -1,5 +1,5 @@ import { useAssistants } from '@renderer/hooks/useAssistant' -import { useSettings } from '@renderer/hooks/useSettings' +import { useNavbarPosition, useSettings } from '@renderer/hooks/useSettings' import { useActiveTopic } from '@renderer/hooks/useTopic' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import NavigationService from '@renderer/services/NavigationService' @@ -17,6 +17,7 @@ let _activeAssistant: Assistant const HomePage: FC = () => { const { assistants } = useAssistants() const navigate = useNavigate() + const { isLeftNavbar } = useNavbarPosition() const location = useLocation() const state = location.state @@ -81,14 +82,16 @@ const HomePage: FC = () => { return ( - - + {isLeftNavbar && ( + + )} + {showAssistants && ( { [isGrid, isGrouped, topic, multiModelMessageStyle, messages.length, selectedMessageId, gridPopoverTrigger] ) + const maxWidth = useChatMaxWidth() + return ( + className={classNames([multiModelMessageStyle, { 'multi-select-mode': isMultiSelectMode }])} + style={{ maxWidth }}> { } const GroupContainer = styled.div` + [navbar-position='left'] & { + max-width: calc(100vw - var(--sidebar-width) - var(--assistants-width) - 20px); + } &.horizontal, &.grid { padding: 4px 10px; diff --git a/src/renderer/src/pages/home/Messages/Messages.tsx b/src/renderer/src/pages/home/Messages/Messages.tsx index 968c4152d..8e0532d7a 100644 --- a/src/renderer/src/pages/home/Messages/Messages.tsx +++ b/src/renderer/src/pages/home/Messages/Messages.tsx @@ -379,7 +379,7 @@ const LoaderContainer = styled.div` const ScrollContainer = styled.div` display: flex; flex-direction: column-reverse; - padding: 10px 16px 20px; + padding: 10px 10px 20px; .multi-select-mode & { padding-bottom: 60px; } diff --git a/src/renderer/src/pages/home/Navbar.tsx b/src/renderer/src/pages/home/Navbar.tsx index 4ef2c7e67..7e65c25cf 100644 --- a/src/renderer/src/pages/home/Navbar.tsx +++ b/src/renderer/src/pages/home/Navbar.tsx @@ -1,6 +1,5 @@ import { Navbar, NavbarLeft, NavbarRight } from '@renderer/components/app/Navbar' import { HStack } from '@renderer/components/Layout' -import FloatingSidebar from '@renderer/components/Popups/FloatingSidebar' import SearchPopup from '@renderer/components/Popups/SearchPopup' import { isMac } from '@renderer/config/constant' import { useAssistant } from '@renderer/hooks/useAssistant' @@ -15,10 +14,11 @@ import { setNarrowMode } from '@renderer/store/settings' import { Assistant, Topic } from '@renderer/types' import { Tooltip } from 'antd' import { t } from 'i18next' -import { MessageSquareDiff, PanelLeftClose, PanelRightClose, Search } from 'lucide-react' -import { FC, useCallback, useState } from 'react' +import { Menu, MessageSquareDiff, PanelLeftClose, PanelRightClose, Search } from 'lucide-react' +import { FC, useCallback } from 'react' import styled from 'styled-components' +import AssistantsDrawer from './components/AssistantsDrawer' import SelectModelButton from './components/SelectModelButton' import UpdateAppButton from './components/UpdateAppButton' @@ -37,32 +37,20 @@ const HeaderNavbar: FC = ({ activeAssistant, setActiveAssistant, activeTo const { topicPosition, narrowMode } = useSettings() const { showTopics, toggleShowTopics } = useShowTopics() const dispatch = useAppDispatch() - const [sidebarHideCooldown, setSidebarHideCooldown] = useState(false) // Function to toggle assistants with cooldown const handleToggleShowAssistants = useCallback(() => { if (showAssistants) { - // When hiding sidebar, set cooldown toggleShowAssistants() - setSidebarHideCooldown(true) - // setTimeout(() => { - // setSidebarHideCooldown(false) - // }, 10000) // 10 seconds cooldown } else { - // When showing sidebar, no cooldown needed toggleShowAssistants() } }, [showAssistants, toggleShowAssistants]) + const handleToggleShowTopics = useCallback(() => { if (showTopics) { - // When hiding sidebar, set cooldown toggleShowTopics() - setSidebarHideCooldown(true) - // setTimeout(() => { - // setSidebarHideCooldown(false) - // }, 10000) // 10 seconds cooldown } else { - // When showing sidebar, no cooldown needed toggleShowTopics() } }, [showTopics, toggleShowTopics]) @@ -86,6 +74,15 @@ const HeaderNavbar: FC = ({ activeAssistant, setActiveAssistant, activeTo dispatch(setNarrowMode(!narrowMode)) } + const onShowAssistantsDrawer = () => { + AssistantsDrawer.show({ + activeAssistant, + setActiveAssistant, + activeTopic, + setActiveTopic + }) + } + return ( {showAssistants && ( @@ -104,32 +101,20 @@ const HeaderNavbar: FC = ({ activeAssistant, setActiveAssistant, activeTo )} - {!showAssistants && !sidebarHideCooldown && ( - - - toggleShowAssistants()} - style={{ marginRight: 8, marginLeft: isMac && !isFullscreen ? 4 : -12 }}> - - - - - )} - {!showAssistants && sidebarHideCooldown && ( + {!showAssistants && ( toggleShowAssistants()} - style={{ marginRight: 8, marginLeft: isMac && !isFullscreen ? 4 : -12 }} - onMouseOut={() => setSidebarHideCooldown(false)}> + style={{ marginRight: 8, marginLeft: isMac && !isFullscreen ? 4 : -12 }}> )} + {!showAssistants && ( + + + + )} @@ -144,23 +129,9 @@ const HeaderNavbar: FC = ({ activeAssistant, setActiveAssistant, activeTo - {topicPosition === 'right' && !showTopics && !sidebarHideCooldown && ( - - - toggleShowTopics()}> - - - - - )} - {topicPosition === 'right' && !showTopics && sidebarHideCooldown && ( + {topicPosition === 'right' && !showTopics && ( - toggleShowTopics()} onMouseOut={() => setSidebarHideCooldown(false)}> + toggleShowTopics()}> diff --git a/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx b/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx index 20e3456be..e4e25a131 100644 --- a/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx +++ b/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx @@ -185,15 +185,11 @@ const AssistantAddItem = styled.div` padding-right: 35px; border-radius: var(--list-item-border-radius); border: 0.5px solid transparent; + margin-top: -8px; cursor: pointer; &:hover { - background-color: var(--color-background-soft); - } - - &.active { - background-color: var(--color-background-soft); - border: 0.5px solid var(--color-border); + background-color: var(--color-list-item-hover); } ` diff --git a/src/renderer/src/pages/home/Tabs/TopicsTab.tsx b/src/renderer/src/pages/home/Tabs/TopicsTab.tsx index d296ccdcf..1f3997ff8 100644 --- a/src/renderer/src/pages/home/Tabs/TopicsTab.tsx +++ b/src/renderer/src/pages/home/Tabs/TopicsTab.tsx @@ -5,6 +5,7 @@ import { EditOutlined, FolderOutlined, MenuOutlined, + PlusOutlined, PushpinOutlined, QuestionCircleOutlined, UploadOutlined @@ -24,7 +25,7 @@ import store from '@renderer/store' import { RootState } from '@renderer/store' import { setGenerating } from '@renderer/store/runtime' import { Assistant, Topic } from '@renderer/types' -import { removeSpecialCharactersForFileName } from '@renderer/utils' +import { classNames, removeSpecialCharactersForFileName } from '@renderer/utils' import { copyTopicAsMarkdown, copyTopicAsPlainText } from '@renderer/utils/copy' import { exportMarkdownToJoplin, @@ -48,13 +49,14 @@ interface Props { assistant: Assistant activeTopic: Topic setActiveTopic: (topic: Topic) => void + position: 'left' | 'right' } -const Topics: FC = ({ assistant: _assistant, activeTopic, setActiveTopic }) => { +const Topics: FC = ({ assistant: _assistant, activeTopic, setActiveTopic, position }) => { const { assistants } = useAssistants() const { assistant, removeTopic, moveTopic, updateTopic, updateTopics } = useAssistant(_assistant.id) const { t } = useTranslation() - const { showTopicTime, pinTopicsToTop, setTopicPosition } = useSettings() + const { showTopicTime, pinTopicsToTop, setTopicPosition, topicPosition } = useSettings() const renamingTopics = useSelector((state: RootState) => state.runtime.chat.renamingTopics) const newlyRenamedTopics = useSelector((state: RootState) => state.runtime.chat.newlyRenamedTopics) @@ -443,13 +445,21 @@ const Topics: FC = ({ assistant: _assistant, activeTopic, setActiveTopic return assistant.topics }, [assistant.topics, pinTopicsToTop]) + const singlealone = topicPosition === 'right' && position === 'right' + return ( + itemContainerStyle={{ paddingBottom: '8px' }} + header={ + EventEmitter.emit(EVENT_NAMES.ADD_NEW_TOPIC)}> + + {t('chat.add.topic.title')} + + }> {(topic) => { const isActive = topic.id === activeTopic?.id const topicName = topic.name.replace('`', '') @@ -466,7 +476,7 @@ const Topics: FC = ({ assistant: _assistant, activeTopic, setActiveTopic setTargetTopic(topic)} - className={isActive ? 'active' : ''} + className={classNames(isActive ? 'active' : '', singlealone ? 'singlealone' : '')} onClick={() => onSwitchTopic(topic)} style={{ borderRadius }}> {isPending(topic.id) && !isActive && } @@ -548,6 +558,7 @@ const TopicListItem = styled.div` } &.active { background-color: var(--color-list-item); + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); .menu { opacity: 1; &:hover { @@ -555,6 +566,16 @@ const TopicListItem = styled.div` } } } + &.singlealone { + border-radius: 0 !important; + &:hover { + background-color: var(--color-background-soft); + } + &.active { + border-left: 2px solid var(--color-primary); + box-shadow: none; + } + } ` const TopicNameContainer = styled.div` @@ -626,6 +647,31 @@ const PendingIndicator = styled.div.attrs({ background-color: var(--color-primary); ` +const AddTopicButton = styled.div` + display: flex; + align-items: center; + gap: 6px; + width: calc(100% - 10px); + padding: 7px 12px; + margin-bottom: 8px; + background: transparent; + color: var(--color-text-2); + font-size: 13px; + border-radius: var(--list-item-border-radius); + cursor: pointer; + transition: all 0.2s; + margin-top: -5px; + + &:hover { + background-color: var(--color-list-item-hover); + color: var(--color-text-1); + } + + .anticon { + font-size: 12px; + } +` + const TopicPromptText = styled.div` color: var(--color-text-2); font-size: 12px; diff --git a/src/renderer/src/pages/home/Tabs/components/AssistantItem.tsx b/src/renderer/src/pages/home/Tabs/components/AssistantItem.tsx index f0ae3e888..0c169a7c2 100644 --- a/src/renderer/src/pages/home/Tabs/components/AssistantItem.tsx +++ b/src/renderer/src/pages/home/Tabs/components/AssistantItem.tsx @@ -390,6 +390,7 @@ const Container = styled.div` } &.active { background-color: var(--color-list-item); + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); } ` diff --git a/src/renderer/src/pages/home/Tabs/index.tsx b/src/renderer/src/pages/home/Tabs/index.tsx index 21f3a21e4..5a8c6b67d 100644 --- a/src/renderer/src/pages/home/Tabs/index.tsx +++ b/src/renderer/src/pages/home/Tabs/index.tsx @@ -1,11 +1,10 @@ import AddAssistantPopup from '@renderer/components/Popups/AddAssistantPopup' import { useAssistants, useDefaultAssistant } from '@renderer/hooks/useAssistant' -import { useSettings } from '@renderer/hooks/useSettings' +import { useNavbarPosition, useSettings } from '@renderer/hooks/useSettings' import { useShowTopics } from '@renderer/hooks/useStore' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { Assistant, Topic } from '@renderer/types' import { uuid } from '@renderer/utils' -import { Segmented as AntSegmented, SegmentedProps } from 'antd' import { FC, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -41,25 +40,22 @@ const HomeTabs: FC = ({ const [tab, setTab] = useState(position === 'left' ? _tab || 'assistants' : 'topic') const { topicPosition } = useSettings() const { defaultAssistant } = useDefaultAssistant() - const { showTopics, toggleShowTopics } = useShowTopics() + const { toggleShowTopics } = useShowTopics() + const { isLeftNavbar } = useNavbarPosition() const { t } = useTranslation() const borderStyle = '0.5px solid var(--color-border)' const border = - position === 'left' ? { borderRight: borderStyle } : { borderLeft: borderStyle, borderTopLeftRadius: 0 } + position === 'left' + ? { borderRight: isLeftNavbar ? borderStyle : 'none' } + : { borderLeft: isLeftNavbar ? borderStyle : 'none', borderTopLeftRadius: 0 } if (position === 'left' && topicPosition === 'left') { _tab = tab } - const showTab = !(position === 'left' && topicPosition === 'right') - - const assistantTab = { - label: t('assistants.abbr'), - value: 'assistants' - // icon: - } + const showTab = position === 'left' && topicPosition === 'left' const onCreateAssistant = async () => { const assistant = await AddAssistantPopup.show() @@ -97,41 +93,36 @@ const HomeTabs: FC = ({ if (position === 'right' && topicPosition === 'right' && tab === 'assistants') { setTab('topic') } - if (position === 'left' && topicPosition === 'right' && forceToSeeAllTab != true && tab !== 'assistants') { + if (position === 'left' && topicPosition === 'right' && tab === 'topic') { setTab('assistants') } }, [position, tab, topicPosition, forceToSeeAllTab]) return ( - {(showTab || (forceToSeeAllTab == true && !showTopics)) && ( - <> - - }, - { - label: t('settings.title'), - value: 'settings' - // icon: - } - ].filter(Boolean) as SegmentedProps['options'] - } - onChange={(value) => setTab(value as 'topic' | 'settings')} - block - /> - - + {position === 'left' && topicPosition === 'left' && ( + + setTab('assistants')}> + {t('assistants.abbr')} + + setTab('topic')}> + {t('common.topics')} + + setTab('settings')}> + {t('settings.title')} + + + )} + + {position === 'left' && topicPosition === 'right' && ( + + setTab('assistants')}> + {t('assistants.abbr')} + + setTab('settings')}> + {t('settings.title')} + + )} @@ -144,7 +135,12 @@ const HomeTabs: FC = ({ /> )} {tab === 'topic' && ( - + )} {tab === 'settings' && } @@ -157,7 +153,12 @@ const Container = styled.div` flex-direction: column; max-width: var(--assistants-width); min-width: var(--assistants-width); - background-color: var(--color-background); + [navbar-position='left'] & { + background-color: var(--color-background); + } + [navbar-position='top'] & { + min-height: calc(100vh - var(--navbar-height) - var(--navbar-height) - 12px); + } overflow: hidden; .collapsed { width: 0; @@ -169,72 +170,62 @@ const TabContent = styled.div` display: flex; flex: 1; flex-direction: column; - overflow-y: auto; + overflow-y: hidden; overflow-x: hidden; ` -const Divider = styled.div` - border-top: 0.5px solid var(--color-border); - margin-top: 10px; - margin-left: 10px; - margin-right: 10px; +const CustomTabs = styled.div` + display: flex; + margin: 0 12px; + padding: 6px 0; + border-bottom: 1px solid var(--color-border); + background: transparent; + [navbar-position='top'] & { + padding-top: 2px; + } ` -const Segmented = styled(AntSegmented)` - font-family: var(--font-family); +const TabItem = styled.button<{ active: boolean }>` + flex: 1; + height: 32px; + border: none; + background: transparent; + color: ${(props) => (props.active ? 'var(--color-text)' : 'var(--color-text-secondary)')}; + font-size: 13px; + font-weight: ${(props) => (props.active ? '600' : '400')}; + cursor: pointer; + border-radius: 8px; + margin: 0 2px; + position: relative; + display: flex; + align-items: center; + justify-content: center; - &.ant-segmented { - background-color: transparent; - margin: 0 10px; - margin-top: 10px; - padding: 0; - } - .ant-segmented-item { - overflow: hidden; - transition: none !important; - height: 34px; - line-height: 34px; - background-color: transparent; - user-select: none; - border-radius: var(--list-item-border-radius); - box-shadow: none; - } - .ant-segmented-item-selected, - .ant-segmented-item-selected:active { - transition: none !important; - background-color: var(--color-list-item); - } - .ant-segmented-item-label { - align-items: center; - display: flex; - flex-direction: row; - justify-content: center; - font-size: 13px; - height: 100%; - } - .ant-segmented-item-label[aria-selected='true'] { + &:hover { color: var(--color-text); } - .icon-business-smart-assistant { - margin-right: -2px; + + &:active { + transform: scale(0.98); } - .ant-segmented-thumb { - transition: none !important; - background-color: var(--color-list-item); - border-radius: var(--list-item-border-radius); - box-shadow: none; - &:hover { - background-color: transparent; - } + + &::after { + content: ''; + position: absolute; + bottom: -9px; + left: 50%; + transform: translateX(-50%); + width: ${(props) => (props.active ? '30px' : '0')}; + height: 3px; + background: var(--color-primary); + border-radius: 1px; + transition: all 0.2s ease; } - .ant-segmented-item-label, - .ant-segmented-item-icon { - display: flex; - align-items: center; + + &:hover::after { + width: ${(props) => (props.active ? '30px' : '16px')}; + background: ${(props) => (props.active ? 'var(--color-primary)' : 'var(--color-primary-soft)')}; } - /* These styles ensure the same appearance as before */ - border-radius: 0; - box-shadow: none; ` export default HomeTabs diff --git a/src/renderer/src/pages/home/components/AssistantsDrawer.tsx b/src/renderer/src/pages/home/components/AssistantsDrawer.tsx new file mode 100644 index 000000000..58eed840c --- /dev/null +++ b/src/renderer/src/pages/home/components/AssistantsDrawer.tsx @@ -0,0 +1,92 @@ +import { TopView } from '@renderer/components/TopView' +import { isMac } from '@renderer/config/constant' +import { Assistant, Topic } from '@renderer/types' +import { Drawer } from 'antd' +import { useState } from 'react' + +import HomeTabs from '../Tabs' + +interface ShowParams { + activeAssistant: Assistant + setActiveAssistant: (assistant: Assistant) => void + activeTopic: Topic + setActiveTopic: (topic: Topic) => void +} + +interface Props extends ShowParams { + resolve: (data: any) => void +} + +const PopupContainer: React.FC = ({ + activeAssistant, + setActiveAssistant, + activeTopic, + setActiveTopic, + resolve +}) => { + const [open, setOpen] = useState(true) + + const onClose = () => { + setOpen(false) + setTimeout(resolve, 300) + } + + AssistantsDrawer.hide = onClose + + return ( + + { + setActiveAssistant(assistant) + onClose() + }} + setActiveTopic={(topic) => { + setActiveTopic(topic) + onClose() + }} + position="left" + /> + + ) +} + +const TopViewKey = 'AssistantsDrawer' + +export default class AssistantsDrawer { + static topviewId = 0 + static hide() { + TopView.hide(TopViewKey) + } + static show(props: ShowParams) { + return new Promise((resolve) => { + TopView.show( + { + resolve(v) + TopView.hide(TopViewKey) + }} + />, + TopViewKey + ) + }) + } +} diff --git a/src/renderer/src/pages/knowledge/KnowledgeContent.tsx b/src/renderer/src/pages/knowledge/KnowledgeContent.tsx index e1b546478..e8948508e 100644 --- a/src/renderer/src/pages/knowledge/KnowledgeContent.tsx +++ b/src/renderer/src/pages/knowledge/KnowledgeContent.tsx @@ -3,7 +3,7 @@ import { loggerService } from '@logger' import CustomTag from '@renderer/components/CustomTag' import { HStack } from '@renderer/components/Layout' import { useKnowledge } from '@renderer/hooks/useKnowledge' -import { NavbarIcon } from '@renderer/pages/home/Navbar' +import { NavbarIcon } from '@renderer/pages/home/ChatNavbar' import { getProviderName } from '@renderer/services/ProviderService' import { KnowledgeBase } from '@renderer/types' import { Button, Empty, Tabs, Tag, Tooltip } from 'antd' diff --git a/src/renderer/src/pages/launchpad/LaunchpadPage.tsx b/src/renderer/src/pages/launchpad/LaunchpadPage.tsx new file mode 100644 index 000000000..c189ae086 --- /dev/null +++ b/src/renderer/src/pages/launchpad/LaunchpadPage.tsx @@ -0,0 +1,217 @@ +import App from '@renderer/components/MinApp/MinApp' +import { useMinapps } from '@renderer/hooks/useMinapps' +import { useRuntime } from '@renderer/hooks/useRuntime' +import { useSettings } from '@renderer/hooks/useSettings' +import tabsService from '@renderer/services/TabsService' +import { FileSearch, Folder, Languages, LayoutGrid, Palette, Sparkle } from 'lucide-react' +import { FC, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { useNavigate } from 'react-router-dom' +import styled from 'styled-components' + +const LaunchpadPage: FC = () => { + const navigate = useNavigate() + const { t } = useTranslation() + const { defaultPaintingProvider } = useSettings() + const { pinned } = useMinapps() + const { openedKeepAliveMinapps } = useRuntime() + + const appMenuItems = [ + { + icon: , + text: t('title.apps'), + path: '/apps', + bgColor: 'linear-gradient(135deg, #8B5CF6, #A855F7)' // 小程序:紫色,代表多功能和灵活性 + }, + { + icon: , + text: t('title.knowledge'), + path: '/knowledge', + bgColor: 'linear-gradient(135deg, #10B981, #34D399)' // 知识库:翠绿色,代表生长和知识 + }, + { + icon: , + text: t('title.paintings'), + path: `/paintings/${defaultPaintingProvider}`, + bgColor: 'linear-gradient(135deg, #EC4899, #F472B6)' // 绘画:活力粉色,代表创造力和艺术 + }, + { + icon: , + text: t('title.agents'), + path: '/agents', + bgColor: 'linear-gradient(135deg, #6366F1, #4F46E5)' // AI助手:靛蓝渐变,代表智能和科技 + }, + { + icon: , + text: t('title.translate'), + path: '/translate', + bgColor: 'linear-gradient(135deg, #06B6D4, #0EA5E9)' // 翻译:明亮的青蓝色,代表沟通和流畅 + }, + { + icon: , + text: t('title.files'), + path: '/files', + bgColor: 'linear-gradient(135deg, #F59E0B, #FBBF24)' // 文件:金色,代表资源和重要性 + } + ] + + // 合并并排序小程序列表 + const sortedMinapps = useMemo(() => { + // 先添加固定的小程序,保持原有顺序 + const result = [...pinned] + + // 再添加其他已打开但未固定的小程序 + openedKeepAliveMinapps.forEach((app) => { + if (!result.some((pinnedApp) => pinnedApp.id === app.id)) { + result.push(app) + } + }) + + return result + }, [openedKeepAliveMinapps, pinned]) + + return ( + + +
+ {t('launchpad.apps')} + + {appMenuItems.map((item) => ( + navigate(item.path)}> + + {item.icon} + + {item.text} + + ))} + +
+ + {sortedMinapps.length > 0 && ( +
+ {t('launchpad.minapps')} + + {sortedMinapps.map((app) => ( + setTimeout(() => tabsService.closeTab('launchpad'), 350)}> + + + ))} + +
+ )} +
+
+ ) +} + +const Container = styled.div` + width: 100%; + flex: 1; + display: flex; + justify-content: center; + align-items: flex-start; + background-color: var(--color-background); + overflow-y: auto; + padding: 50px 0; +` + +const Content = styled.div` + max-width: 720px; + width: 100%; + display: flex; + flex-direction: column; + gap: 20px; +` + +const Section = styled.div` + display: flex; + flex-direction: column; + gap: 8px; +` + +const SectionTitle = styled.h2` + font-size: 14px; + font-weight: 600; + color: var(--color-text); + opacity: 0.8; + margin: 0; + padding: 0 36px; +` + +const Grid = styled.div` + display: grid; + grid-template-columns: repeat(6, 1fr); + gap: 8px; + padding: 0 8px; +` + +const AppIcon = styled.div` + display: flex; + flex-direction: column; + align-items: center; + cursor: pointer; + gap: 4px; + padding: 8px 4px; + border-radius: 16px; + transition: transform 0.2s ease; + + &:hover { + transform: scale(1.05); + } + + &:active { + transform: scale(0.95); + } +` + +const IconContainer = styled.div` + position: relative; + display: flex; + justify-content: center; + align-items: center; + width: 56px; + height: 56px; +` + +const IconWrapper = styled.div<{ bgColor: string }>` + width: 56px; + height: 56px; + border-radius: 16px; + background: ${(props) => props.bgColor}; + display: flex; + justify-content: center; + align-items: center; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + + .icon { + color: white; + width: 28px; + height: 28px; + } +` + +const AppName = styled.div` + font-size: 12px; + color: var(--color-text); + text-align: center; + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +` + +const AppWrapper = styled.div` + padding: 8px 4px; + border-radius: 8px; + transition: transform 0.2s ease; + + &:hover { + transform: scale(1.05); + } + + &:active { + transform: scale(0.95); + } +` + +export default LaunchpadPage diff --git a/src/renderer/src/pages/apps/AppsPage.tsx b/src/renderer/src/pages/minapps/MinAppsPage.tsx similarity index 51% rename from src/renderer/src/pages/apps/AppsPage.tsx rename to src/renderer/src/pages/minapps/MinAppsPage.tsx index 31cb3f239..f1c052ca7 100644 --- a/src/renderer/src/pages/apps/AppsPage.tsx +++ b/src/renderer/src/pages/minapps/MinAppsPage.tsx @@ -1,22 +1,22 @@ import { Navbar, NavbarMain } from '@renderer/components/app/Navbar' +import App from '@renderer/components/MinApp/MinApp' +import Scrollbar from '@renderer/components/Scrollbar' import { useMinapps } from '@renderer/hooks/useMinapps' +import { useNavbarPosition } from '@renderer/hooks/useSettings' import { Button, Input } from 'antd' -import { Search, SettingsIcon, X } from 'lucide-react' -import React, { FC, useEffect, useState } from 'react' +import { Search, SettingsIcon } from 'lucide-react' +import React, { FC, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useLocation } from 'react-router' import styled from 'styled-components' -import App from './App' -import MiniAppSettings from './MiniappSettings/MiniAppSettings' +import MinappSettingsPopup from './MiniappSettings/MinappSettingsPopup' import NewAppButton from './NewAppButton' const AppsPage: FC = () => { const { t } = useTranslation() const [search, setSearch] = useState('') const { minapps } = useMinapps() - const [isSettingsOpen, setIsSettingsOpen] = useState(false) - const location = useLocation() + const { isTopNavbar } = useNavbarPosition() const filteredApps = search ? minapps.filter( @@ -35,10 +35,6 @@ const AppsPage: FC = () => { e.preventDefault() } - useEffect(() => { - setIsSettingsOpen(false) - }, [location.key]) - return ( @@ -60,26 +56,47 @@ const AppsPage: FC = () => { suffix={} value={search} onChange={(e) => setSearch(e.target.value)} - disabled={isSettingsOpen} /> @@ -146,10 +142,6 @@ const MiniAppSettings: FC = () => { onChange={(checked) => dispatch(setShowOpenedMinappsInSidebar(checked))} /> - - - - ) } @@ -158,6 +150,7 @@ const Container = styled.div` display: flex; flex-direction: column; flex: 1; + padding-top: 10px; ` // 修改和新增样式 diff --git a/src/renderer/src/pages/apps/NewAppButton.tsx b/src/renderer/src/pages/minapps/NewAppButton.tsx similarity index 100% rename from src/renderer/src/pages/apps/NewAppButton.tsx rename to src/renderer/src/pages/minapps/NewAppButton.tsx diff --git a/src/renderer/src/pages/settings/DisplaySettings/DisplaySettings.tsx b/src/renderer/src/pages/settings/DisplaySettings/DisplaySettings.tsx index 27453ef1c..276447d72 100644 --- a/src/renderer/src/pages/settings/DisplaySettings/DisplaySettings.tsx +++ b/src/renderer/src/pages/settings/DisplaySettings/DisplaySettings.tsx @@ -1,9 +1,10 @@ import { SyncOutlined } from '@ant-design/icons' import CodeEditor from '@renderer/components/CodeEditor' import { HStack } from '@renderer/components/Layout' +import TextBadge from '@renderer/components/TextBadge' import { isMac, THEME_COLOR_PRESETS } from '@renderer/config/constant' import { useTheme } from '@renderer/context/ThemeProvider' -import { useSettings } from '@renderer/hooks/useSettings' +import { useNavbarPosition, useSettings } from '@renderer/hooks/useSettings' import useUserTheme from '@renderer/hooks/useUserTheme' import { useAppDispatch } from '@renderer/store' import { @@ -68,6 +69,7 @@ const DisplaySettings: FC = () => { assistantIconType, userTheme } = useSettings() + const { navbarPosition, setNavbarPosition } = useNavbarPosition() const { theme, settedTheme } = useTheme() const { t } = useTranslation() const dispatch = useAppDispatch() @@ -216,6 +218,24 @@ const DisplaySettings: FC = () => { )} + + + {t('settings.display.navbar.title')} + + + + {t('settings.display.navbar.position')} + + + {t('settings.display.zoom.title')} @@ -286,22 +306,24 @@ const DisplaySettings: FC = () => { /> - - - {t('settings.display.sidebar.title')} - - - - - - - + {navbarPosition === 'left' && ( + + + {t('settings.display.sidebar.title')} + + + + + + + + )} {t('settings.display.custom.css')} diff --git a/src/renderer/src/pages/settings/MCPSettings/InstallNpxUv.tsx b/src/renderer/src/pages/settings/MCPSettings/InstallNpxUv.tsx index ef1bbfad3..2f7e64bf3 100644 --- a/src/renderer/src/pages/settings/MCPSettings/InstallNpxUv.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/InstallNpxUv.tsx @@ -76,7 +76,6 @@ const InstallNpxUv: FC = ({ mini = false }) => { return (