diff --git a/docs/features/memory-guide-zh.md b/docs/features/memory-guide-zh.md new file mode 100644 index 0000000000..6c8c37cbef --- /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 45a8537c8b..0199b89a59 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 5389c7542a..057e84ca76 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 b72615657f..88c038a1fa 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 0701e7db2d..0ec17691ec 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 5a7561cac8..b0ecf360f9 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 83d241fe85..c3ac979d25 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 8b2f22fc20..736934cd1a 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 0000000000..07f0932525 --- /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 0000000000..cbb1b81764 --- /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 17493df72e..ab27e37ebc 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 876c4e605f..c23c34351f 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 8b5fe0ade6..140219ad78 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 69fa983f93..27962e7cef 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 4c985c8954..e24f0c0eaa 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 776c06ad1b..76ba1c3daa 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 1b5a500b97..9dbe4698e4 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 902b2362b5..5c636eb8bf 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 3346349544..af1b21fe23 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 f86dd6de16..3f96c6f99a 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 b3dcffebd8..8174f556b1 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 2f811d363c..fb3f60fe54 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 0000000000..bd5af9ad52 --- /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 + } + }}> +
+ +