From 29af30b8d2166fb36023996436764d6aaa397cfc Mon Sep 17 00:00:00 2001 From: LiuVaayne <10231735+vaayne@users.noreply.github.com> Date: Mon, 23 Jun 2025 21:32:04 +0800 Subject: [PATCH] Feat/memory (#6454) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(Memory): add memory management functionality with settings modal and integration into sidebar * Add user memory search to chat completion flow Enable assistants to use memory search results for more personalized responses when enableMemory is true. Add enableMemory flag to Assistant type. * Add MemoryService and memory types interfaces * Add memory settings tab to AssistantSettings * feat(memory): implement Phase 1 core backend infrastructure - Add Memory IPC channels for full CRUD operations - Create main process MemoryService with LibSQL storage - Implement memory database schema with history tracking - Add IPC handlers for all memory operations - Update preload API to expose memory functionality - Update renderer MemoryService to use IPC instead of mock data - Add comprehensive error handling and logging - Support memory deduplication using SHA256 hashes - Implement audit trail for all memory changes Core features: - Memory add/search/list/delete/update operations - Full history tracking for changes - User/agent/run filtering support - Prepared for vector embeddings (Phase 2) 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * ✨ feat: Implement memory vector search and hybrid search with LibSQL native vector support - Implemented native vector support using `F32_BLOB` for storing embeddings directly in LibSQL. - Created vector index using `libsql_vector_idx()` for efficient similarity search. - Implemented `vectorSearch()` using `vector_top_k()` for fast nearest neighbor search based on cosine similarity. - Implemented `hybridSearch()` combining text search and vector similarity search. - Added `addBatchMemories()` to efficiently add multiple memories with embeddings. - Added embedding model configuration via `setEmbeddingModel()`. - Integrated embedding generation using `EmbeddingService`. - Updated database schema with vector column and index. - Added methods for embedding conversion and similarity calculations. - Updated documentation with vector search details and schema changes. - Improved memory management with embedding caching and clearing. - Added `findSimilarMemories` to avoid duplicates - Added embedding stats and cache methods * feat(memory): add EmbeddingService and VectorSearch for memory embedding and search functionality * fix(memory): update vector operations to match libsql documentation - Fix vector32() function usage by removing quotes from embedding parameters - Replace vector_top_k with standard queries to avoid index dependency - Make embedding dimensions flexible by removing fixed 1536 dimension - Add NULL checks for embeddings in all vector operations - Update memory update method to regenerate embeddings - Use parameterized queries to prevent SQL injection 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * update implement plan * feat(memory): implement Phase 3 - UI and integration components - Create Memory Page UI with full CRUD operations - Add, delete, search, and filter memories - User and date range filtering - Bulk operations support - Implement Memory Processing Pipeline - Automatic fact extraction from conversations - Intelligent memory updates (add/update/delete) - Background processing for non-blocking UI - Integrate memory search into ApiService - Inject relevant memories into conversation context - Post-conversation memory processing - Assistant-specific memory isolation - Add Assistant Memory Settings - Toggle memory per assistant - View memory statistics - Configure from assistant settings panel - Complete memory feature integration - Redux store already implemented - Memory service backend ready - Full end-to-end functionality 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * docs: update memory feature implementation plan to reflect Phase 3 completion - Mark Phase 3 (UI and Integration) as completed - Update implementation progress to 90% - Document all completed components and features - Add usage instructions for the memory feature - List key implementation files for reference 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * Revert "feat(memory): implement Phase 3 - UI and integration components" This reverts commit 422463d0f7d69a19d7920d570b5520e1ab110d86. * ✨ feat: Implement memory management for assistants - Introduced `MemoryProcessor` to handle fact extraction, memory updates, and searches. - Added `AssistantMemorySettings` component to allow enabling/disabling memory and viewing memory statistics. - Integrated memory functionality with assistant settings. - Implemented memory-related prompts and schema validation. - Added memory service calls for adding, updating, deleting, and searching memories. * fix: Update memory settings form handling in AssistantMemorySettings component * feat(memory): implement Phase 3 - UI and integration components - Create Memory Page UI with full CRUD operations - Add, delete, search, and filter memories - User and date range filtering - Bulk operations support - Implement Memory Processing Pipeline - Automatic fact extraction from conversations - Intelligent memory updates (add/update/delete) - Background processing for non-blocking UI - Integrate memory search into ApiService - Inject relevant memories into conversation context - Post-conversation memory processing - Assistant-specific memory isolation - Add Assistant Memory Settings - Toggle memory per assistant - View memory statistics - Configure from assistant settings panel - Complete memory feature integration - Redux store already implemented - Memory service backend ready - Full end-to-end functionality 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * fix: update memory search method and integrate form handling in AssistantMemorySettings * feat(memory): enhance memory service with new IPC channels and configuration updates * refactor: remove VectorSearch service and update memory configuration methods - Deleted the VectorSearch service implementation from the memory services. - Updated the memory configuration method in the preload index from `updateConfig` to `setConfig`. - Adjusted the corresponding call in the MemoryService to reflect the new method name. * refactor(memory): remove embedding cache and related methods from MemoryService * feat(memory): update MemoryService initialization to handle configuration updates and errors * feat(memory): centralize SQL queries for MemoryService to improve maintainability * feat: Enhance memory management with user context and CRUD operations - Updated memory feature analysis with current implementation status and new functionalities. - Added user context management, allowing automatic filtering based on selected user. - Implemented full CRUD operations for memories, including add, edit, and delete functionalities. - Introduced user management system with validation rules for user IDs. - Enhanced internationalization support for memory features across multiple languages. - Improved UI components for memory management, including user switching and memory editing modals. - Updated MemoryService and MemoryProcessor to handle user-specific memory operations. - Added comprehensive testing and documentation for new features. * feat(memory): enhance UI components and user interactions in MemoriesPage * feat(memory): integrate user context into memory operations and enhance user management * feat(memory): add user deletion and reset functionality for specific users * feat(memory): enhance localization for user management and memory operations in multiple languages * feat(memory): remove reset functionality and add user listing feature in memory service * feat(memory): enhance memory processing by adding fallback models and improving configuration handling * feat(memory): refactor fact extraction and memory update logic to use fetchGenerate for LLM calls * feat(memory): iinject memory to chat * feat(memory): enhance memory handling by ensuring fallback to empty array and improving logging in memory search * feat(memory): implement pagination for memory list operations - Add offset parameter to MemoryListOptions interface for pagination support - Update MemoryService.list() method to properly handle offset parameter - Implement client-side pagination with 50 items per page default - Add pagination controls with page size options (20, 50, 100, 200) - Include pagination state management (currentPage, pageSize) - Load all memories at once and paginate on client side for better search performance - Reset pagination when switching users or performing searches - Update memory CRUD operations to maintain current pagination state - Add styled pagination component with consistent design 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * fix(memory): correct state variable name in user switch handler * feat(memory): add comprehensive memory feature guide in Chinese * feat: Enhance memory management and citation handling - Updated BaseApiClient to retrieve memory references from cache and format them for use. - Modified CitationBlock to include memory references in rendering logic. - Enhanced CitationsList to display memory citations with appropriate formatting. - Improved MemoriesPage layout and functionality, including debounced search and user management. - Refactored MemoryProcessor to handle memory updates and search queries more effectively. - Updated API service to include memory in external tool results. - Adjusted memory-related types and schemas for better integration. - Enhanced createCitationBlock utility to accommodate memory data. * feat(memory): update memory localization keys and descriptions for improved clarity * feat(memory): add descriptions to memory localization files and improve error handling in MemoryService * ✨ style: Remove blank lines and fix typo in settings - Removed blank lines in README files. - Fixed a typo in the assistant settings label for the memory tab. revert: revert format changes revert: revert format changes * feat(memory): enhance memory update logic and improve prompt clarity * feat(memory): implement global memory toggle and enhance memory settings UI * feat(memory): refine hybrid search to focus on vector similarity and update localization files * feat(i18n): add memory configuration prompts in multiple languages --------- Co-authored-by: Claude --- docs/features/memory-guide-zh.md | 222 +++ packages/shared/IpcChannel.ts | 13 +- src/main/ipc.ts | 31 + src/main/services/memory/MemoryService.ts | 704 ++++++++++ src/main/services/memory/queries.ts | 150 ++ src/preload/index.ts | 19 + src/renderer/src/App.tsx | 2 + .../src/aiCore/clients/BaseApiClient.ts | 18 +- src/renderer/src/components/app/Sidebar.tsx | 7 +- src/renderer/src/hooks/useAppInit.ts | 15 + src/renderer/src/i18n/locales/en-us.json | 90 +- src/renderer/src/i18n/locales/ja-jp.json | 90 +- src/renderer/src/i18n/locales/ru-ru.json | 92 +- src/renderer/src/i18n/locales/zh-cn.json | 90 +- src/renderer/src/i18n/locales/zh-tw.json | 92 +- .../home/Messages/Blocks/CitationBlock.tsx | 5 +- .../src/pages/home/Messages/CitationsList.tsx | 14 +- src/renderer/src/pages/memory/index.tsx | 1246 +++++++++++++++++ .../src/pages/memory/settings-modal.tsx | 126 ++ .../AssistantMemorySettings.tsx | 188 +++ .../settings/AssistantSettings/index.tsx | 22 +- src/renderer/src/services/ApiService.ts | 170 ++- src/renderer/src/services/MemoryProcessor.ts | 260 ++++ src/renderer/src/services/MemoryService.ts | 213 +++ src/renderer/src/services/PasteService.ts | 432 +++--- 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/settings.ts | 13 +- src/renderer/src/store/thunk/messageThunk.ts | 4 +- src/renderer/src/types/index.ts | 74 +- src/renderer/src/types/newMessage.ts | 2 + src/renderer/src/utils/memory-prompts.ts | 279 ++++ src/renderer/src/utils/messageUtils/create.ts | 5 +- 34 files changed, 4575 insertions(+), 251 deletions(-) create mode 100644 docs/features/memory-guide-zh.md 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/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/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index 86342370f..760f1070e 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -218,5 +218,16 @@ 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_GetUsersList = 'memory:get-users-list' } diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 5eb77afdd..e88117174 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -21,6 +21,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' @@ -44,6 +45,7 @@ const backupManager = new BackupManager() const exportService = new ExportService(fileManager) const obsidianVaultService = new ObsidianVaultService() const vertexAIService = VertexAIService.getInstance() +const memoryService = MemoryService.getInstance() export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { const appUpdater = new AppUpdater(mainWindow) @@ -377,6 +379,35 @@ 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_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/services/memory/MemoryService.ts b/src/main/services/memory/MemoryService.ts new file mode 100644 index 000000000..e944220f4 --- /dev/null +++ b/src/main/services/memory/MemoryService.ts @@ -0,0 +1,704 @@ +import { Client, createClient } from '@libsql/client' +import Embeddings from '@main/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 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.checkExists, + args: [hash] + }) + + if (existing.rows.length > 0) { + logger.info(`Memory already exists with hash: ${hash}`) + continue + } + + // Generate embedding if model is configured + let embedding: number[] | null = null + if (this.config?.embedderModel) { + try { + embedding = await this.generateEmbedding(trimmedMemory) + } catch (error) { + logger.error('Failed to generate embedding:', error) + // Continue without embedding + } + } + + // 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?.embedderModel) { + 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?.embedderModel) { + try { + embedding = await this.generateEmbedding(memory) + } 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 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) ========== + + /** + * Generate embedding for text + */ + private async generateEmbedding(text: string): Promise { + if (!this.config?.embedderModel) { + throw new Error('Embedder model not configured') + } + + try { + // Initialize embeddings instance if needed + if (!this.embeddings) { + const model = this.config.embedderModel + const provider = this.config.embedderProvider + + if (!provider) { + throw new Error('Embedder provider not configured') + } + + this.embeddings = new Embeddings({ + id: model.id, + model: model.id, + provider: provider.id, + apiKey: provider.apiKey || '', + baseURL: provider.apiHost || '', + apiVersion: provider.apiVersion, + dimensions: this.config.embedderDimensions + }) + await this.embeddings.init() + } + + const embedding = await this.embeddings.embedQuery(text) + + return 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..3c8a0cf4a --- /dev/null +++ b/src/main/services/memory/queries.ts @@ -0,0 +1,150 @@ +/** + * 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', + + 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 + ` + } +} as const diff --git a/src/preload/index.ts b/src/preload/index.ts index 2f1585a09..a93d39b7c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -3,12 +3,17 @@ import { electronAPI } from '@electron-toolkit/preload' import { FeedUrl } from '@shared/config/constant' import { IpcChannel } from '@shared/IpcChannel' import { + AddMemoryOptions, + AssistantMessage, FileListResponse, FileMetadata, FileUploadResponse, KnowledgeBaseParams, KnowledgeItem, MCPServer, + MemoryConfig, + MemoryListOptions, + MemorySearchOptions, Provider, Shortcut, ThemeMode, @@ -152,6 +157,20 @@ 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), + 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/App.tsx b/src/renderer/src/App.tsx index b46910cd6..f36001c22 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -18,6 +18,7 @@ import AppsPage from './pages/apps/AppsPage' import FilesPage from './pages/files/FilesPage' import HomePage from './pages/home/HomePage' import KnowledgePage from './pages/knowledge/KnowledgePage' +import MemoryPage from './pages/memory' import PaintingsRoutePage from './pages/paintings/PaintingsRoutePage' import SettingsPage from './pages/settings/SettingsPage' import TranslatePage from './pages/translate/TranslatePage' @@ -42,6 +43,7 @@ function App(): React.ReactElement { } /> } /> } /> + } /> } /> } /> diff --git a/src/renderer/src/aiCore/clients/BaseApiClient.ts b/src/renderer/src/aiCore/clients/BaseApiClient.ts index 4e39e9464..3f59113d7 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, @@ -216,6 +217,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) => ({ @@ -223,7 +225,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) @@ -265,6 +267,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/components/app/Sidebar.tsx b/src/renderer/src/components/app/Sidebar.tsx index 5e4365c3c..928f4d24b 100644 --- a/src/renderer/src/components/app/Sidebar.tsx +++ b/src/renderer/src/components/app/Sidebar.tsx @@ -20,6 +20,7 @@ import { Folder, Languages, LayoutGrid, + MemoryStick, MessageSquare, Moon, Palette, @@ -155,7 +156,8 @@ const MainMenus: FC = () => { translate: , minapp: , knowledge: , - files: + files: , + memory: } const pathMap = { @@ -165,7 +167,8 @@ const MainMenus: FC = () => { translate: '/translate', minapp: '/apps', knowledge: '/knowledge', - files: '/files' + files: '/files', + memory: '/memory' } return sidebarIcons.visible.map((icon) => { 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/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 5215b39b2..e51d1ee3c 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -406,9 +406,11 @@ "prompt": "Prompt", "provider": "Provider", "regenerate": "Regenerate", + "refresh": "Refresh", "rename": "Rename", "reset": "Reset", "save": "Save", + "settings": "Settings", "search": "Search", "select": "Select", "selectedMessages": "Selected {{count}} messages", @@ -422,7 +424,9 @@ "pinyin.asc": "Sort by Pinyin (A-Z)", "pinyin.desc": "Sort by Pinyin (Z-A)" }, - "no_results": "No results" + "no_results": "No results", + "enabled": "Enabled", + "disabled": "Disabled" }, "docs": { "title": "Docs" @@ -2145,6 +2149,90 @@ "user_tips": "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." } } + }, + "memory": { + "title": "Memories", + "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", + "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", + "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" } } } diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 9db96bf56..6c0412e4b 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -406,9 +406,11 @@ "prompt": "プロンプト", "provider": "プロバイダー", "regenerate": "再生成", + "refresh": "更新", "rename": "名前を変更", "reset": "リセット", "save": "保存", + "settings": "設定", "search": "検索", "select": "選択", "selectedMessages": "{{count}}件のメッセージを選択しました", @@ -422,7 +424,9 @@ "pinyin.asc": "ピンインで昇順ソート", "pinyin.desc": "ピンインで降順ソート" }, - "no_results": "検索結果なし" + "no_results": "検索結果なし", + "enabled": "有効", + "disabled": "無効" }, "docs": { "title": "ドキュメント" @@ -2145,6 +2149,90 @@ "user_tips": "アプリケーションの実行ファイル名を1行ずつ入力してください。大文字小文字は区別しません。例: chrome.exe, weixin.exe, Cherry Studio.exe, など。" } } + }, + "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": "ユーザーメモリをリセット", + "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": "ユーザーメモリのリセットに失敗しました", + "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": "メモリページに移動" } } } diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 09842d104..85b7572de 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -406,9 +406,11 @@ "prompt": "Промпт", "provider": "Провайдер", "regenerate": "Пересоздать", + "refresh": "Обновить", "rename": "Переименовать", "reset": "Сбросить", "save": "Сохранить", + "settings": "Настройки", "search": "Поиск", "select": "Выбрать", "selectedMessages": "Выбрано {{count}} сообщений", @@ -422,7 +424,9 @@ "pinyin.asc": "Сортировать по пиньинь (А-Я)", "pinyin.desc": "Сортировать по пиньинь (Я-А)" }, - "no_results": "Результатов не найдено" + "no_results": "Результатов не найдено", + "enabled": "Включено", + "disabled": "Отключено" }, "docs": { "title": "Документация" @@ -2145,6 +2149,90 @@ "user_tips": "Введите имя исполняемого файла приложения, один на строку, не учитывая регистр, можно использовать подстановку *" } } + }, + "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": "Сбросить воспоминания пользователя", + "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": "Не удалось сбросить воспоминания пользователя", + "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": "Перейти на страницу памяти" } } -} \ No newline at end of file +} diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 44f912819..069563993 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -406,9 +406,11 @@ "prompt": "提示词", "provider": "提供商", "regenerate": "重新生成", + "refresh": "刷新", "rename": "重命名", "reset": "重置", "save": "保存", + "settings": "设置", "search": "搜索", "select": "选择", "selectedMessages": "选中 {{count}} 条消息", @@ -422,7 +424,9 @@ "pinyin.asc": "按拼音升序", "pinyin.desc": "按拼音降序" }, - "no_results": "无结果" + "no_results": "无结果", + "enabled": "已启用", + "disabled": "已禁用" }, "docs": { "title": "帮助文档" @@ -2145,6 +2149,90 @@ "user_tips": "请输入应用的执行文件名,每行一个,不区分大小写,可以模糊匹配。例如:chrome.exe、weixin.exe、Cherry Studio.exe等" } } + }, + "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": "重置用户记忆", + "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": "重置用户记忆失败", + "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": "前往记忆页面" } } } diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 65669a90f..e4acd2a87 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -406,9 +406,11 @@ "prompt": "提示詞", "provider": "供應商", "regenerate": "重新生成", + "refresh": "重新整理", "rename": "重新命名", "reset": "重設", "save": "儲存", + "settings": "設定", "search": "搜尋", "select": "選擇", "selectedMessages": "選中 {{count}} 條訊息", @@ -422,7 +424,9 @@ "pinyin.asc": "按拼音升序", "pinyin.desc": "按拼音降序" }, - "no_results": "沒有結果" + "no_results": "沒有結果", + "enabled": "已啟用", + "disabled": "已停用" }, "docs": { "title": "說明文件" @@ -2145,6 +2149,90 @@ "user_tips": "請輸入應用的執行檔名稱,每行一個,不區分大小寫,可以模糊匹配。例如:chrome.exe、weixin.exe、Cherry Studio.exe等" } } + }, + "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": "重置使用者記憶", + "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": "重置使用者記憶失敗", + "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": "前往記憶頁面" } } -} \ No newline at end of file +} diff --git a/src/renderer/src/pages/home/Messages/Blocks/CitationBlock.tsx b/src/renderer/src/pages/home/Messages/Blocks/CitationBlock.tsx index fdd4640d0..3f8d1263d 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/CitationBlock.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/CitationBlock.tsx @@ -17,9 +17,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]) if (block.status === MessageBlockStatus.PROCESSING) { return diff --git a/src/renderer/src/pages/home/Messages/CitationsList.tsx b/src/renderer/src/pages/home/Messages/CitationsList.tsx index f1e945d3e..0119d8a0d 100644 --- a/src/renderer/src/pages/home/Messages/CitationsList.tsx +++ b/src/renderer/src/pages/home/Messages/CitationsList.tsx @@ -83,11 +83,17 @@ const CitationsList: React.FC = ({ citations }) => { {open && citations.map((citation) => ( - {citation.type === 'websearch' ? ( - - ) : ( - + {citation.type === 'websearch' && } + {citation.type === 'memory' && ( + )} + {citation.type === 'knowledge' && } ))} diff --git a/src/renderer/src/pages/memory/index.tsx b/src/renderer/src/pages/memory/index.tsx new file mode 100644 index 000000000..352dd5bfa --- /dev/null +++ b/src/renderer/src/pages/memory/index.tsx @@ -0,0 +1,1246 @@ +import { + CalendarOutlined, + DeleteOutlined, + EditOutlined, + ExclamationCircleOutlined, + MoreOutlined, + PlusOutlined, + ReloadOutlined, + SettingOutlined, + UserAddOutlined, + UserDeleteOutlined, + UserOutlined +} from '@ant-design/icons' +import MemoryService from '@renderer/services/MemoryService' +import { + selectCurrentUserId, + selectGlobalMemoryEnabled, + setCurrentUserId, + setGlobalMemoryEnabled +} from '@renderer/store/memory' +import { MemoryItem } from '@types' +import { + Avatar, + Button, + Card, + Dropdown, + Form, + Input, + Layout, + message, + Modal, + Pagination, + Select, + Space, + Spin, + Switch +} from 'antd' +import dayjs from 'dayjs' +import relativeTime from 'dayjs/plugin/relativeTime' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useDispatch, useSelector } from 'react-redux' +import styled from 'styled-components' + +import MemoriesSettingsModal from './settings-modal' + +dayjs.extend(relativeTime) + +const DEFAULT_USER_ID = 'default-user' + +const { Content } = Layout +const { Option } = Select +const { TextArea } = Input + +// Styled Components +const StyledContent = styled(Content)` + padding: 0; + background: var(--color-background); + min-height: 100vh; +` + +const HeaderSection = styled.div` + background: var(--color-background); + border-bottom: 1px solid var(--color-border); + padding: 32px 24px 28px; + margin-bottom: 0; + position: sticky; + top: 0; + z-index: 10; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08); + backdrop-filter: blur(8px); + + .header-container { + max-width: 800px; + margin: 0 auto; + } + + .header-top { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 20px; + gap: 24px; + } + + .header-title-section { + display: flex; + align-items: center; + gap: 20px; + flex: 1; + min-width: 0; + } + + .title-content { + flex: 1; + min-width: 0; + } + + .header-title { + color: var(--color-text); + margin: 0; + font-weight: 700; + font-size: 32px; + line-height: 1.1; + letter-spacing: -0.5px; + background: linear-gradient(135deg, var(--color-text) 0%, var(--color-primary) 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + } + + .global-memory-toggle { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 16px; + background: var(--color-background-soft); + border: 1px solid var(--color-border); + border-radius: 28px; + backdrop-filter: blur(8px); + transition: all 0.2s ease; + white-space: nowrap; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); + + &:hover { + background: var(--color-background-hover); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); + transform: translateY(-1px); + } + + .toggle-label { + font-size: 13px; + font-weight: 500; + color: var(--color-text-secondary); + } + + .toggle-status { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 3px 8px; + border-radius: 12px; + background: rgba(var(--color-success-rgb), 0.12); + color: var(--color-success); + margin-left: 4px; + + &.disabled { + background: rgba(var(--color-text-tertiary-rgb), 0.12); + color: var(--color-text-tertiary); + } + } + } + + .header-utility { + display: flex; + align-items: center; + gap: 8px; + } + + .settings-button { + background: var(--color-background-soft); + border: 1px solid var(--color-border); + color: var(--color-text-secondary); + width: 38px; + height: 38px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + backdrop-filter: blur(4px); + transition: all 0.2s ease; + + &:hover { + background: var(--color-background-hover); + color: var(--color-text); + border-color: var(--color-primary); + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + } + } + + .user-stats-section { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; + } + + .stats-row { + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; + } + + .stat-item { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 14px; + background: rgba(var(--color-primary-rgb), 0.08); + border-radius: 20px; + border: 1px solid rgba(var(--color-primary-rgb), 0.15); + font-size: 13px; + font-weight: 500; + color: var(--color-primary); + backdrop-filter: blur(4px); + transition: all 0.2s ease; + + &:hover { + background: rgba(var(--color-primary-rgb), 0.12); + transform: translateY(-1px); + } + } + + .stat-icon { + font-size: 12px; + opacity: 0.8; + } + + .main-actions { + display: flex; + align-items: center; + gap: 12px; + } + + .user-selector { + min-width: 200px; + border-radius: 10px; + border: 1px solid var(--color-border); + background: var(--color-background-soft); + backdrop-filter: blur(4px); + + .ant-select-selector { + border: none !important; + background: transparent !important; + box-shadow: none !important; + padding: 8px 12px; + border-radius: 10px; + color: var(--color-text) !important; + } + } + + .user-avatar { + background: linear-gradient(135deg, var(--color-primary) 0%, #667eea 100%); + color: white; + font-weight: 600; + font-size: 11px; + border: 2px solid rgba(255, 255, 255, 0.3); + } + + .action-button { + border-radius: 10px; + font-weight: 500; + height: 38px; + backdrop-filter: blur(4px); + transition: all 0.2s ease; + + &.primary-action { + background: linear-gradient(135deg, var(--color-primary) 0%, #667eea 100%); + border: none; + box-shadow: 0 2px 8px rgba(var(--color-primary-rgb), 0.3); + + &:hover { + transform: translateY(-1px); + box-shadow: 0 4px 16px rgba(var(--color-primary-rgb), 0.4); + } + } + + &.secondary-action { + background: var(--color-background-soft); + border: 1px solid var(--color-border); + color: var(--color-text-secondary); + + &:hover { + background: var(--color-background-hover); + color: var(--color-text); + border-color: var(--color-primary); + } + } + } + + .search-section { + display: flex; + justify-content: center; + margin-top: 4px; + } + + .search-input { + max-width: 100%; + width: 100%; + border-radius: 12px; + border: 1px solid var(--color-border); + background: var(--color-background-soft); + backdrop-filter: blur(4px); + transition: all 0.2s ease; + + .ant-input { + border: none; + background: transparent; + font-size: 15px; + padding: 12px 16px; + border-radius: 12px; + color: var(--color-text); + + &::placeholder { + color: var(--color-text-tertiary); + font-weight: 400; + } + } + + .ant-input-search-button { + border-radius: 0 12px 12px 0; + border: none; + background: var(--color-primary); + + &:hover { + background: var(--color-primary-hover); + } + } + + &:hover, + &:focus-within { + border-color: var(--color-primary); + box-shadow: 0 2px 12px rgba(var(--color-primary-rgb), 0.15); + } + } +` + +const MainContent = styled.div` + max-width: 800px; + margin: 0 auto; + padding: 32px 24px; +` + +const MemoryCard = styled(Card)` + background: var(--color-background); + border: 1px solid var(--color-border); + border-radius: 12px; + margin-bottom: 16px; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.04); + position: relative; + overflow: hidden; + + &:hover { + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); + border-color: var(--color-primary); + transform: translateY(-2px) scale(1.01); + } + + .ant-card-body { + padding: 24px; + } + + .memory-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 16px; + } + + .memory-meta { + display: flex; + align-items: center; + gap: 6px; + color: var(--color-text-tertiary); + font-size: 12px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .memory-content { + color: var(--color-text); + font-size: 16px; + line-height: 1.6; + margin: 0; + font-weight: 400; + word-break: break-word; + cursor: text; + transition: background-color 0.2s ease; + padding: 4px; + border-radius: 4px; + + &:hover { + background-color: rgba(var(--color-primary-rgb), 0.05); + } + } + + .memory-actions { + position: absolute; + top: 16px; + right: 16px; + display: flex; + align-items: center; + gap: 4px; + opacity: 0; + transition: opacity 0.2s ease; + background: var(--color-background-soft); + padding: 4px; + border-radius: 8px; + backdrop-filter: blur(4px); + border: 1px solid var(--color-border); + } + + &:hover .memory-actions { + opacity: 1; + } + + .quick-edit-btn { + border: none; + background: transparent; + color: var(--color-text-secondary); + transition: color 0.2s ease; + + &:hover { + color: var(--color-primary); + background: rgba(var(--color-primary-rgb), 0.1); + } + } + + .inline-edit-container { + margin-bottom: 12px; + } + + .inline-edit-actions { + display: flex; + gap: 8px; + margin-top: 8px; + justify-content: flex-end; + } +` + +const EmptyStateContainer = styled.div` + text-align: center; + padding: 120px 20px; + + .empty-icon { + font-size: 72px; + margin-bottom: 24px; + opacity: 0.6; + } + + .empty-title { + color: var(--color-text); + font-size: 24px; + font-weight: 600; + margin-bottom: 12px; + line-height: 1.3; + } + + .empty-description { + color: var(--color-text-secondary); + font-size: 16px; + margin-bottom: 32px; + max-width: 400px; + margin-left: auto; + margin-right: auto; + line-height: 1.5; + } +` + +const PaginationContainer = styled.div` + display: flex; + justify-content: center; + align-items: center; + margin-top: 32px; + padding: 24px 0; + + .ant-pagination { + font-size: 14px; + } + + .ant-pagination-item-active { + background-color: var(--color-primary); + border-color: var(--color-primary); + + a { + color: white; + } + } +` + +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 ( + + + {t('memory.add_memory')} + + } + open={visible} + onCancel={onCancel} + width={600} + styles={{ + header: { + borderBottom: '1px solid var(--color-border)', + paddingBottom: 16 + }, + body: { + paddingTop: 24 + } + }} + footer={[ + , + + ]}> +
+ +