Compare commits

..

29 Commits

Author SHA1 Message Date
1600822305
2822a5e65d 新增信息id 2025-04-14 23:20:03 +08:00
1600822305
26b798f345 修复了一些bug 2025-04-14 17:55:25 +08:00
1600822305
7aec8b4a35 添加了记忆功能 2025-04-13 23:34:58 +08:00
1600822305
994ab7362f 修复 2025-04-13 22:42:26 +08:00
1600822305
bbdcd85014 bug修改丢失记忆 2025-04-13 21:36:23 +08:00
1600822305
249ab3d59f 冲突 2025-04-13 20:53:32 +08:00
1600822305
5df40ffc14 记忆功能升级 2025-04-13 20:49:52 +08:00
1600822305
2bbe2f7ae5 添加了记忆功能 2025-04-13 16:51:05 +08:00
1600822305
f0876eaef0 6 2025-04-13 03:54:38 +08:00
1600822305
aa8c7fd66f 记忆功能 2025-04-13 03:51:11 +08:00
1600822305
b8dffce149 记忆功能 2025-04-12 22:03:13 +08:00
kangfenmao
8b95a131ec fix(SettingsTab): refine reasoning effort handling for Grok models
fix: #4735
2025-04-12 20:23:31 +08:00
kangfenmao
72e18fbcc1 feat(MCPSettings): enhance MCP server management and localization updates
- Added a new SVG icon for npm in the MCP settings.
- Introduced a custom hook `useMCPServer` for retrieving a specific MCP server by ID.
- Updated localization files to include new error messages for tool and prompt loading in English, Japanese, Russian, and Chinese.
- Refactored MCP settings components for improved navigation and state management, including the use of React Router for routing.
- Enhanced the Npx search functionality and UI for better user experience.
2025-04-12 19:47:36 +08:00
kangfenmao
b62c59eb52 style(SelectModelPopup): update background color animation for improved visual consistency 2025-04-12 17:02:17 +08:00
kangfenmao
ffe7702c1c style(QuickPanel): update font sizes and line height for improved readability 2025-04-12 16:41:39 +08:00
kangfenmao
1ed6320caf refactor(license.html): update structure and styling for improved readability and consistency 2025-04-12 16:41:26 +08:00
kangfenmao
315271ac35 Revert "fix(ChatNavigation): improve navigation button collapse functionality"
This reverts commit fb5ddaf9d5.
2025-04-12 16:12:34 +08:00
kangfenmao
0bd24f652d feat(NewContextButton): add styled container for responsive design
- Introduced a styled container to the NewContextButton component to hide it on smaller screens (max-width: 800px).
- Ensured the tooltip and button functionality remain intact while enhancing the component's layout.
2025-04-12 16:11:00 +08:00
kangfenmao
0e7c4e4bdd refactor(Inputbar, Messages): simplify clear topic functionality and improve message display logic
- Removed unused QuestionCircleOutlined icon and Popconfirm from Inputbar, replacing it with a direct button click for clearing topics.
- Refactored message display logic in Messages component to enhance clarity and maintainability, while preserving existing functionality.
2025-04-12 16:07:40 +08:00
kangfenmao
d4bf8da225 feat(CustomCollapse): enhance component with customizable styles and improve usage in EditModelsPopup 2025-04-12 15:57:50 +08:00
LiuVaayne
8eb6632620 Feat/improve UI mcp settings (#4717)
* feat(MCPSettings): implement server selection and navigation with back button

* chore(ui)

* chore(UI): npx search padding

* feat(NpxSearch): add server selection and navigation; update styles

---------

Co-authored-by: eeee0717 <chentao020717Work@outlook.com>
2025-04-12 15:31:52 +08:00
王叔叔
10225512f4 docs: Update LICENSE (#4723) 2025-04-12 15:31:33 +08:00
Hao He
76058bd749 feat(MessageTools): add error handling and status indicator for tool responses (#4712)
* feat(MessageTools): add error handling and status indicator for tool responses

* feat(i18n): add error message for tool invocation in English, Japanese, and Russian locales
2025-04-12 10:33:14 +08:00
Herio
a692ae7e9d fix(Messages): 调整ScrollContainer和Container的样式以减少底部空间 2025-04-12 10:28:06 +08:00
LiuVaayne
a70ca190ba Feat/mcp support MCP prompt (#4675)
* Add MCP prompt listing and retrieval functionality

* Add generic caching mechanism for MCP service methods

Refactor caching strategy by implementing a higher-order withCache function
to centralize cache logic and reduce code duplication. Separate implementation
details from caching concerns in listTools, listPrompts and getPrompt methods.

# Conflicts:
#	src/main/services/MCPService.ts

* Add MCP prompts listing feature

- Add IPC handlers for listing and getting prompts
- Create UI component to display available prompts in settings tab
- Improve error handling in MCP service methods

* fix(McpService): add error handling for tool and prompt listing methods

* feat(MCPSettings): enhance prompts and tools sections with improved UI and reset functionality

* feat(i18n): add tabs and prompts sections to localization files

* feat(MCPToolsButton): add MCP prompt list functionality to Inputbar

* feat(McpSettings, NpxSearch): improve user feedback with success messages on server addition

* feat(MCPService, MCPToolsButton): enhance prompt handling with caching and improved selection logic

* feat(MCPToolsButton): enhance prompt handling with argument support and error management

---------

Co-authored-by: Teo <cheesen.xu@gmail.com>
2025-04-12 10:27:48 +08:00
robot-AI
7c39116351 重构了memory.ts,增加了文件写入锁,解决了并行写入导致记忆文件错误的问题; (#4671)
优化了memory.json文件的加载过程,只加载一次,其它涉及图谱的操作均在内存中完成,提高效率;
注意新引入了async-mutex软件包,需要yarn install安装。
2025-04-11 22:03:57 +08:00
kangfenmao
04333535dd chore(version): 1.2.2 2025-04-11 14:43:02 +08:00
kangfenmao
a1dba93d27 feat(websearch): initialize subscribeSources in migrateConfig and update WebSearchState interface 2025-04-11 14:42:35 +08:00
Chen Tao
0842b7e84d fix(llm): rename settingsSlice to llmSlice for clarity (#4688) 2025-04-11 11:32:30 +08:00
96 changed files with 13666 additions and 1195 deletions

View File

@@ -1,13 +1,13 @@
**许可协议**
本软件采用 Apache License 2.0 许可。除 Apache License 2.0 规定的条款外,您在使用 Cherry Studio 时还应遵守以下附加条款
采用 Apache License 2.0 修改版许可,并附加以下条件
**一. 商用许可**
在以下任何一种情况下,您需要联系我们并获得明确的书面商业授权后,方可继续使用 Cherry Studio 材料:
1. **修改与衍生** 您对 Cherry Studio 材料进行修改或基于其进行衍生开发包括但不限于修改应用名称、Logo、代码、功能、界面数据等
2. **企业服务** 在您的企业内部,或为企业客户提供基于 CherryStudio 的服务,且该服务支持 10 人及以上累计用户使用。
2. **企业服务** 在您的企业内部,或为企业客户提供基于 Cherry Studio 的服务,且该服务支持 10 人及以上累计用户使用。
3. **硬件捆绑销售** 您将 Cherry Studio 预装或集成到硬件设备或产品中进行捆绑销售。
4. **政府或教育机构大规模采购** 您的使用场景属于政府或教育机构的大规模采购项目,特别是涉及安全、数据隐私等敏感需求时。
5. **面向公众的公有云服务**:基于 Cherry Studio提供面向公众的公有云服务。
@@ -33,7 +33,7 @@
**License Agreement**
This software is licensed under the Apache License 2.0. In addition to the terms stipulated by the Apache License 2.0, you must comply with the following supplementary terms when using Cherry Studio:
This software is licensed under a modified version of the Apache License 2.0, with the following additional conditions。
**I. Commercial Licensing**
@@ -59,4 +59,4 @@ As a contributor to Cherry Studio, you must agree to the following terms:
If you have any questions or need to apply for commercial authorization, please contact the Cherry Studio development team.
Other than these specific conditions, all remaining rights and restrictions follow the Apache License 2.0. For more detailed information regarding Apache License 2.0, please visit http://www.apache.org/licenses/LICENSE-2.0.
Other than these specific conditions, all remaining rights and restrictions follow the Apache License 2.0. For more detailed information regarding Apache License 2.0, please visit http://www.apache.org/licenses/LICENSE-2.0.

View File

@@ -1,6 +1,6 @@
{
"name": "CherryStudio",
"version": "1.2.2",
"version": "1.2.2-batemo",
"private": true,
"description": "A powerful AI assistant for producer.",
"main": "./out/main/index.js",
@@ -73,7 +73,9 @@
"@types/react-infinite-scroll-component": "^5.0.0",
"@xyflow/react": "^12.4.4",
"adm-zip": "^0.5.16",
"async-mutex": "^0.5.0",
"color": "^5.0.0",
"d3": "^7.9.0",
"diff": "^7.0.0",
"docx": "^9.0.2",
"electron-log": "^5.1.5",
@@ -120,6 +122,7 @@
"@tavily/core": "patch:@tavily/core@npm%3A0.3.1#~/.yarn/patches/@tavily-core-npm-0.3.1-fe69bf2bea.patch",
"@tryfabric/martian": "^1.2.4",
"@types/adm-zip": "^0",
"@types/d3": "^7",
"@types/diff": "^7",
"@types/fs-extra": "^11",
"@types/lodash": "^4.17.5",

View File

@@ -39,6 +39,8 @@ export enum IpcChannel {
Mcp_StopServer = 'mcp:stop-server',
Mcp_ListTools = 'mcp:list-tools',
Mcp_CallTool = 'mcp:call-tool',
Mcp_ListPrompts = 'mcp:list-prompts',
Mcp_GetPrompt = 'mcp:get-prompt',
Mcp_GetInstallInfo = 'mcp:get-install-info',
Mcp_ServersChanged = 'mcp:servers-changed',
Mcp_ServersUpdated = 'mcp:servers-updated',
@@ -151,5 +153,14 @@ export enum IpcChannel {
// Search Window
SearchWindow_Open = 'search-window:open',
SearchWindow_Close = 'search-window:close',
SearchWindow_OpenUrl = 'search-window:open-url'
SearchWindow_OpenUrl = 'search-window:open-url',
// Memory File Storage
Memory_LoadData = 'memory:load-data',
Memory_SaveData = 'memory:save-data',
Memory_DeleteShortMemoryById = 'memory:delete-short-memory-by-id',
// Long-term Memory File Storage
LongTermMemory_LoadData = 'long-term-memory:load-data',
LongTermMemory_SaveData = 'long-term-memory:save-data'
}

View File

@@ -1,111 +1,109 @@
<!doctype html>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CherryStudio 许可协议-ZH/EN</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet" />
</head>
<body class="bg-gray-100 p-8">
<div class="container mx-auto bg-white p-6 rounded shadow-lg">
<h1 class="text-3xl font-bold mb-6 text-center">Cherry Studio 许可协议</h1>
<div class="mb-8">
<h2 class="text-2xl font-semibold mb-4">许可协议</h2>
<p class="mb-4">
本软件采用 <strong>Apache License 2.0</strong> 许可。除 Apache License 2.0 规定的条款外,您在使用 Cherry
Studio 时还应遵守以下附加条款:
</p>
<h3 class="text-xl font-semibold mb-2">一. 商用许可</h3>
<ol class="list-decimal list-inside mb-4">
<li><strong>免费商用</strong>:用户在不修改代码的情况下,可以免费用于商业目的。</li>
<li>
<strong>商业授权</strong>:如果您满足以下任意条件之一,需取得商业授权:
<ol class="list-decimal list-inside ml-4">
<li>对本软件进行二次修改、开发包括但不限于修改应用名称、logo、代码以及功能</li>
<li>为企业客户提供多租户服务,且该服务支持 10 人或以上的使用。</li>
<li>预装或集成到硬件设备或产品中进行捆绑销售。</li>
<li>政府或教育机构的大规模采购项目,特别是涉及安全、数据隐私等敏感需求时。</li>
</ol>
</li>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>许可协议 | License Agreement</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-50">
<div class="max-w-4xl mx-auto px-4 py-8">
<!-- 中文版本 -->
<div class="mb-12">
<h1 class="text-3xl font-bold mb-8 text-gray-900">许可协议</h1>
<p class="mb-6 text-gray-700">采用 Apache License 2.0 修改版许可,并附加以下条件:</p>
<section class="mb-8">
<h2 class="text-xl font-semibold mb-4 text-gray-900">一. 商用许可</h2>
<p class="mb-4 text-gray-700">在以下任何一种情况下,您需要联系我们并获得明确的书面商业授权后,方可继续使用 Cherry Studio 材料:</p>
<ol class="list-decimal pl-6 space-y-2 text-gray-700">
<li><strong>修改与衍生</strong> 您对 Cherry Studio 材料进行修改或基于其进行衍生开发包括但不限于修改应用名称、Logo、代码、功能、界面数据等</li>
<li><strong>企业服务</strong> 在您的企业内部,或为企业客户提供基于 Cherry Studio 的服务,且该服务支持 10 人及以上累计用户使用。</li>
<li><strong>硬件捆绑销售</strong> 您将 Cherry Studio 预装或集成到硬件设备或产品中进行捆绑销售。</li>
<li><strong>政府或教育机构大规模采购</strong> 您的使用场景属于政府或教育机构的大规模采购项目,特别是涉及安全、数据隐私等敏感需求时。</li>
<li><strong>面向公众的公有云服务</strong>:基于 Cherry Studio提供面向公众的公有云服务。</li>
</ol>
<h3 class="text-xl font-semibold mb-2">二. 贡献者协议</h3>
<ol class="list-decimal list-inside mb-4">
</section>
<section class="mb-8">
<h2 class="text-xl font-semibold mb-4 text-gray-900">二. 贡献者协议</h2>
<p class="mb-4 text-gray-700">作为 Cherry Studio 的贡献者,您应当同意以下条款:</p>
<ol class="list-decimal pl-6 space-y-2 text-gray-700">
<li><strong>许可调整</strong>:生产者有权根据需要对开源协议进行调整,使其更加严格或宽松。</li>
<li><strong>商业用途</strong>:您贡献的代码可能会被用于商业用途,包括但不限于云业务运营。</li>
</ol>
<h3 class="text-xl font-semibold mb-2">三. 其他条款</h3>
<ol class="list-decimal list-inside mb-4">
</section>
<section class="mb-8">
<h2 class="text-xl font-semibold mb-4 text-gray-900">三. 其他条款</h2>
<ol class="list-decimal pl-6 space-y-2 text-gray-700">
<li>本协议条款的解释权归 Cherry Studio 开发者所有。</li>
<li>本协议可能根据实际情况进行更新,更新时将通过本软件通知用户。</li>
</ol>
<p class="mb-4">如有任何问题或需申请商业授权,请联系 Cherry Studio 开发团队。</p>
<p>
除上述特定条件外,其他所有权利和限制均遵循 Apache License 2.0。有关 Apache License 2.0 的详细信息,请访问
<a href="http://www.apache.org/licenses/LICENSE-2.0" class="text-blue-500 underline"
>http://www.apache.org/licenses/LICENSE-2.0</a
>
</p>
</div>
<h1 class="text-3xl font-bold mb-6 text-center">Cherry Studio License</h1>
<div class="mb-8">
<h2 class="text-2xl font-semibold mb-4">License Agreement</h2>
<p class="mb-4">
This software is licensed under the <strong>Apache License 2.0</strong>. In addition to the terms of the
Apache License 2.0, the following additional terms apply to the use of Cherry Studio:
</p>
<h3 class="text-xl font-semibold mb-2">I. Commercial Use License</h3>
<ol class="list-decimal list-inside mb-4">
<li>
<strong>Free Commercial Use</strong>: Users can use the software for commercial purposes without modifying
the code.
</li>
<li>
<strong>Commercial License Required</strong>: A commercial license is required if any of the following
conditions are met:
<ol class="list-decimal list-inside ml-4">
<li>
You modify, develop, or alter the software, including but not limited to changes to the application
name, logo, code, or functionality.
</li>
<li>You provide multi-tenant services to enterprise customers with 10 or more users.</li>
<li>
You pre-install or integrate the software into hardware devices or products and bundle it for sale.
</li>
<li>
You are engaging in large-scale procurement for government or educational institutions, especially
involving security, data privacy, or other sensitive requirements.
</li>
</ol>
</li>
</ol>
<h3 class="text-xl font-semibold mb-2">II. Contributor Agreement</h3>
<ol class="list-decimal list-inside mb-4">
<li>
<strong>License Adjustment</strong>: The producer reserves the right to adjust the open-source license as
needed, making it stricter or more lenient.
</li>
<li>
<strong>Commercial Use</strong>: Any code you contribute may be used for commercial purposes, including but
not limited to cloud business operations.
</li>
</ol>
<h3 class="text-xl font-semibold mb-2">III. Other Terms</h3>
<ol class="list-decimal list-inside mb-4">
<li>The interpretation of these terms is subject to the discretion of Cherry Studio developers.</li>
<li>These terms may be updated, and users will be notified through the software when changes occur.</li>
</ol>
<p class="mb-4">
For any questions or to request a commercial license, please contact the Cherry Studio development team.
</p>
<p>
Apart from the specific conditions mentioned above, all other rights and restrictions follow the Apache
License 2.0. Detailed information about the Apache License 2.0 can be found at
<a href="http://www.apache.org/licenses/LICENSE-2.0" class="text-blue-500 underline"
>http://www.apache.org/licenses/LICENSE-2.0</a
>
</p>
</div>
</section>
</div>
</body>
</html>
<hr class="my-12 border-gray-300">
<!-- English Version -->
<div>
<h1 class="text-3xl font-bold mb-8 text-gray-900">License Agreement</h1>
<p class="mb-6 text-gray-700">This software is licensed under a modified version of the Apache License 2.0, with
the following additional conditions.</p>
<section class="mb-8">
<h2 class="text-xl font-semibold mb-4 text-gray-900">I. Commercial Licensing</h2>
<p class="mb-4 text-gray-700">You must contact us and obtain explicit written commercial authorization to
continue using Cherry Studio materials under any of the following circumstances:</p>
<ol class="list-decimal pl-6 space-y-2 text-gray-700">
<li><strong>Modifications and Derivatives:</strong> You modify Cherry Studio materials or perform derivative
development based on them (including but not limited to changing the application's name, logo, code,
functionality, user interface, data, etc.).</li>
<li><strong>Enterprise Services:</strong> You use Cherry Studio internally within your enterprise, or you
provide Cherry Studio-based services for enterprise customers, and such services support cumulative usage by
10 or more users.</li>
<li><strong>Hardware Bundling and Sales:</strong> You pre-install or integrate Cherry Studio into hardware
devices or products for bundled sale.</li>
<li><strong>Large-scale Procurement by Government or Educational Institutions:</strong> Your usage scenario
involves large-scale procurement projects by government or educational institutions, especially in cases
involving sensitive requirements such as security and data privacy.</li>
<li><strong>Public Cloud Services:</strong> You provide public cloud-based product services utilizing Cherry
Studio.</li>
</ol>
</section>
<section class="mb-8">
<h2 class="text-xl font-semibold mb-4 text-gray-900">II. Contributor Agreement</h2>
<p class="mb-4 text-gray-700">As a contributor to Cherry Studio, you must agree to the following terms:</p>
<ol class="list-decimal pl-6 space-y-2 text-gray-700">
<li><strong>License Adjustments:</strong> The producer reserves the right to adjust the open-source license as
necessary, making it more strict or permissive.</li>
<li><strong>Commercial Usage:</strong> Your contributed code may be used commercially, including but not
limited to cloud business operations.</li>
</ol>
</section>
<section class="mb-8">
<h2 class="text-xl font-semibold mb-4 text-gray-900">III. Other Terms</h2>
<ol class="list-decimal pl-6 space-y-2 text-gray-700">
<li>Cherry Studio developers reserve the right of final interpretation of these agreement terms.</li>
<li>This agreement may be updated according to practical circumstances, and users will be notified of updates
through this software.</li>
</ol>
</section>
<p class="mt-8 text-gray-700">
Other than these specific conditions, all remaining rights and restrictions follow the Apache License 2.0. For
more detailed information regarding Apache License 2.0, please visit
<a href="http://www.apache.org/licenses/LICENSE-2.0"
class="text-blue-600 hover:underline">http://www.apache.org/licenses/LICENSE-2.0</a>.
</p>
</div>
</div>
</body>
</html>

View File

@@ -1,3 +1,5 @@
import './services/MemoryFileService'
import { electronApp, optimizer } from '@electron-toolkit/utils'
import { replaceDevtoolsFont } from '@main/utils/windowUtil'
import { IpcChannel } from '@shared/IpcChannel'

View File

@@ -1,3 +1,5 @@
import './services/MemoryFileService'
import fs from 'node:fs'
import { isMac, isWin } from '@main/constant'
@@ -18,6 +20,7 @@ import FileStorage from './services/FileStorage'
import { GeminiService } from './services/GeminiService'
import KnowledgeService from './services/KnowledgeService'
import mcpService from './services/MCPService'
import { memoryFileService } from './services/MemoryFileService'
import * as NutstoreService from './services/NutstoreService'
import ObsidianVaultService from './services/ObsidianVaultService'
import { ProxyConfig, proxyManager } from './services/ProxyManager'
@@ -262,6 +265,8 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.Mcp_StopServer, mcpService.stopServer)
ipcMain.handle(IpcChannel.Mcp_ListTools, mcpService.listTools)
ipcMain.handle(IpcChannel.Mcp_CallTool, mcpService.callTool)
ipcMain.handle(IpcChannel.Mcp_ListPrompts, mcpService.listPrompts)
ipcMain.handle(IpcChannel.Mcp_GetPrompt, mcpService.getPrompt)
ipcMain.handle(IpcChannel.Mcp_GetInstallInfo, mcpService.getInstallInfo)
ipcMain.handle(IpcChannel.App_IsBinaryExist, (_, name: string) => isBinaryExists(name))
@@ -303,4 +308,21 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.SearchWindow_OpenUrl, async (_, uid: string, url: string) => {
return await searchService.openUrlInSearchWindow(uid, url)
})
// memory
ipcMain.handle(IpcChannel.Memory_LoadData, async () => {
return await memoryFileService.loadData()
})
ipcMain.handle(IpcChannel.Memory_SaveData, async (_, data, forceOverwrite = false) => {
return await memoryFileService.saveData(data, forceOverwrite)
})
ipcMain.handle(IpcChannel.Memory_DeleteShortMemoryById, async (_, id) => {
return await memoryFileService.deleteShortMemoryById(id)
})
ipcMain.handle(IpcChannel.LongTermMemory_LoadData, async () => {
return await memoryFileService.loadLongTermData()
})
ipcMain.handle(IpcChannel.LongTermMemory_SaveData, async (_, data, forceOverwrite = false) => {
return await memoryFileService.saveLongTermData(data, forceOverwrite)
})
}

View File

@@ -6,6 +6,7 @@ import FetchServer from './fetch'
import FileSystemServer from './filesystem'
import MemoryServer from './memory'
import ThinkingServer from './sequentialthinking'
import SimpleRememberServer from './simpleremember'
export function createInMemoryMCPServer(name: string, args: string[] = [], envs: Record<string, string> = {}): Server {
Logger.info(`[MCP] Creating in-memory MCP server: ${name} with args: ${args} and envs: ${JSON.stringify(envs)}`)
@@ -26,6 +27,10 @@ export function createInMemoryMCPServer(name: string, args: string[] = [], envs:
case '@cherry/filesystem': {
return new FileSystemServer(args).server
}
case '@cherry/simpleremember': {
const envPath = envs.SIMPLEREMEMBER_FILE_PATH
return new SimpleRememberServer(envPath).server
}
default:
throw new Error(`Unknown in-memory MCP server: ${name}`)
}

View File

@@ -1,15 +1,14 @@
// port https://github.com/modelcontextprotocol/servers/blob/main/src/memory/index.ts
import { getConfigDir } from '@main/utils/file'
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } from '@modelcontextprotocol/sdk/types.js'
import { Mutex } from 'async-mutex' // 引入 Mutex
import { promises as fs } from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
// Define memory file path using environment variable with fallback
// Define memory file path
const defaultMemoryPath = path.join(getConfigDir(), 'memory.json')
// We are storing our memory using entities, relations, and observations in a graph structure
// Interfaces remain the same
interface Entity {
name: string
entityType: string
@@ -22,6 +21,7 @@ interface Relation {
relationType: string
}
// Structure for storing the graph in memory and in the file
interface KnowledgeGraph {
entities: Entity[]
relations: Relation[]
@@ -30,200 +30,315 @@ interface KnowledgeGraph {
// The KnowledgeGraphManager class contains all operations to interact with the knowledge graph
class KnowledgeGraphManager {
private memoryPath: string
private entities: Map<string, Entity> // Use Map for efficient entity lookup
private relations: Set<string> // Store stringified relations for easy Set operations
private fileMutex: Mutex // Mutex for file writing
constructor(memoryPath: string) {
private constructor(memoryPath: string) {
this.memoryPath = memoryPath
this.ensureMemoryPathExists()
this.entities = new Map<string, Entity>()
this.relations = new Set<string>()
this.fileMutex = new Mutex()
}
private async ensureMemoryPathExists(): Promise<void> {
// Static async factory method for initialization
public static async create(memoryPath: string): Promise<KnowledgeGraphManager> {
const manager = new KnowledgeGraphManager(memoryPath)
await manager._ensureMemoryPathExists()
await manager._loadGraphFromDisk()
return manager
}
private async _ensureMemoryPathExists(): Promise<void> {
try {
// Ensure the directory exists
const directory = path.dirname(this.memoryPath)
await fs.mkdir(directory, { recursive: true })
// Check if the file exists, if not create an empty one
try {
await fs.access(this.memoryPath)
} catch (error) {
// File doesn't exist, create an empty file
await fs.writeFile(this.memoryPath, '')
// File doesn't exist, create an empty file with initial structure
await fs.writeFile(this.memoryPath, JSON.stringify({ entities: [], relations: [] }, null, 2))
}
} catch (error) {
console.error('Failed to create memory path:', error)
console.error('Failed to ensure memory path exists:', error)
// Propagate the error or handle it more gracefully depending on requirements
throw new McpError(
ErrorCode.InternalError,
`Failed to ensure memory path: ${error instanceof Error ? error.message : String(error)}`
)
}
}
private async loadGraph(): Promise<KnowledgeGraph> {
// Load graph from disk into memory (called once during initialization)
private async _loadGraphFromDisk(): Promise<void> {
try {
const data = await fs.readFile(this.memoryPath, 'utf-8')
const lines = data.split('\n').filter((line) => line.trim() !== '')
return lines.reduce(
(graph: KnowledgeGraph, line) => {
const item = JSON.parse(line)
if (item.type === 'entity') graph.entities.push(item as Entity)
if (item.type === 'relation') graph.relations.push(item as Relation)
return graph
},
{ entities: [], relations: [] }
)
// Handle empty file case
if (data.trim() === '') {
this.entities = new Map()
this.relations = new Set()
// Optionally write the initial empty structure back
await this._persistGraph()
return
}
const graph: KnowledgeGraph = JSON.parse(data)
this.entities.clear()
this.relations.clear()
graph.entities.forEach((entity) => this.entities.set(entity.name, entity))
graph.relations.forEach((relation) => this.relations.add(this._serializeRelation(relation)))
} catch (error) {
if (error instanceof Error && 'code' in error && (error as any).code === 'ENOENT') {
return { entities: [], relations: [] }
// File doesn't exist (should have been created by _ensureMemoryPathExists, but handle defensively)
this.entities = new Map()
this.relations = new Set()
await this._persistGraph() // Create the file with empty structure
} else if (error instanceof SyntaxError) {
console.error('Failed to parse memory.json, initializing with empty graph:', error)
// If JSON is invalid, start fresh and overwrite the corrupted file
this.entities = new Map()
this.relations = new Set()
await this._persistGraph()
} else {
console.error('Failed to load knowledge graph from disk:', error)
throw new McpError(
ErrorCode.InternalError,
`Failed to load graph: ${error instanceof Error ? error.message : String(error)}`
)
}
throw error
}
}
private async saveGraph(graph: KnowledgeGraph): Promise<void> {
const lines = [
...graph.entities.map((e) => JSON.stringify({ type: 'entity', ...e })),
...graph.relations.map((r) => JSON.stringify({ type: 'relation', ...r }))
]
await fs.writeFile(this.memoryPath, lines.join('\n'))
// Persist the current in-memory graph to disk using a mutex
private async _persistGraph(): Promise<void> {
const release = await this.fileMutex.acquire()
try {
const graphData: KnowledgeGraph = {
entities: Array.from(this.entities.values()),
relations: Array.from(this.relations).map((rStr) => this._deserializeRelation(rStr))
}
await fs.writeFile(this.memoryPath, JSON.stringify(graphData, null, 2))
} catch (error) {
console.error('Failed to save knowledge graph:', error)
// Decide how to handle write errors - potentially retry or notify
throw new McpError(
ErrorCode.InternalError,
`Failed to save graph: ${error instanceof Error ? error.message : String(error)}`
)
} finally {
release()
}
}
// Helper to consistently serialize relations for Set storage
private _serializeRelation(relation: Relation): string {
// Simple serialization, ensure order doesn't matter if properties are consistent
return JSON.stringify({ from: relation.from, to: relation.to, relationType: relation.relationType })
}
// Helper to deserialize relations from Set storage
private _deserializeRelation(relationStr: string): Relation {
return JSON.parse(relationStr) as Relation
}
async createEntities(entities: Entity[]): Promise<Entity[]> {
const graph = await this.loadGraph()
const newEntities = entities.filter((e) => !graph.entities.some((existingEntity) => existingEntity.name === e.name))
graph.entities.push(...newEntities)
await this.saveGraph(graph)
const newEntities: Entity[] = []
entities.forEach((entity) => {
if (!this.entities.has(entity.name)) {
// Ensure observations is always an array
const newEntity = { ...entity, observations: Array.isArray(entity.observations) ? entity.observations : [] }
this.entities.set(entity.name, newEntity)
newEntities.push(newEntity)
}
})
if (newEntities.length > 0) {
await this._persistGraph()
}
return newEntities
}
async createRelations(relations: Relation[]): Promise<Relation[]> {
const graph = await this.loadGraph()
const newRelations = relations.filter(
(r) =>
!graph.relations.some(
(existingRelation) =>
existingRelation.from === r.from &&
existingRelation.to === r.to &&
existingRelation.relationType === r.relationType
)
)
graph.relations.push(...newRelations)
await this.saveGraph(graph)
const newRelations: Relation[] = []
relations.forEach((relation) => {
// Ensure related entities exist before creating a relation
if (!this.entities.has(relation.from) || !this.entities.has(relation.to)) {
console.warn(`Skipping relation creation: Entity not found for relation ${relation.from} -> ${relation.to}`)
return // Skip this relation
}
const relationStr = this._serializeRelation(relation)
if (!this.relations.has(relationStr)) {
this.relations.add(relationStr)
newRelations.push(relation)
}
})
if (newRelations.length > 0) {
await this._persistGraph()
}
return newRelations
}
async addObservations(
observations: { entityName: string; contents: string[] }[]
): Promise<{ entityName: string; addedObservations: string[] }[]> {
const graph = await this.loadGraph()
const results = observations.map((o) => {
const entity = graph.entities.find((e) => e.name === o.entityName)
const results: { entityName: string; addedObservations: string[] }[] = []
let changed = false
observations.forEach((o) => {
const entity = this.entities.get(o.entityName)
if (!entity) {
throw new Error(`Entity with name ${o.entityName} not found`)
// Option 1: Throw error
throw new McpError(ErrorCode.InvalidParams, `Entity with name ${o.entityName} not found`)
// Option 2: Skip and warn
// console.warn(`Entity with name ${o.entityName} not found when adding observations. Skipping.`);
// return;
}
// Ensure observations array exists
if (!Array.isArray(entity.observations)) {
entity.observations = []
}
const newObservations = o.contents.filter((content) => !entity.observations.includes(content))
entity.observations.push(...newObservations)
return { entityName: o.entityName, addedObservations: newObservations }
if (newObservations.length > 0) {
entity.observations.push(...newObservations)
results.push({ entityName: o.entityName, addedObservations: newObservations })
changed = true
} else {
// Still include in results even if nothing was added, to confirm processing
results.push({ entityName: o.entityName, addedObservations: [] })
}
})
await this.saveGraph(graph)
if (changed) {
await this._persistGraph()
}
return results
}
async deleteEntities(entityNames: string[]): Promise<void> {
const graph = await this.loadGraph()
graph.entities = graph.entities.filter((e) => !entityNames.includes(e.name))
graph.relations = graph.relations.filter((r) => !entityNames.includes(r.from) && !entityNames.includes(r.to))
await this.saveGraph(graph)
let changed = false
const namesToDelete = new Set(entityNames)
// Delete entities
namesToDelete.forEach((name) => {
if (this.entities.delete(name)) {
changed = true
}
})
// Delete relations involving deleted entities
const relationsToDelete = new Set<string>()
this.relations.forEach((relStr) => {
const rel = this._deserializeRelation(relStr)
if (namesToDelete.has(rel.from) || namesToDelete.has(rel.to)) {
relationsToDelete.add(relStr)
}
})
relationsToDelete.forEach((relStr) => {
if (this.relations.delete(relStr)) {
changed = true
}
})
if (changed) {
await this._persistGraph()
}
}
async deleteObservations(deletions: { entityName: string; observations: string[] }[]): Promise<void> {
const graph = await this.loadGraph()
let changed = false
deletions.forEach((d) => {
const entity = graph.entities.find((e) => e.name === d.entityName)
if (entity) {
entity.observations = entity.observations.filter((o) => !d.observations.includes(o))
const entity = this.entities.get(d.entityName)
if (entity && Array.isArray(entity.observations)) {
const initialLength = entity.observations.length
const observationsToDelete = new Set(d.observations)
entity.observations = entity.observations.filter((o) => !observationsToDelete.has(o))
if (entity.observations.length !== initialLength) {
changed = true
}
}
})
await this.saveGraph(graph)
if (changed) {
await this._persistGraph()
}
}
async deleteRelations(relations: Relation[]): Promise<void> {
const graph = await this.loadGraph()
graph.relations = graph.relations.filter(
(r) =>
!relations.some(
(delRelation) =>
r.from === delRelation.from && r.to === delRelation.to && r.relationType === delRelation.relationType
)
)
await this.saveGraph(graph)
let changed = false
relations.forEach((rel) => {
const relStr = this._serializeRelation(rel)
if (this.relations.delete(relStr)) {
changed = true
}
})
if (changed) {
await this._persistGraph()
}
}
// Read the current state from memory
async readGraph(): Promise<KnowledgeGraph> {
return this.loadGraph()
// Return a deep copy to prevent external modification of the internal state
return JSON.parse(
JSON.stringify({
entities: Array.from(this.entities.values()),
relations: Array.from(this.relations).map((rStr) => this._deserializeRelation(rStr))
})
)
}
// Very basic search function
// Search operates on the in-memory graph
async searchNodes(query: string): Promise<KnowledgeGraph> {
const graph = await this.loadGraph()
// Filter entities
const filteredEntities = graph.entities.filter(
const lowerCaseQuery = query.toLowerCase()
const filteredEntities = Array.from(this.entities.values()).filter(
(e) =>
e.name.toLowerCase().includes(query.toLowerCase()) ||
e.entityType.toLowerCase().includes(query.toLowerCase()) ||
e.observations.some((o) => o.toLowerCase().includes(query.toLowerCase()))
e.name.toLowerCase().includes(lowerCaseQuery) ||
e.entityType.toLowerCase().includes(lowerCaseQuery) ||
(Array.isArray(e.observations) && e.observations.some((o) => o.toLowerCase().includes(lowerCaseQuery)))
)
// Create a Set of filtered entity names for quick lookup
const filteredEntityNames = new Set(filteredEntities.map((e) => e.name))
// Filter relations to only include those between filtered entities
const filteredRelations = graph.relations.filter(
(r) => filteredEntityNames.has(r.from) && filteredEntityNames.has(r.to)
)
const filteredRelations = Array.from(this.relations)
.map((rStr) => this._deserializeRelation(rStr))
.filter((r) => filteredEntityNames.has(r.from) && filteredEntityNames.has(r.to))
const filteredGraph: KnowledgeGraph = {
return {
entities: filteredEntities,
relations: filteredRelations
}
return filteredGraph
}
// Open operates on the in-memory graph
async openNodes(names: string[]): Promise<KnowledgeGraph> {
const graph = await this.loadGraph()
// Filter entities
const filteredEntities = graph.entities.filter((e) => names.includes(e.name))
// Create a Set of filtered entity names for quick lookup
const nameSet = new Set(names)
const filteredEntities = Array.from(this.entities.values()).filter((e) => nameSet.has(e.name))
const filteredEntityNames = new Set(filteredEntities.map((e) => e.name))
// Filter relations to only include those between filtered entities
const filteredRelations = graph.relations.filter(
(r) => filteredEntityNames.has(r.from) && filteredEntityNames.has(r.to)
)
const filteredRelations = Array.from(this.relations)
.map((rStr) => this._deserializeRelation(rStr))
.filter((r) => filteredEntityNames.has(r.from) && filteredEntityNames.has(r.to))
const filteredGraph: KnowledgeGraph = {
return {
entities: filteredEntities,
relations: filteredRelations
}
return filteredGraph
}
}
class MemoryServer {
public server: Server
private knowledgeGraphManager: KnowledgeGraphManager
// Hold the manager instance, initialized asynchronously
private knowledgeGraphManager: KnowledgeGraphManager | null = null
private initializationPromise: Promise<void> // To track initialization
constructor(envPath: string = '') {
const memoryPath = envPath
? path.isAbsolute(envPath)
? envPath
: path.join(path.dirname(fileURLToPath(import.meta.url)), envPath)
: path.resolve(envPath) // Use path.resolve for relative paths based on CWD
: defaultMemoryPath
this.knowledgeGraphManager = new KnowledgeGraphManager(memoryPath)
this.server = new Server(
{
name: 'memory-server',
version: '1.0.0'
version: '1.1.0' // Incremented version for changes
},
{
capabilities: {
@@ -231,17 +346,53 @@ class MemoryServer {
}
}
)
this.initialize()
// Start initialization, but don't block constructor
this.initializationPromise = this._initializeManager(memoryPath)
this.setupRequestHandlers() // Setup handlers immediately
}
initialize() {
// The server instance and tools exposed to Claude
// Private async method to handle manager initialization
private async _initializeManager(memoryPath: string): Promise<void> {
try {
this.knowledgeGraphManager = await KnowledgeGraphManager.create(memoryPath)
console.log('KnowledgeGraphManager initialized successfully.')
} catch (error) {
console.error('Failed to initialize KnowledgeGraphManager:', error)
// Server might be unusable, consider how to handle this state
// Maybe set a flag and return errors for all tool calls?
this.knowledgeGraphManager = null // Ensure it's null if init fails
}
}
// Ensures the manager is initialized before handling tool calls
private async _getManager(): Promise<KnowledgeGraphManager> {
await this.initializationPromise // Wait for initialization to complete
if (!this.knowledgeGraphManager) {
throw new McpError(ErrorCode.InternalError, 'Memory server failed to initialize. Cannot process requests.')
}
return this.knowledgeGraphManager
}
// Setup handlers (can be called from constructor)
setupRequestHandlers() {
// ListTools remains largely the same, descriptions might be updated if needed
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
// Ensure manager is ready before listing tools that depend on it
// Although ListTools itself doesn't *call* the manager, it implies the
// manager is ready to handle calls for those tools.
try {
await this._getManager() // Wait for initialization before confirming tools are available
} catch (error) {
// If manager failed to init, maybe return an empty tool list or throw?
console.error('Cannot list tools, manager initialization failed:', error)
return { tools: [] } // Return empty list if server is not ready
}
return {
tools: [
{
name: 'create_entities',
description: 'Create multiple new entities in the knowledge graph',
description: 'Create multiple new entities in the knowledge graph. Skips existing entities.',
inputSchema: {
type: 'object',
properties: {
@@ -255,10 +406,11 @@ class MemoryServer {
observations: {
type: 'array',
items: { type: 'string' },
description: 'An array of observation contents associated with the entity'
description: 'An array of observation contents associated with the entity',
default: [] // Add default empty array
}
},
required: ['name', 'entityType', 'observations']
required: ['name', 'entityType'] // Observations are optional now on creation
}
}
},
@@ -268,7 +420,7 @@ class MemoryServer {
{
name: 'create_relations',
description:
'Create multiple new relations between entities in the knowledge graph. Relations should be in active voice',
'Create multiple new relations between EXISTING entities. Skips existing relations or relations with non-existent entities.',
inputSchema: {
type: 'object',
properties: {
@@ -290,7 +442,7 @@ class MemoryServer {
},
{
name: 'add_observations',
description: 'Add new observations to existing entities in the knowledge graph',
description: 'Add new observations to existing entities. Skips duplicate observations.',
inputSchema: {
type: 'object',
properties: {
@@ -315,7 +467,7 @@ class MemoryServer {
},
{
name: 'delete_entities',
description: 'Delete multiple entities and their associated relations from the knowledge graph',
description: 'Delete multiple entities and their associated relations.',
inputSchema: {
type: 'object',
properties: {
@@ -330,7 +482,7 @@ class MemoryServer {
},
{
name: 'delete_observations',
description: 'Delete specific observations from entities in the knowledge graph',
description: 'Delete specific observations from entities.',
inputSchema: {
type: 'object',
properties: {
@@ -355,7 +507,7 @@ class MemoryServer {
},
{
name: 'delete_relations',
description: 'Delete multiple relations from the knowledge graph',
description: 'Delete multiple specific relations.',
inputSchema: {
type: 'object',
properties: {
@@ -378,7 +530,7 @@ class MemoryServer {
},
{
name: 'read_graph',
description: 'Read the entire knowledge graph',
description: 'Read the entire knowledge graph from memory.',
inputSchema: {
type: 'object',
properties: {}
@@ -386,7 +538,7 @@ class MemoryServer {
},
{
name: 'search_nodes',
description: 'Search for nodes in the knowledge graph based on a query',
description: 'Search nodes (entities and relations) in memory based on a query.',
inputSchema: {
type: 'object',
properties: {
@@ -400,7 +552,7 @@ class MemoryServer {
},
{
name: 'open_nodes',
description: 'Open specific nodes in the knowledge graph by their names',
description: 'Retrieve specific entities and their connecting relations from memory by name.',
inputSchema: {
type: 'object',
properties: {
@@ -417,90 +569,129 @@ class MemoryServer {
}
})
// CallTool handler needs to await the manager and the async methods
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
const manager = await this._getManager() // Ensure manager is ready
const { name, arguments: args } = request.params
if (!args) {
throw new Error(`No arguments provided for tool: ${name}`)
// Use McpError for standard errors
throw new McpError(ErrorCode.InvalidParams, `No arguments provided for tool: ${name}`)
}
switch (name) {
case 'create_entities':
return {
content: [
{
type: 'text',
text: JSON.stringify(
await this.knowledgeGraphManager.createEntities(args.entities as Entity[]),
null,
2
)
}
]
}
case 'create_relations':
return {
content: [
{
type: 'text',
text: JSON.stringify(
await this.knowledgeGraphManager.createRelations(args.relations as Relation[]),
null,
2
)
}
]
}
case 'add_observations':
return {
content: [
{
type: 'text',
text: JSON.stringify(
await this.knowledgeGraphManager.addObservations(
args.observations as { entityName: string; contents: string[] }[]
),
null,
2
)
}
]
}
case 'delete_entities':
await this.knowledgeGraphManager.deleteEntities(args.entityNames as string[])
return { content: [{ type: 'text', text: 'Entities deleted successfully' }] }
case 'delete_observations':
await this.knowledgeGraphManager.deleteObservations(
args.deletions as { entityName: string; observations: string[] }[]
)
return { content: [{ type: 'text', text: 'Observations deleted successfully' }] }
case 'delete_relations':
await this.knowledgeGraphManager.deleteRelations(args.relations as Relation[])
return { content: [{ type: 'text', text: 'Relations deleted successfully' }] }
case 'read_graph':
return {
content: [{ type: 'text', text: JSON.stringify(await this.knowledgeGraphManager.readGraph(), null, 2) }]
}
case 'search_nodes':
return {
content: [
{
type: 'text',
text: JSON.stringify(await this.knowledgeGraphManager.searchNodes(args.query as string), null, 2)
}
]
}
case 'open_nodes':
return {
content: [
{
type: 'text',
text: JSON.stringify(await this.knowledgeGraphManager.openNodes(args.names as string[]), null, 2)
}
]
}
default:
throw new Error(`Unknown tool: ${name}`)
try {
switch (name) {
case 'create_entities':
// Validate args structure if necessary, though SDK might do basic validation
if (!args.entities || !Array.isArray(args.entities)) {
throw new McpError(
ErrorCode.InvalidParams,
`Invalid arguments for ${name}: 'entities' array is required.`
)
}
return {
content: [
{ type: 'text', text: JSON.stringify(await manager.createEntities(args.entities as Entity[]), null, 2) }
]
}
case 'create_relations':
if (!args.relations || !Array.isArray(args.relations)) {
throw new McpError(
ErrorCode.InvalidParams,
`Invalid arguments for ${name}: 'relations' array is required.`
)
}
return {
content: [
{
type: 'text',
text: JSON.stringify(await manager.createRelations(args.relations as Relation[]), null, 2)
}
]
}
case 'add_observations':
if (!args.observations || !Array.isArray(args.observations)) {
throw new McpError(
ErrorCode.InvalidParams,
`Invalid arguments for ${name}: 'observations' array is required.`
)
}
return {
content: [
{
type: 'text',
text: JSON.stringify(
await manager.addObservations(args.observations as { entityName: string; contents: string[] }[]),
null,
2
)
}
]
}
case 'delete_entities':
if (!args.entityNames || !Array.isArray(args.entityNames)) {
throw new McpError(
ErrorCode.InvalidParams,
`Invalid arguments for ${name}: 'entityNames' array is required.`
)
}
await manager.deleteEntities(args.entityNames as string[])
return { content: [{ type: 'text', text: 'Entities deleted successfully' }] }
case 'delete_observations':
if (!args.deletions || !Array.isArray(args.deletions)) {
throw new McpError(
ErrorCode.InvalidParams,
`Invalid arguments for ${name}: 'deletions' array is required.`
)
}
await manager.deleteObservations(args.deletions as { entityName: string; observations: string[] }[])
return { content: [{ type: 'text', text: 'Observations deleted successfully' }] }
case 'delete_relations':
if (!args.relations || !Array.isArray(args.relations)) {
throw new McpError(
ErrorCode.InvalidParams,
`Invalid arguments for ${name}: 'relations' array is required.`
)
}
await manager.deleteRelations(args.relations as Relation[])
return { content: [{ type: 'text', text: 'Relations deleted successfully' }] }
case 'read_graph':
// No arguments expected or needed for read_graph based on original schema
return {
content: [{ type: 'text', text: JSON.stringify(await manager.readGraph(), null, 2) }]
}
case 'search_nodes':
if (typeof args.query !== 'string') {
throw new McpError(ErrorCode.InvalidParams, `Invalid arguments for ${name}: 'query' string is required.`)
}
return {
content: [
{ type: 'text', text: JSON.stringify(await manager.searchNodes(args.query as string), null, 2) }
]
}
case 'open_nodes':
if (!args.names || !Array.isArray(args.names)) {
throw new McpError(ErrorCode.InvalidParams, `Invalid arguments for ${name}: 'names' array is required.`)
}
return {
content: [
{ type: 'text', text: JSON.stringify(await manager.openNodes(args.names as string[]), null, 2) }
]
}
default:
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`)
}
} catch (error) {
// Catch errors from manager methods (like entity not found) or other issues
if (error instanceof McpError) {
throw error // Re-throw McpErrors directly
}
console.error(`Error executing tool ${name}:`, error)
// Throw a generic internal error for unexpected issues
throw new McpError(
ErrorCode.InternalError,
`Error executing tool ${name}: ${error instanceof Error ? error.message : String(error)}`
)
}
})
}

View File

@@ -0,0 +1,321 @@
// src/main/mcpServers/simpleremember.ts
import { getConfigDir } from '@main/utils/file'
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import {
CallToolRequestSchema,
ErrorCode,
ListPromptsRequestSchema,
ListToolsRequestSchema,
McpError
} from '@modelcontextprotocol/sdk/types.js'
import { Mutex } from 'async-mutex'
import { promises as fs } from 'fs'
import path from 'path'
// 定义记忆文件路径
const defaultMemoryPath = path.join(getConfigDir(), 'simpleremember.json')
// 记忆项接口
interface Memory {
content: string
createdAt: string
}
// 记忆存储结构
interface MemoryStorage {
memories: Memory[]
}
class SimpleRememberManager {
private memoryPath: string
private memories: Memory[] = []
private fileMutex: Mutex = new Mutex()
constructor(memoryPath: string) {
this.memoryPath = memoryPath
}
// 静态工厂方法用于初始化
public static async create(memoryPath: string): Promise<SimpleRememberManager> {
const manager = new SimpleRememberManager(memoryPath)
await manager._ensureMemoryPathExists()
await manager._loadMemoriesFromDisk()
return manager
}
// 确保记忆文件存在
private async _ensureMemoryPathExists(): Promise<void> {
try {
const directory = path.dirname(this.memoryPath)
await fs.mkdir(directory, { recursive: true })
try {
await fs.access(this.memoryPath)
} catch (error) {
// 文件不存在,创建一个空文件
await fs.writeFile(this.memoryPath, JSON.stringify({ memories: [] }, null, 2))
}
} catch (error) {
console.error('Failed to ensure memory path exists:', error)
throw new McpError(
ErrorCode.InternalError,
`Failed to ensure memory path: ${error instanceof Error ? error.message : String(error)}`
)
}
}
// 从磁盘加载记忆
private async _loadMemoriesFromDisk(): Promise<void> {
try {
const data = await fs.readFile(this.memoryPath, 'utf-8')
// 处理空文件情况
if (data.trim() === '') {
this.memories = []
await this._persistMemories()
return
}
const storage: MemoryStorage = JSON.parse(data)
this.memories = storage.memories || []
} catch (error) {
if (error instanceof Error && 'code' in error && (error as any).code === 'ENOENT') {
this.memories = []
await this._persistMemories()
} else if (error instanceof SyntaxError) {
console.error('Failed to parse simpleremember.json, initializing with empty memories:', error)
this.memories = []
await this._persistMemories()
} else {
console.error('Unexpected error loading memories:', error)
throw new McpError(
ErrorCode.InternalError,
`Failed to load memories: ${error instanceof Error ? error.message : String(error)}`
)
}
}
}
// 将记忆持久化到磁盘
private async _persistMemories(): Promise<void> {
const release = await this.fileMutex.acquire()
try {
const storage: MemoryStorage = {
memories: this.memories
}
await fs.writeFile(this.memoryPath, JSON.stringify(storage, null, 2))
} catch (error) {
console.error('Failed to save memories:', error)
throw new McpError(
ErrorCode.InternalError,
`Failed to save memories: ${error instanceof Error ? error.message : String(error)}`
)
} finally {
release()
}
}
// 添加新记忆
async remember(memory: string): Promise<Memory> {
const newMemory: Memory = {
content: memory,
createdAt: new Date().toISOString()
}
this.memories.push(newMemory)
await this._persistMemories()
return newMemory
}
// 获取所有记忆
async getAllMemories(): Promise<Memory[]> {
return [...this.memories]
}
// 获取记忆 - 这个方法会被get_memories工具调用
async get_memories(): Promise<Memory[]> {
return this.getAllMemories()
}
}
// 定义工具 - 按照MCP规范定义工具
const REMEMBER_TOOL = {
name: 'remember',
description:
'用于记忆长期有用信息的工具。这个工具会自动应用记忆,无需显式调用。只用于存储长期有用的信息,不适合临时信息。',
inputSchema: {
type: 'object',
properties: {
memory: {
type: 'string',
description: '要记住的简洁(1句话)记忆内容'
}
},
required: ['memory']
}
}
const GET_MEMORIES_TOOL = {
name: 'get_memories',
description: '获取所有已存储的记忆',
inputSchema: {
type: 'object',
properties: {}
}
}
// 添加日志以便调试
console.log('[SimpleRemember] Defined tools:', { REMEMBER_TOOL, GET_MEMORIES_TOOL })
class SimpleRememberServer {
public server: Server
private simpleRememberManager: SimpleRememberManager | null = null
private initializationPromise: Promise<void>
constructor(envPath: string = '') {
const memoryPath = envPath ? (path.isAbsolute(envPath) ? envPath : path.resolve(envPath)) : defaultMemoryPath
console.log('[SimpleRemember] Creating server with memory path:', memoryPath)
// 初始化服务器
this.server = new Server(
{
name: 'simple-remember-server',
version: '1.0.0'
},
{
capabilities: {
tools: {
// 按照MCP规范声明工具能力
listChanged: true
},
// 添加空的prompts能力表示支持提示词功能但没有实际的提示词
prompts: {}
}
}
)
console.log('[SimpleRemember] Server initialized with tools capability')
// 手动添加工具到服务器的工具列表中
console.log('[SimpleRemember] Adding tools to server')
// 先设置请求处理程序,再初始化管理器
this.setupRequestHandlers()
this.initializationPromise = this._initializeManager(memoryPath)
console.log('[SimpleRemember] Server initialization complete')
// 打印工具信息以确认它们已注册
console.log('[SimpleRemember] Tools registered:', [REMEMBER_TOOL.name, GET_MEMORIES_TOOL.name])
}
private async _initializeManager(memoryPath: string): Promise<void> {
try {
this.simpleRememberManager = await SimpleRememberManager.create(memoryPath)
console.log('SimpleRememberManager initialized successfully.')
} catch (error) {
console.error('Failed to initialize SimpleRememberManager:', error)
this.simpleRememberManager = null
}
}
private async _getManager(): Promise<SimpleRememberManager> {
if (!this.simpleRememberManager) {
await this.initializationPromise
if (!this.simpleRememberManager) {
throw new McpError(ErrorCode.InternalError, 'SimpleRememberManager is not initialized')
}
}
return this.simpleRememberManager
}
setupRequestHandlers() {
// 添加对prompts/list请求的处理
this.server.setRequestHandler(ListPromptsRequestSchema, async (request) => {
console.log('[SimpleRemember] Listing prompts request received', request)
// 返回空的提示词列表
return {
prompts: []
}
})
this.server.setRequestHandler(ListToolsRequestSchema, async (request) => {
// 直接返回工具列表,不需要等待管理器初始化
console.log('[SimpleRemember] Listing tools request received', request)
// 打印工具定义以确保它们存在
console.log('[SimpleRemember] REMEMBER_TOOL:', JSON.stringify(REMEMBER_TOOL))
console.log('[SimpleRemember] GET_MEMORIES_TOOL:', JSON.stringify(GET_MEMORIES_TOOL))
const toolsList = [REMEMBER_TOOL, GET_MEMORIES_TOOL]
console.log('[SimpleRemember] Returning tools:', JSON.stringify(toolsList))
// 按照MCP规范返回工具列表
return {
tools: toolsList
// 如果有分页可以添加nextCursor
// nextCursor: "next-page-cursor"
}
})
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params
console.log(`[SimpleRemember] Received tool call: ${name}`, args)
try {
const manager = await this._getManager()
if (name === 'remember') {
if (!args || typeof args.memory !== 'string') {
console.error(`[SimpleRemember] Invalid arguments for ${name}:`, args)
throw new McpError(ErrorCode.InvalidParams, `Invalid arguments for ${name}: 'memory' string is required.`)
}
console.log(`[SimpleRemember] Remembering: "${args.memory}"`)
const result = await manager.remember(args.memory)
console.log(`[SimpleRemember] Memory saved successfully:`, result)
// 按照MCP规范返回工具调用结果
return {
content: [
{
type: 'text',
text: `记忆已保存: "${args.memory}"`
}
],
isError: false
}
}
if (name === 'get_memories') {
console.log(`[SimpleRemember] Getting all memories`)
const memories = await manager.get_memories()
console.log(`[SimpleRemember] Retrieved ${memories.length} memories`)
// 按照MCP规范返回工具调用结果
return {
content: [
{
type: 'text',
text: JSON.stringify(memories, null, 2)
}
],
isError: false
}
}
console.error(`[SimpleRemember] Unknown tool: ${name}`)
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`)
} catch (error) {
console.error(`[SimpleRemember] Error handling tool call ${name}:`, error)
// 按照MCP规范返回工具调用错误
return {
content: [
{
type: 'text',
text: error instanceof Error ? error.message : String(error)
}
],
isError: true
}
}
})
}
}
export default SimpleRememberServer

View File

@@ -8,6 +8,7 @@ import * as fs from 'fs-extra'
import * as path from 'path'
import { createClient, CreateDirectoryOptions, FileStat } from 'webdav'
import { getConfigDir } from '../utils/file'
import WebDav from './WebDav'
import { windowService } from './WindowService'
@@ -111,10 +112,29 @@ class BackupManager {
// 使用流式复制
await this.copyDirWithProgress(sourcePath, tempDataDir, (size) => {
copiedSize += size
const progress = Math.min(80, 20 + Math.floor((copiedSize / totalSize) * 60))
const progress = Math.min(70, 20 + Math.floor((copiedSize / totalSize) * 50))
onProgress({ stage: 'copying_files', progress, total: 100 })
})
// 复制记忆数据文件
const configDir = getConfigDir()
const memoryDataPath = path.join(configDir, 'memory-data.json')
const tempConfigDir = path.join(this.tempDir, 'Config')
const tempMemoryDataPath = path.join(tempConfigDir, 'memory-data.json')
// 确保目录存在
await fs.ensureDir(tempConfigDir)
// 如果记忆数据文件存在,则复制
if (await fs.pathExists(memoryDataPath)) {
await fs.copy(memoryDataPath, tempMemoryDataPath)
Logger.log('[BackupManager] Memory data file copied')
onProgress({ stage: 'copying_memory_data', progress: 75, total: 100 })
} else {
Logger.log('[BackupManager] Memory data file not found, skipping')
onProgress({ stage: 'copying_memory_data', progress: 75, total: 100 })
}
await this.setWritableRecursive(tempDataDir)
onProgress({ stage: 'compressing', progress: 80, total: 100 })
@@ -176,11 +196,32 @@ class BackupManager {
// 使用流式复制
await this.copyDirWithProgress(sourcePath, destPath, (size) => {
copiedSize += size
const progress = Math.min(90, 40 + Math.floor((copiedSize / totalSize) * 50))
const progress = Math.min(80, 40 + Math.floor((copiedSize / totalSize) * 40))
onProgress({ stage: 'copying_files', progress, total: 100 })
})
Logger.log('[backup] step 4: clean up temp directory')
// 恢复记忆数据文件
Logger.log('[backup] step 4: restore memory data file')
const tempConfigDir = path.join(this.tempDir, 'Config')
const tempMemoryDataPath = path.join(tempConfigDir, 'memory-data.json')
if (await fs.pathExists(tempMemoryDataPath)) {
const configDir = getConfigDir()
const memoryDataPath = path.join(configDir, 'memory-data.json')
// 确保目录存在
await fs.ensureDir(configDir)
// 复制记忆数据文件
await fs.copy(tempMemoryDataPath, memoryDataPath)
Logger.log('[backup] Memory data file restored')
onProgress({ stage: 'restoring_memory_data', progress: 90, total: 100 })
} else {
Logger.log('[backup] Memory data file not found in backup, skipping')
onProgress({ stage: 'restoring_memory_data', progress: 90, total: 100 })
}
Logger.log('[backup] step 5: clean up temp directory')
// 清理临时目录
await this.setWritableRecursive(this.tempDir)
await fs.remove(this.tempDir)

View File

@@ -10,13 +10,47 @@ import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
import { getDefaultEnvironment, StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory'
import { nanoid } from '@reduxjs/toolkit'
import { MCPServer, MCPTool } from '@types'
import { GetMCPPromptResponse, MCPPrompt, MCPServer, MCPTool } from '@types'
import { app } from 'electron'
import Logger from 'electron-log'
import { CacheService } from './CacheService'
import { StreamableHTTPClientTransport, type StreamableHTTPClientTransportOptions } from './MCPStreamableHttpClient'
// Generic type for caching wrapped functions
type CachedFunction<T extends unknown[], R> = (...args: T) => Promise<R>
/**
* Higher-order function to add caching capability to any async function
* @param fn The original function to be wrapped with caching
* @param getCacheKey Function to generate a cache key from the function arguments
* @param ttl Time to live for the cache entry in milliseconds
* @param logPrefix Prefix for log messages
* @returns The wrapped function with caching capability
*/
function withCache<T extends unknown[], R>(
fn: (...args: T) => Promise<R>,
getCacheKey: (...args: T) => string,
ttl: number,
logPrefix: string
): CachedFunction<T, R> {
return async (...args: T): Promise<R> => {
const cacheKey = getCacheKey(...args)
if (CacheService.has(cacheKey)) {
Logger.info(`${logPrefix} loaded from cache`)
const cachedData = CacheService.get<R>(cacheKey)
if (cachedData) {
return cachedData
}
}
const result = await fn(...args)
CacheService.set(cacheKey, result, ttl)
return result
}
}
class McpService {
private clients: Map<string, Client> = new Map()
@@ -35,6 +69,8 @@ class McpService {
this.initClient = this.initClient.bind(this)
this.listTools = this.listTools.bind(this)
this.callTool = this.callTool.bind(this)
this.listPrompts = this.listPrompts.bind(this)
this.getPrompt = this.getPrompt.bind(this)
this.closeClient = this.closeClient.bind(this)
this.removeServer = this.removeServer.bind(this)
this.restartServer = this.restartServer.bind(this)
@@ -216,31 +252,40 @@ class McpService {
}
}
async listTools(_: Electron.IpcMainInvokeEvent, server: MCPServer) {
const client = await this.initClient(server)
const serverKey = this.getServerKey(server)
const cacheKey = `mcp:list_tool:${serverKey}`
if (CacheService.has(cacheKey)) {
Logger.info(`[MCP] Tools from ${server.name} loaded from cache`)
const cachedTools = CacheService.get<MCPTool[]>(cacheKey)
if (cachedTools && cachedTools.length > 0) {
return cachedTools
}
}
private async listToolsImpl(server: MCPServer): Promise<MCPTool[]> {
Logger.info(`[MCP] Listing tools for server: ${server.name}`)
const { tools } = await client.listTools()
const serverTools: MCPTool[] = []
tools.map((tool: any) => {
const serverTool: MCPTool = {
...tool,
id: `f${nanoid()}`,
serverId: server.id,
serverName: server.name
}
serverTools.push(serverTool)
})
CacheService.set(cacheKey, serverTools, 5 * 60 * 1000)
return serverTools
const client = await this.initClient(server)
try {
const { tools } = await client.listTools()
const serverTools: MCPTool[] = []
tools.map((tool: any) => {
const serverTool: MCPTool = {
...tool,
id: `f${nanoid()}`,
serverId: server.id,
serverName: server.name
}
serverTools.push(serverTool)
})
return serverTools
} catch (error) {
Logger.error(`[MCP] Failed to list tools for server: ${server.name}`, error)
return []
}
}
async listTools(_: Electron.IpcMainInvokeEvent, server: MCPServer) {
const cachedListTools = withCache<[MCPServer], MCPTool[]>(
this.listToolsImpl.bind(this),
(server) => {
const serverKey = this.getServerKey(server)
return `mcp:list_tool:${serverKey}`
},
5 * 60 * 1000, // 5 minutes TTL
`[MCP] Tools from ${server.name}`
)
return cachedListTools(server)
}
/**
@@ -270,6 +315,76 @@ class McpService {
return { dir, uvPath, bunPath }
}
/**
* List prompts available on an MCP server
*/
private async listPromptsImpl(server: MCPServer): Promise<MCPPrompt[]> {
Logger.info(`[MCP] Listing prompts for server: ${server.name}`)
const client = await this.initClient(server)
try {
const { prompts } = await client.listPrompts()
const serverPrompts = prompts.map((prompt: any) => ({
...prompt,
id: `p${nanoid()}`,
serverId: server.id,
serverName: server.name
}))
return serverPrompts
} catch (error) {
Logger.error(`[MCP] Failed to list prompts for server: ${server.name}`, error)
return []
}
}
/**
* List prompts available on an MCP server with caching
*/
public async listPrompts(_: Electron.IpcMainInvokeEvent, server: MCPServer): Promise<MCPPrompt[]> {
const cachedListPrompts = withCache<[MCPServer], MCPPrompt[]>(
this.listPromptsImpl.bind(this),
(server) => {
const serverKey = this.getServerKey(server)
return `mcp:list_prompts:${serverKey}`
},
60 * 60 * 1000, // 60 minutes TTL
`[MCP] Prompts from ${server.name}`
)
return cachedListPrompts(server)
}
/**
* Get a specific prompt from an MCP server (implementation)
*/
private async getPromptImpl(
server: MCPServer,
name: string,
args?: Record<string, any>
): Promise<GetMCPPromptResponse> {
Logger.info(`[MCP] Getting prompt ${name} from server: ${server.name}`)
const client = await this.initClient(server)
return await client.getPrompt({ name, arguments: args })
}
/**
* Get a specific prompt from an MCP server with caching
*/
public async getPrompt(
_: Electron.IpcMainInvokeEvent,
{ server, name, args }: { server: MCPServer; name: string; args?: Record<string, any> }
): Promise<GetMCPPromptResponse> {
const cachedGetPrompt = withCache<[MCPServer, string, Record<string, any> | undefined], GetMCPPromptResponse>(
this.getPromptImpl.bind(this),
(server, name, args) => {
const serverKey = this.getServerKey(server)
const argsKey = args ? JSON.stringify(args) : 'no-args'
return `mcp:get_prompt:${serverKey}:${name}:${argsKey}`
},
30 * 60 * 1000, // 30 minutes TTL
`[MCP] Prompt ${name} from ${server.name}`
)
return await cachedGetPrompt(server, name, args)
}
/**
* Get enhanced PATH including common tool locations
*/

View File

@@ -111,6 +111,20 @@ export class StreamableHTTPClientTransport implements Transport {
headers.set('last-event-id', this._lastEventId)
}
// 删除可能存在的HTTP/2伪头部
if (headers.has(':path')) {
headers.delete(':path')
}
if (headers.has(':method')) {
headers.delete(':method')
}
if (headers.has(':authority')) {
headers.delete(':authority')
}
if (headers.has(':scheme')) {
headers.delete(':scheme')
}
const response = await fetch(this._url, {
method: 'GET',
headers,
@@ -216,6 +230,21 @@ export class StreamableHTTPClientTransport implements Transport {
headers.set('content-type', 'application/json')
headers.set('accept', 'application/json, text/event-stream')
// 添加错误处理确保不使用HTTP/2伪头部
// 删除可能存在的HTTP/2伪头部
if (headers.has(':path')) {
headers.delete(':path')
}
if (headers.has(':method')) {
headers.delete(':method')
}
if (headers.has(':authority')) {
headers.delete(':authority')
}
if (headers.has(':scheme')) {
headers.delete(':scheme')
}
const init = {
...this._requestInit,
method: 'POST',

View File

@@ -0,0 +1,310 @@
import log from 'electron-log'
import { promises as fs } from 'fs'
import path from 'path'
import { getConfigDir } from '../utils/file'
// 定义记忆文件路径
const memoryDataPath = path.join(getConfigDir(), 'memory-data.json')
// 定义长期记忆文件路径
const longTermMemoryDataPath = path.join(getConfigDir(), 'long-term-memory-data.json')
export class MemoryFileService {
constructor() {
this.registerIpcHandlers()
}
async loadData() {
try {
// 确保配置目录存在
const configDir = path.dirname(memoryDataPath)
try {
await fs.mkdir(configDir, { recursive: true })
} catch (mkdirError) {
log.warn('Failed to create config directory, it may already exist:', mkdirError)
}
// 检查文件是否存在
try {
await fs.access(memoryDataPath)
} catch (accessError) {
// 文件不存在,创建默认文件
log.info('Memory data file does not exist, creating default file')
const defaultData = {
memoryLists: [
{
id: 'default',
name: '默认列表',
isActive: true
}
],
shortMemories: [],
analyzeModel: 'gpt-3.5-turbo',
shortMemoryAnalyzeModel: 'gpt-3.5-turbo',
historicalContextAnalyzeModel: 'gpt-3.5-turbo',
vectorizeModel: 'gpt-3.5-turbo'
}
await fs.writeFile(memoryDataPath, JSON.stringify(defaultData, null, 2))
return defaultData
}
// 读取文件
const data = await fs.readFile(memoryDataPath, 'utf-8')
const parsedData = JSON.parse(data)
log.info('Memory data loaded successfully')
return parsedData
} catch (error) {
log.error('Failed to load memory data:', error)
return null
}
}
async saveData(data: any, forceOverwrite: boolean = false) {
try {
// 确保配置目录存在
const configDir = path.dirname(memoryDataPath)
try {
await fs.mkdir(configDir, { recursive: true })
} catch (mkdirError) {
log.warn('Failed to create config directory, it may already exist:', mkdirError)
}
// 如果强制覆盖,直接使用传入的数据
if (forceOverwrite) {
log.info('Force overwrite enabled for short memory data, using provided data directly')
// 确保数据包含必要的字段
const defaultData = {
memoryLists: [],
shortMemories: [],
analyzeModel: '',
shortMemoryAnalyzeModel: '',
historicalContextAnalyzeModel: '',
vectorizeModel: ''
}
// 合并默认数据和传入的数据,确保数据结构完整
const completeData = { ...defaultData, ...data }
// 保存数据
await fs.writeFile(memoryDataPath, JSON.stringify(completeData, null, 2))
log.info('Memory data saved successfully (force overwrite)')
return true
}
// 尝试读取现有数据并合并
let existingData = {}
try {
await fs.access(memoryDataPath)
const fileContent = await fs.readFile(memoryDataPath, 'utf-8')
existingData = JSON.parse(fileContent)
log.info('Existing memory data loaded for merging')
} catch (readError) {
log.warn('No existing memory data found or failed to read:', readError)
// 如果文件不存在或读取失败,使用空对象
}
// 合并数据,注意数组的处理
const mergedData = { ...existingData }
// 处理每个属性
Object.entries(data).forEach(([key, value]) => {
// 如果是数组属性,需要特殊处理
if (Array.isArray(value) && Array.isArray(mergedData[key])) {
// 对于 shortMemories 和 memories直接使用传入的数组完全替换现有的记忆
if (key === 'shortMemories' || key === 'memories') {
mergedData[key] = value
log.info(`Replacing ${key} array with provided data`)
} else {
// 其他数组属性,使用新值
mergedData[key] = value
}
} else {
// 非数组属性,直接使用新值
mergedData[key] = value
}
})
// 保存合并后的数据
await fs.writeFile(memoryDataPath, JSON.stringify(mergedData, null, 2))
log.info('Memory data saved successfully')
return true
} catch (error) {
log.error('Failed to save memory data:', error)
return false
}
}
async loadLongTermData() {
try {
// 确保配置目录存在
const configDir = path.dirname(longTermMemoryDataPath)
try {
await fs.mkdir(configDir, { recursive: true })
} catch (mkdirError) {
log.warn('Failed to create config directory, it may already exist:', mkdirError)
}
// 检查文件是否存在
try {
await fs.access(longTermMemoryDataPath)
} catch (accessError) {
// 文件不存在,创建默认文件
log.info('Long-term memory data file does not exist, creating default file')
const now = new Date().toISOString()
const defaultData = {
memoryLists: [
{
id: 'default',
name: '默认列表',
isActive: true,
createdAt: now,
updatedAt: now
}
],
memories: [],
currentListId: 'default',
analyzeModel: 'gpt-3.5-turbo'
}
await fs.writeFile(longTermMemoryDataPath, JSON.stringify(defaultData, null, 2))
return defaultData
}
// 读取文件
const data = await fs.readFile(longTermMemoryDataPath, 'utf-8')
const parsedData = JSON.parse(data)
log.info('Long-term memory data loaded successfully')
return parsedData
} catch (error) {
log.error('Failed to load long-term memory data:', error)
return null
}
}
async saveLongTermData(data: any, forceOverwrite: boolean = false) {
try {
// 确保配置目录存在
const configDir = path.dirname(longTermMemoryDataPath)
try {
await fs.mkdir(configDir, { recursive: true })
} catch (mkdirError) {
log.warn('Failed to create config directory, it may already exist:', mkdirError)
}
// 如果强制覆盖,直接使用传入的数据
if (forceOverwrite) {
log.info('Force overwrite enabled, using provided data directly')
// 确保数据包含必要的字段
const defaultData = {
memoryLists: [],
memories: [],
currentListId: '',
analyzeModel: ''
}
// 合并默认数据和传入的数据,确保数据结构完整
const completeData = { ...defaultData, ...data }
// 保存数据
await fs.writeFile(longTermMemoryDataPath, JSON.stringify(completeData, null, 2))
log.info('Long-term memory data saved successfully (force overwrite)')
return true
}
// 尝试读取现有数据并合并
let existingData = {}
try {
await fs.access(longTermMemoryDataPath)
const fileContent = await fs.readFile(longTermMemoryDataPath, 'utf-8')
existingData = JSON.parse(fileContent)
log.info('Existing long-term memory data loaded for merging')
} catch (readError) {
log.warn('No existing long-term memory data found or failed to read:', readError)
// 如果文件不存在或读取失败,使用空对象
}
// 合并数据,注意数组的处理
const mergedData = { ...existingData }
// 处理每个属性
Object.entries(data).forEach(([key, value]) => {
// 如果是数组属性,需要特殊处理
if (Array.isArray(value) && Array.isArray(mergedData[key])) {
// 对于 memories 和 shortMemories直接使用传入的数组完全替换现有的记忆
if (key === 'memories' || key === 'shortMemories') {
mergedData[key] = value
log.info(`Replacing ${key} array with provided data`)
} else {
// 其他数组属性,使用新值
mergedData[key] = value
}
} else {
// 非数组属性,直接使用新值
mergedData[key] = value
}
})
// 保存合并后的数据
await fs.writeFile(longTermMemoryDataPath, JSON.stringify(mergedData, null, 2))
log.info('Long-term memory data saved successfully')
return true
} catch (error) {
log.error('Failed to save long-term memory data:', error)
return false
}
}
/**
* 删除指定ID的短期记忆
* @param id 要删除的短期记忆ID
* @returns 是否成功删除
*/
async deleteShortMemoryById(id: string) {
try {
// 检查文件是否存在
try {
await fs.access(memoryDataPath)
} catch (accessError) {
log.error('Memory data file does not exist, cannot delete memory')
return false
}
// 读取文件
const fileContent = await fs.readFile(memoryDataPath, 'utf-8')
const data = JSON.parse(fileContent)
// 检查shortMemories数组是否存在
if (!data.shortMemories || !Array.isArray(data.shortMemories)) {
log.error('No shortMemories array found in memory data file')
return false
}
// 过滤掉要删除的记忆
const originalLength = data.shortMemories.length
data.shortMemories = data.shortMemories.filter((memory: any) => memory.id !== id)
// 如果长度没变,说明没有找到要删除的记忆
if (data.shortMemories.length === originalLength) {
log.warn(`Short memory with ID ${id} not found, nothing to delete`)
return false
}
// 写回文件
await fs.writeFile(memoryDataPath, JSON.stringify(data, null, 2))
log.info(`Successfully deleted short memory with ID ${id}`)
return true
} catch (error) {
log.error('Failed to delete short memory:', error)
return false
}
}
private registerIpcHandlers() {
// 注册处理函数已移至ipc.ts文件中
// 这里不需要重复注册
}
}
// 创建并导出MemoryFileService实例
export const memoryFileService = new MemoryFileService()

View File

@@ -127,7 +127,15 @@ export class ProxyManager {
const [protocol, address] = proxyUrl.split('://')
const [host, port] = address.split(':')
if (!protocol.includes('socks')) {
setGlobalDispatcher(new ProxyAgent(proxyUrl))
// 使用标准方式创建ProxyAgent但添加错误处理
try {
// 尝试使用代理
const agent = new ProxyAgent(proxyUrl)
setGlobalDispatcher(agent)
console.log('[Proxy] Successfully set HTTP proxy:', proxyUrl)
} catch (error) {
console.error('[Proxy] Failed to set proxy:', error)
}
} else {
const dispatcher = socksDispatcher({
port: parseInt(port),

View File

@@ -151,6 +151,16 @@ declare global {
stopServer: (server: MCPServer) => Promise<void>
listTools: (server: MCPServer) => Promise<MCPTool[]>
callTool: ({ server, name, args }: { server: MCPServer; name: string; args: any }) => Promise<any>
listPrompts: (server: MCPServer) => Promise<MCPPrompt[]>
getPrompt: ({
server,
name,
args
}: {
server: MCPServer
name: string
args?: Record<string, any>
}) => Promise<GetMCPPromptResponse>
getInstallInfo: () => Promise<{ dir: string; uvPath: string; bunPath: string }>
}
copilot: {
@@ -180,6 +190,13 @@ declare global {
closeSearchWindow: (uid: string) => Promise<string>
openUrlInSearchWindow: (uid: string, url: string) => Promise<string>
}
memory: {
loadData: () => Promise<any>
saveData: (data: any, forceOverwrite?: boolean) => Promise<boolean>
deleteShortMemoryById: (id: string) => Promise<boolean>
loadLongTermData: () => Promise<any>
saveLongTermData: (data: any, forceOverwrite?: boolean) => Promise<boolean>
}
}
}
}

View File

@@ -130,8 +130,11 @@ const api = {
restartServer: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_RestartServer, server),
stopServer: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_StopServer, server),
listTools: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_ListTools, server),
callTool: ({ server, name, args }: { server: MCPServer; name: string; args: any }) =>
callTool: ({ server, name, args }: { server: MCPServer; name: string; args?: Record<string, any> }) =>
ipcRenderer.invoke(IpcChannel.Mcp_CallTool, { server, name, args }),
listPrompts: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_ListPrompts, server),
getPrompt: ({ server, name, args }: { server: MCPServer; name: string; args?: Record<string, any> }) =>
ipcRenderer.invoke(IpcChannel.Mcp_GetPrompt, { server, name, args }),
getInstallInfo: () => ipcRenderer.invoke(IpcChannel.Mcp_GetInstallInfo)
},
shell: {
@@ -174,6 +177,14 @@ const api = {
openSearchWindow: (uid: string) => ipcRenderer.invoke(IpcChannel.SearchWindow_Open, uid),
closeSearchWindow: (uid: string) => ipcRenderer.invoke(IpcChannel.SearchWindow_Close, uid),
openUrlInSearchWindow: (uid: string, url: string) => ipcRenderer.invoke(IpcChannel.SearchWindow_OpenUrl, uid, url)
},
memory: {
loadData: () => ipcRenderer.invoke(IpcChannel.Memory_LoadData),
saveData: (data: any) => ipcRenderer.invoke(IpcChannel.Memory_SaveData, data),
deleteShortMemoryById: (id: string) => ipcRenderer.invoke(IpcChannel.Memory_DeleteShortMemoryById, id),
loadLongTermData: () => ipcRenderer.invoke(IpcChannel.LongTermMemory_LoadData),
saveLongTermData: (data: any, forceOverwrite: boolean = false) =>
ipcRenderer.invoke(IpcChannel.LongTermMemory_SaveData, data, forceOverwrite)
}
}

View File

@@ -6,6 +6,7 @@ import { HashRouter, Route, Routes } from 'react-router-dom'
import { PersistGate } from 'redux-persist/integration/react'
import Sidebar from './components/app/Sidebar'
import MemoryProvider from './components/MemoryProvider'
import TopViewContainer from './components/TopView'
import AntdProvider from './context/AntdProvider'
import StyleSheetManager from './context/StyleSheetManager'
@@ -29,22 +30,24 @@ function App(): React.ReactElement {
<AntdProvider>
<SyntaxHighlighterProvider>
<PersistGate loading={null} persistor={persistor}>
<TopViewContainer>
<HashRouter>
<NavigationHandler />
<Sidebar />
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/agents" element={<AgentsPage />} />
<Route path="/paintings" element={<PaintingsPage />} />
<Route path="/translate" element={<TranslatePage />} />
<Route path="/files" element={<FilesPage />} />
<Route path="/knowledge" element={<KnowledgePage />} />
<Route path="/apps" element={<AppsPage />} />
<Route path="/settings/*" element={<SettingsPage />} />
</Routes>
</HashRouter>
</TopViewContainer>
<MemoryProvider>
<TopViewContainer>
<HashRouter>
<NavigationHandler />
<Sidebar />
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/agents" element={<AgentsPage />} />
<Route path="/paintings" element={<PaintingsPage />} />
<Route path="/translate" element={<TranslatePage />} />
<Route path="/files" element={<FilesPage />} />
<Route path="/knowledge" element={<KnowledgePage />} />
<Route path="/apps" element={<AppsPage />} />
<Route path="/settings/*" element={<SettingsPage />} />
</Routes>
</HashRouter>
</TopViewContainer>
</MemoryProvider>
</PersistGate>
</SyntaxHighlighterProvider>
</AntdProvider>

View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1744456106953" class="icon" viewBox="0 0 2633 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1486" xmlns:xlink="http://www.w3.org/1999/xlink" width="514.2578125" height="200"><path d="M0 0v877.843607h731.440328v146.156393H1316.724263v-146.156393h1316.724262V0z m731.440328 731.196014h-146.156393V292.312786h-146.485574v438.883228H146.485574V146.238688h584.954754z m438.880656 0v146.567869h-292.405369V146.238688H1463.209837v585.037049H1170.320984z m1316.888853 0H2341.30033V292.312786h-146.56787v438.883228h-146.485574V292.312786h-145.909508v438.883228H1609.283935V146.238688h878.008197zM1170.238688 292.477377H1316.724263v292.644539h-146.485575z" fill="#CB3837" p-id="1487"></path></svg>

After

Width:  |  Height:  |  Size: 845 B

View File

@@ -1,6 +1,7 @@
.markdown {
color: var(--color-text);
line-height: 1.6;
-webkit-user-select: text;
user-select: text;
word-break: break-word;

View File

@@ -1,4 +1,5 @@
import { Collapse } from 'antd'
import { merge } from 'lodash'
import { FC, memo } from 'react'
interface CustomCollapseProps {
@@ -9,6 +10,11 @@ interface CustomCollapseProps {
defaultActiveKey?: string[]
activeKey?: string[]
collapsible?: 'header' | 'icon' | 'disabled'
style?: React.CSSProperties
styles?: {
header?: React.CSSProperties
body?: React.CSSProperties
}
}
const CustomCollapse: FC<CustomCollapseProps> = ({
@@ -18,14 +24,17 @@ const CustomCollapse: FC<CustomCollapseProps> = ({
destroyInactivePanel = false,
defaultActiveKey = ['1'],
activeKey,
collapsible = undefined
collapsible = undefined,
style,
styles
}) => {
const CollapseStyle = {
const defaultCollapseStyle = {
width: '100%',
background: 'transparent',
border: '0.5px solid var(--color-border)'
}
const CollapseItemStyles = {
const defaultCollapseItemStyles = {
header: {
padding: '8px 16px',
alignItems: 'center',
@@ -38,17 +47,21 @@ const CustomCollapse: FC<CustomCollapseProps> = ({
borderTop: '0.5px solid var(--color-border)'
}
}
const collapseStyle = merge({}, defaultCollapseStyle, style)
const collapseItemStyles = merge({}, defaultCollapseItemStyles, styles)
return (
<Collapse
bordered={false}
style={CollapseStyle}
style={collapseStyle}
defaultActiveKey={defaultActiveKey}
activeKey={activeKey}
destroyInactivePanel={destroyInactivePanel}
collapsible={collapsible}
items={[
{
styles: CollapseItemStyles,
styles: collapseItemStyles,
key: '1',
label,
extra,

View File

@@ -0,0 +1,230 @@
import { useMemoryService } from '@renderer/services/MemoryService'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import store from '@renderer/store'
import {
clearShortMemories,
loadLongTermMemoryData,
loadMemoryData,
setAdaptiveAnalysisEnabled,
setAnalysisDepth,
setAnalysisFrequency,
setAutoAnalyze,
setAutoRecommendMemories,
setContextualRecommendationEnabled,
setCurrentMemoryList,
setDecayEnabled,
setDecayRate,
setFreshnessEnabled,
setInterestTrackingEnabled,
setMemoryActive,
setMonitoringEnabled,
setPriorityManagementEnabled,
setRecommendationThreshold,
setShortMemoryActive
} from '@renderer/store/memory'
import { FC, ReactNode, useEffect, useRef } from 'react'
interface MemoryProviderProps {
children: ReactNode
}
/**
* 记忆功能提供者组件
* 这个组件负责初始化记忆功能并在适当的时候触发记忆分析
*/
const MemoryProvider: FC<MemoryProviderProps> = ({ children }) => {
console.log('[MemoryProvider] Initializing memory provider')
const { analyzeAndAddMemories } = useMemoryService()
const dispatch = useAppDispatch()
// 从 Redux 获取记忆状态
const isActive = useAppSelector((state) => state.memory?.isActive || false)
const autoAnalyze = useAppSelector((state) => state.memory?.autoAnalyze || false)
const analyzeModel = useAppSelector((state) => state.memory?.analyzeModel || null)
const shortMemoryActive = useAppSelector((state) => state.memory?.shortMemoryActive || false)
// 获取当前对话
const currentTopic = useAppSelector((state) => state.messages?.currentTopic?.id)
const messages = useAppSelector((state) => {
if (!currentTopic || !state.messages?.messagesByTopic) {
return []
}
return state.messages.messagesByTopic[currentTopic] || []
})
// 存储上一次的话题ID
const previousTopicRef = useRef<string | null>(null)
// 添加一个 ref 来存储上次分析时的消息数量
const lastAnalyzedCountRef = useRef(0)
// 在组件挂载时加载记忆数据和设置
useEffect(() => {
console.log('[MemoryProvider] Loading memory data from file')
// 使用Redux Thunk加载短期记忆数据
dispatch(loadMemoryData())
.then((result) => {
if (result.payload) {
console.log('[MemoryProvider] Short-term memory data loaded successfully via Redux Thunk')
// 更新所有设置
const data = result.payload
// 基本设置
if (data.isActive !== undefined) dispatch(setMemoryActive(data.isActive))
if (data.shortMemoryActive !== undefined) dispatch(setShortMemoryActive(data.shortMemoryActive))
if (data.autoAnalyze !== undefined) dispatch(setAutoAnalyze(data.autoAnalyze))
// 自适应分析相关
if (data.adaptiveAnalysisEnabled !== undefined)
dispatch(setAdaptiveAnalysisEnabled(data.adaptiveAnalysisEnabled))
if (data.analysisFrequency !== undefined) dispatch(setAnalysisFrequency(data.analysisFrequency))
if (data.analysisDepth !== undefined) dispatch(setAnalysisDepth(data.analysisDepth))
// 用户关注点相关
if (data.interestTrackingEnabled !== undefined)
dispatch(setInterestTrackingEnabled(data.interestTrackingEnabled))
// 性能监控相关
if (data.monitoringEnabled !== undefined) dispatch(setMonitoringEnabled(data.monitoringEnabled))
// 智能优先级与时效性管理相关
if (data.priorityManagementEnabled !== undefined)
dispatch(setPriorityManagementEnabled(data.priorityManagementEnabled))
if (data.decayEnabled !== undefined) dispatch(setDecayEnabled(data.decayEnabled))
if (data.freshnessEnabled !== undefined) dispatch(setFreshnessEnabled(data.freshnessEnabled))
if (data.decayRate !== undefined) dispatch(setDecayRate(data.decayRate))
// 上下文感知记忆推荐相关
if (data.contextualRecommendationEnabled !== undefined)
dispatch(setContextualRecommendationEnabled(data.contextualRecommendationEnabled))
if (data.autoRecommendMemories !== undefined) dispatch(setAutoRecommendMemories(data.autoRecommendMemories))
if (data.recommendationThreshold !== undefined)
dispatch(setRecommendationThreshold(data.recommendationThreshold))
console.log('[MemoryProvider] Memory settings loaded successfully')
} else {
console.log('[MemoryProvider] No short-term memory data loaded or loading failed')
}
})
.catch((error) => {
console.error('[MemoryProvider] Error loading short-term memory data:', error)
})
// 使用Redux Thunk加载长期记忆数据
dispatch(loadLongTermMemoryData())
.then((result) => {
if (result.payload) {
console.log('[MemoryProvider] Long-term memory data loaded successfully via Redux Thunk')
// 确保在长期记忆数据加载后,检查并设置当前记忆列表
setTimeout(() => {
const state = store.getState().memory
if (!state.currentListId && state.memoryLists && state.memoryLists.length > 0) {
// 先尝试找到一个isActive为true的列表
const activeList = state.memoryLists.find((list) => list.isActive)
if (activeList) {
console.log('[MemoryProvider] Auto-selecting active memory list:', activeList.name)
dispatch(setCurrentMemoryList(activeList.id))
} else {
// 如果没有激活的列表,使用第一个列表
console.log('[MemoryProvider] Auto-selecting first memory list:', state.memoryLists[0].name)
dispatch(setCurrentMemoryList(state.memoryLists[0].id))
}
}
}, 500) // 添加一个小延迟,确保状态已更新
} else {
console.log('[MemoryProvider] No long-term memory data loaded or loading failed')
}
})
.catch((error) => {
console.error('[MemoryProvider] Error loading long-term memory data:', error)
})
}, [dispatch])
// 当对话更新时,触发记忆分析
useEffect(() => {
if (isActive && autoAnalyze && analyzeModel && messages.length > 0) {
// 获取当前的分析频率
const memoryState = store.getState().memory || {}
const analysisFrequency = memoryState.analysisFrequency || 5
const adaptiveAnalysisEnabled = memoryState.adaptiveAnalysisEnabled || false
// 检查是否有新消息需要分析
const newMessagesCount = messages.length - lastAnalyzedCountRef.current
// 使用自适应分析频率
if (
newMessagesCount >= analysisFrequency ||
(messages.length % analysisFrequency === 0 && lastAnalyzedCountRef.current === 0)
) {
console.log(
`[Memory Analysis] Triggering analysis with ${newMessagesCount} new messages (frequency: ${analysisFrequency})`
)
// 将当前话题ID传递给分析函数
analyzeAndAddMemories(currentTopic)
lastAnalyzedCountRef.current = messages.length
// 性能监控:记录当前分析触发时的消息数量
if (adaptiveAnalysisEnabled) {
console.log(`[Memory Analysis] Adaptive analysis enabled, current frequency: ${analysisFrequency}`)
}
}
}
}, [isActive, autoAnalyze, analyzeModel, messages.length, analyzeAndAddMemories, currentTopic])
// 当对话话题切换时,清除上一个话题的短记忆
useEffect(() => {
// 如果短记忆功能激活且当前话题发生变化
if (shortMemoryActive && currentTopic !== previousTopicRef.current && previousTopicRef.current) {
console.log(`[Memory] Topic changed from ${previousTopicRef.current} to ${currentTopic}, clearing short memories`)
// 清除上一个话题的短记忆
dispatch(clearShortMemories(previousTopicRef.current))
}
// 更新上一次的话题ID
previousTopicRef.current = currentTopic || null
}, [currentTopic, shortMemoryActive, dispatch])
// 监控记忆列表变化,确保总是有一个选中的记忆列表
useEffect(() => {
// 立即检查一次
const checkAndSetMemoryList = () => {
const state = store.getState().memory
if (state.memoryLists && state.memoryLists.length > 0) {
// 如果没有选中的记忆列表,或者选中的列表不存在
if (!state.currentListId || !state.memoryLists.some((list) => list.id === state.currentListId)) {
// 先尝试找到一个isActive为true的列表
const activeList = state.memoryLists.find((list) => list.isActive)
if (activeList) {
console.log('[MemoryProvider] Setting active memory list:', activeList.name)
dispatch(setCurrentMemoryList(activeList.id))
} else if (state.memoryLists.length > 0) {
// 如果没有激活的列表,使用第一个列表
console.log('[MemoryProvider] Setting first memory list:', state.memoryLists[0].name)
dispatch(setCurrentMemoryList(state.memoryLists[0].id))
}
}
}
}
// 立即检查一次
checkAndSetMemoryList()
// 设置定时器每秒检查一次持续5秒
const intervalId = setInterval(checkAndSetMemoryList, 1000)
const timeoutId = setTimeout(() => {
clearInterval(intervalId)
}, 5000)
return () => {
clearInterval(intervalId)
clearTimeout(timeoutId)
}
}, [dispatch])
return <>{children}</>
}
export default MemoryProvider

View File

@@ -456,8 +456,7 @@ const StyledMenu = styled(Menu)`
/* Simple animation that changes background color when sticky */
@keyframes background-change {
to {
background-color: var(--color-background-soft);
opacity: 0.95;
background-color: var(--color-background);
}
}

View File

@@ -0,0 +1,273 @@
import { DeleteOutlined, InfoCircleOutlined } from '@ant-design/icons'
import { Box } from '@renderer/components/Layout'
import { TopView } from '@renderer/components/TopView'
import { addShortMemoryItem, analyzeAndAddShortMemories } from '@renderer/services/MemoryService'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import store from '@renderer/store'
import { deleteShortMemory } from '@renderer/store/memory'
import { Button, Card, Col, Empty, Input, List, message, Modal, Row, Statistic, Tooltip } from 'antd'
import _ from 'lodash'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
// 不再需要确认对话框
const ButtonGroup = styled.div`
display: flex;
gap: 8px;
margin-top: 8px;
`
const MemoryContent = styled.div`
word-break: break-word;
`
interface ShowParams {
topicId: string
}
interface Props extends ShowParams {
resolve: (data: any) => void
}
const PopupContainer: React.FC<Props> = ({ topicId, resolve }) => {
const { t } = useTranslation()
const dispatch = useAppDispatch()
const [open, setOpen] = useState(true)
// 获取短记忆状态
const shortMemoryActive = useAppSelector((state) => state.memory?.shortMemoryActive || false)
const shortMemories = useAppSelector((state) => {
const allShortMemories = state.memory?.shortMemories || []
// 只显示当前话题的短记忆
return topicId ? allShortMemories.filter((memory) => memory.topicId === topicId) : []
})
// 添加短记忆的状态
const [newMemoryContent, setNewMemoryContent] = useState('')
const [isAnalyzing, setIsAnalyzing] = useState(false)
// 添加新的短记忆 - 使用防抖减少频繁更新
const handleAddMemory = useCallback(
_.debounce(() => {
if (newMemoryContent.trim() && topicId) {
addShortMemoryItem(newMemoryContent.trim(), topicId)
setNewMemoryContent('') // 清空输入框
}
}, 300),
[newMemoryContent, topicId]
)
// 手动分析对话内容 - 使用节流避免频繁分析操作
const handleAnalyzeConversation = useCallback(
_.throttle(async () => {
if (!topicId || !shortMemoryActive) return
setIsAnalyzing(true)
try {
const result = await analyzeAndAddShortMemories(topicId)
if (result) {
// 如果有新的短期记忆被添加
Modal.success({
title: t('settings.memory.shortMemoryAnalysisSuccess') || '分析成功',
content: t('settings.memory.shortMemoryAnalysisSuccessContent') || '已成功提取并添加重要信息到短期记忆'
})
} else {
// 如果没有新的短期记忆被添加
Modal.info({
title: t('settings.memory.shortMemoryAnalysisNoNew') || '无新信息',
content: t('settings.memory.shortMemoryAnalysisNoNewContent') || '未发现新的重要信息或所有信息已存在'
})
}
} catch (error) {
console.error('Failed to analyze conversation:', error)
Modal.error({
title: t('settings.memory.shortMemoryAnalysisError') || '分析失败',
content: t('settings.memory.shortMemoryAnalysisErrorContent') || '分析对话内容时出错'
})
} finally {
setIsAnalyzing(false)
}
}, 1000),
[topicId, shortMemoryActive, t]
)
// 删除短记忆 - 直接删除无需确认,使用节流避免频繁删除操作
const handleDeleteMemory = useCallback(
_.throttle(async (id: string) => {
// 先从当前状态中获取要删除的记忆之外的所有记忆
const state = store.getState().memory
const filteredShortMemories = state.shortMemories.filter((memory) => memory.id !== id)
// 执行删除操作
dispatch(deleteShortMemory(id))
// 直接使用 window.api.memory.saveData 方法保存过滤后的列表
try {
// 加载当前文件数据
const currentData = await window.api.memory.loadData()
// 替换 shortMemories 数组
const newData = {
...currentData,
shortMemories: filteredShortMemories
}
// 使用 true 参数强制覆盖文件
const result = await window.api.memory.saveData(newData, true)
if (result) {
console.log(`[ShortMemoryPopup] Successfully deleted short memory with ID ${id}`)
message.success(t('settings.memory.deleteSuccess') || '删除成功')
} else {
console.error(`[ShortMemoryPopup] Failed to delete short memory with ID ${id}`)
message.error(t('settings.memory.deleteError') || '删除失败')
}
} catch (error) {
console.error('[ShortMemoryPopup] Failed to delete short memory:', error)
message.error(t('settings.memory.deleteError') || '删除失败')
}
}, 500),
[dispatch, t]
)
const onClose = () => {
setOpen(false)
}
const afterClose = () => {
resolve({})
}
ShortMemoryPopup.hide = onClose
return (
<Modal
title={t('settings.memory.shortMemory')}
open={open}
onCancel={onClose}
afterClose={afterClose}
footer={null}
width={500}
centered>
<Box mb={16}>
<Input.TextArea
value={newMemoryContent}
onChange={(e) => setNewMemoryContent(e.target.value)}
placeholder={t('settings.memory.addShortMemoryPlaceholder')}
autoSize={{ minRows: 2, maxRows: 4 }}
disabled={!shortMemoryActive || !topicId}
/>
<ButtonGroup>
<Button
type="primary"
onClick={() => handleAddMemory()}
disabled={!shortMemoryActive || !newMemoryContent.trim() || !topicId}>
{t('settings.memory.addShortMemory')}
</Button>
<Button
onClick={() => handleAnalyzeConversation()}
loading={isAnalyzing}
disabled={!shortMemoryActive || !topicId}>
{t('settings.memory.analyzeConversation') || '分析对话'}
</Button>
</ButtonGroup>
</Box>
{/* 性能监控统计信息 */}
<Box mb={16}>
<Card
size="small"
title={t('settings.memory.performanceStats') || '系统性能统计'}
extra={<InfoCircleOutlined />}>
<Row gutter={16}>
<Col span={8}>
<Statistic
title={t('settings.memory.totalAnalyses') || '总分析次数'}
value={store.getState().memory?.analysisStats?.totalAnalyses || 0}
precision={0}
/>
</Col>
<Col span={8}>
<Statistic
title={t('settings.memory.successRate') || '成功率'}
value={
store.getState().memory?.analysisStats?.totalAnalyses
? ((store.getState().memory?.analysisStats?.successfulAnalyses || 0) /
(store.getState().memory?.analysisStats?.totalAnalyses || 1)) *
100
: 0
}
precision={1}
suffix="%"
/>
</Col>
<Col span={8}>
<Statistic
title={t('settings.memory.avgAnalysisTime') || '平均分析时间'}
value={store.getState().memory?.analysisStats?.averageAnalysisTime || 0}
precision={0}
suffix="ms"
/>
</Col>
</Row>
</Card>
</Box>
<MemoriesList>
{shortMemories.length > 0 ? (
<List
itemLayout="horizontal"
dataSource={shortMemories}
renderItem={(memory) => (
<List.Item
actions={[
<Tooltip title={t('settings.memory.delete')} key="delete">
<Button
icon={<DeleteOutlined />}
onClick={() => handleDeleteMemory(memory.id)}
type="text"
danger
/>
</Tooltip>
]}>
<List.Item.Meta
title={<MemoryContent>{memory.content}</MemoryContent>}
description={new Date(memory.createdAt).toLocaleString()}
/>
</List.Item>
)}
/>
) : (
<Empty description={!topicId ? t('settings.memory.noCurrentTopic') : t('settings.memory.noShortMemories')} />
)}
</MemoriesList>
</Modal>
)
}
const MemoriesList = styled.div`
max-height: 300px;
overflow-y: auto;
`
const TopViewKey = 'ShortMemoryPopup'
export default class ShortMemoryPopup {
static hide: () => void = () => {}
static show(props: ShowParams) {
return new Promise<any>((resolve) => {
TopView.show(
<PopupContainer
{...props}
resolve={(v) => {
resolve(v)
TopView.hide(TopViewKey)
}}
/>,
TopViewKey
)
})
}
}

View File

@@ -567,12 +567,12 @@ const QuickPanelFooterTips = styled.div<{ $footerWidth: number }>`
justify-content: flex-end;
flex-shrink: 0;
gap: 16px;
font-size: 10px;
font-size: 12px;
color: var(--color-text-3);
`
const QuickPanelFooterTitle = styled.div`
font-size: 11px;
font-size: 12px;
color: var(--color-text-3);
overflow: hidden;
text-overflow: ellipsis;
@@ -635,7 +635,8 @@ const QuickPanelItemIcon = styled.span`
const QuickPanelItemLabel = styled.span`
flex: 1;
font-size: 12px;
font-size: 13px;
line-height: 16px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;

View File

@@ -26,3 +26,10 @@ export const useMCPServers = () => {
updateMcpServers: (servers: MCPServer[]) => dispatch(setMCPServers(servers))
}
}
export const useMCPServer = (id: string) => {
const { mcpServers } = useMCPServers()
return {
server: mcpServers.find((server) => server.id === id)
}
}

View File

@@ -498,6 +498,12 @@
"copied": "Copied!",
"copy.failed": "Copy failed",
"copy.success": "Copied!",
"copy_id": "Copy Message ID",
"id_copied": "Message ID copied",
"id_found": "Original message found",
"reference": "Reference message",
"reference.error": "Failed to find original message",
"referenced_message": "Referenced Message",
"error.chunk_overlap_too_large": "Chunk overlap cannot be greater than chunk size",
"error.dimension_too_large": "Content size is too large",
"error.enter.api.host": "Please enter your API host first",
@@ -556,7 +562,8 @@
"switch.disabled": "Please wait for the current reply to complete",
"tools": {
"completed": "Completed",
"invoking": "Invoking"
"invoking": "Invoking",
"error": "Error occurred"
},
"topic.added": "New topic added",
"upgrade.success.button": "Restart",
@@ -1023,6 +1030,127 @@
"launch.onboot": "Start Automatically on Boot",
"launch.title": "Launch",
"launch.totray": "Minimize to Tray on Launch",
"memory": {
"historicalContext": {
"title": "Historical Dialog Context",
"description": "Allow AI to automatically reference historical dialogs when needed, to provide more coherent answers.",
"enable": "Enable Historical Dialog Context",
"enableTip": "When enabled, AI will automatically analyze and reference historical dialogs when needed, to provide more coherent answers",
"analyzeModelTip": "Select the model used for historical dialog context analysis, it's recommended to choose a model with faster response"
},
"title": "Memory Function",
"description": "Manage AI assistant's long-term memory, automatically analyze conversations and extract important information",
"enableMemory": "Enable Memory Function",
"enableAutoAnalyze": "Enable Auto Analysis",
"analyzeModel": "Analysis Model",
"selectModel": "Select Model",
"memoriesList": "Memory List",
"addMemory": "Add Memory",
"editMemory": "Edit Memory",
"clearAll": "Clear All",
"noMemories": "No memories yet",
"memoryPlaceholder": "Enter content to remember",
"addSuccess": "Memory added successfully",
"editSuccess": "Memory edited successfully",
"deleteSuccess": "Memory deleted successfully",
"clearSuccess": "Memories cleared successfully",
"clearConfirmTitle": "Confirm Clear",
"clearConfirmContent": "Are you sure you want to clear all memories? This action cannot be undone.",
"manualAnalyze": "Manual Analysis",
"analyzeNow": "Analyze Now",
"startingAnalysis": "Starting analysis...",
"cannotAnalyze": "Cannot analyze, please check settings",
"resetAnalyzingState": "Reset Analysis State",
"filterSensitiveInfo": "Filter Sensitive Information",
"filterSensitiveInfoTip": "When enabled, memory function will not extract API keys, passwords, or other sensitive information",
"resetLongTermMemory": "Reset Analysis Markers",
"resetLongTermMemorySuccess": "Long-term memory analysis markers reset",
"resetLongTermMemoryNoChange": "No analysis markers to reset",
"resetLongTermMemoryError": "Failed to reset long-term memory analysis markers",
"saveAllSettings": "Save All Settings",
"saveAllSettingsDescription": "Save all memory function settings to file to ensure they persist after application restart.",
"saveAllSettingsSuccess": "All settings saved successfully",
"saveAllSettingsError": "Failed to save settings",
"analyzeConversation": "Analyze Conversation",
"shortMemoryAnalysisSuccess": "Analysis Successful",
"shortMemoryAnalysisSuccessContent": "Successfully extracted and added important information to short-term memory",
"shortMemoryAnalysisNoNew": "No New Information",
"shortMemoryAnalysisNoNewContent": "No new important information found or all information already exists",
"shortMemoryAnalysisError": "Analysis Failed",
"shortMemoryAnalysisErrorContent": "Error occurred while analyzing conversation content",
"performanceStats": "Performance Statistics",
"totalAnalyses": "Total Analyses",
"successRate": "Success Rate",
"avgAnalysisTime": "Average Analysis Time",
"deduplication": {
"title": "Memory Deduplication",
"description": "Analyze similar memories in your memory library and provide intelligent merging suggestions.",
"selectList": "Select Memory List",
"allLists": "All Lists",
"selectTopic": "Select Topic",
"similarityThreshold": "Similarity Threshold",
"startAnalysis": "Start Analysis",
"help": "Help",
"helpTitle": "Memory Deduplication Help",
"helpContent1": "This feature analyzes similar memories in your memory library and provides merging suggestions.",
"helpContent2": "The similarity threshold determines how similar two memories need to be to be considered for merging. Higher values require more similarity.",
"helpContent3": "When you apply the results, similar memories will be merged into a new memory and the original memories will be deleted.",
"analyzing": "Analyzing...",
"noSimilarMemories": "No similar memories found",
"similarGroups": "Similar Memory Groups",
"group": "Group",
"items": "items",
"originalMemories": "Original Memories",
"mergedResult": "Merged Result",
"other": "Other",
"applyResults": "Apply Results",
"confirmApply": "Confirm Apply Results",
"confirmApplyContent": "Applying deduplication results will merge similar memories and delete the original ones. This action cannot be undone. Are you sure you want to continue?",
"applySuccess": "Applied Successfully",
"applySuccessContent": "Memory deduplication has been successfully applied"
},
"shortMemoryDeduplication": {
"title": "Short Memory Deduplication",
"description": "Analyze similar memories in your short-term memory and provide intelligent merging suggestions.",
"selectTopic": "Select Topic",
"similarityThreshold": "Similarity Threshold",
"startAnalysis": "Start Analysis",
"help": "Help",
"helpTitle": "Short Memory Deduplication Help",
"helpContent1": "This feature analyzes similar memories in your short-term memory and provides merging suggestions.",
"helpContent2": "The similarity threshold determines how similar two memories need to be to be considered for merging. Higher values require more similarity.",
"helpContent3": "When you apply the results, similar memories will be merged into a new memory and the original memories will be deleted.",
"analyzing": "Analyzing...",
"noSimilarMemories": "No similar memories found",
"similarGroups": "Similar Memory Groups",
"group": "Group",
"items": "items",
"originalMemories": "Original Memories",
"mergedResult": "Merged Result",
"other": "Other",
"applyResults": "Apply Results",
"confirmApply": "Confirm Apply Results",
"confirmApplyContent": "Applying deduplication results will merge similar memories and delete the original ones. This action cannot be undone. Are you sure you want to continue?",
"applySuccess": "Applied Successfully",
"applySuccessContent": "Short memory deduplication has been successfully applied"
},
"selectTopic": "Select Topic",
"selectTopicPlaceholder": "Select a topic to analyze",
"filterByCategory": "Filter by Category",
"allCategories": "All",
"uncategorized": "Uncategorized",
"shortMemory": "Short-term Memory",
"loading": "Loading...",
"longMemory": "Long-term Memory",
"toggleShortMemoryActive": "Toggle Short-term Memory",
"addShortMemory": "Add Short-term Memory",
"addShortMemoryPlaceholder": "Enter short-term memory content, only valid in current conversation",
"noShortMemories": "No short-term memories",
"noCurrentTopic": "Please select a conversation topic first",
"confirmDelete": "Confirm Delete",
"confirmDeleteContent": "Are you sure you want to delete this short-term memory?",
"delete": "Delete"
},
"mcp": {
"actions": "Actions",
"active": "Active",
@@ -1063,7 +1191,6 @@
"newServer": "MCP Server",
"npx_list": {
"actions": "Actions",
"desc": "Search and add npm packages as MCP servers",
"description": "Description",
"no_packages": "No packages found",
"npm": "NPM",
@@ -1072,7 +1199,6 @@
"scope_required": "Please enter npm scope",
"search": "Search",
"search_error": "Search error",
"title": "NPX Package List",
"usage": "Usage",
"version": "Version"
},
@@ -1089,10 +1215,25 @@
"url": "URL",
"editMcpJson": "Edit MCP Configuration",
"installHelp": "Get Installation Help",
"tabs": {
"general": "General",
"tools": "Tools",
"prompts": "Prompts",
"resources": "Resources"
},
"tools": {
"inputSchema": "Input Schema",
"availableTools": "Available Tools",
"noToolsAvailable": "No tools available"
"noToolsAvailable": "No tools available",
"loadError": "Get tools Error"
},
"prompts": {
"availablePrompts": "Available Prompts",
"noPromptsAvailable": "No prompts available",
"arguments": "Arguments",
"requiredField": "Required Field",
"genericError": "Get prompt Error",
"loadError": "Get prompts Error"
},
"deleteServer": "Delete Server",
"deleteServerConfirm": "Are you sure you want to delete this server?",
@@ -1379,4 +1520,4 @@
"visualization": "Visualization"
}
}
}
}

View File

@@ -555,7 +555,8 @@
"switch.disabled": "現在の応答が完了するまで切り替えを無効にします",
"tools": {
"completed": "完了",
"invoking": "呼び出し中"
"invoking": "呼び出し中",
"error": "エラーが発生しました"
},
"topic.added": "新しいトピックが追加されました",
"upgrade.success.button": "再起動",
@@ -1062,7 +1063,6 @@
"newServer": "MCP サーバー",
"npx_list": {
"actions": "アクション",
"desc": "npm パッケージを検索して MCP サーバーとして追加",
"description": "説明",
"no_packages": "パッケージが見つかりません",
"npm": "NPM",
@@ -1071,7 +1071,6 @@
"scope_required": "npm スコープを入力してください",
"search": "検索",
"search_error": "パッケージの検索に失敗しました",
"title": "NPX パッケージリスト",
"usage": "使用法",
"version": "バージョン"
},
@@ -1088,10 +1087,25 @@
},
"editMcpJson": "MCP 設定を編集",
"installHelp": "インストールヘルプを取得",
"tabs": {
"general": "一般",
"tools": "ツール",
"prompts": "プロンプト",
"resources": "リソース"
},
"tools": {
"inputSchema": "入力スキーマ",
"availableTools": "利用可能なツール",
"noToolsAvailable": "利用可能なツールはありません"
"noToolsAvailable": "利用可能なツールなし",
"loadError": "ツール取得エラー"
},
"prompts": {
"availablePrompts": "利用可能なプロンプト",
"noPromptsAvailable": "利用可能なプロンプトはありません",
"arguments": "引数",
"requiredField": "必須フィールド",
"genericError": "プロンプト取得エラー",
"loadError": "プロンプト取得エラー"
},
"deleteServer": "サーバーを削除",
"deleteServerConfirm": "このサーバーを削除してもよろしいですか?",
@@ -1337,6 +1351,64 @@
"privacy": {
"title": "プライバシー設定",
"enable_privacy_mode": "匿名エラーレポートとデータ統計の送信"
},
"memory": {
"title": "メモリー機能",
"description": "AIアシスタントの長期メモリーを管理し、会話を自動分析して重要な情報を抽出します",
"enableMemory": "メモリー機能を有効にする",
"enableAutoAnalyze": "自動分析を有効にする",
"analyzeModel": "分析モデル",
"selectModel": "モデルを選択",
"memoriesList": "メモリーリスト",
"memoryLists": "メモリーロール",
"addMemory": "メモリーを追加",
"editMemory": "メモリーを編集",
"clearAll": "すべてクリア",
"noMemories": "メモリーなし",
"memoryPlaceholder": "記憶したい内容を入力",
"addSuccess": "メモリーが正常に追加されました",
"editSuccess": "メモリーが正常に編集されました",
"deleteSuccess": "メモリーが正常に削除されました",
"clearSuccess": "メモリーが正常にクリアされました",
"clearConfirmTitle": "クリアの確認",
"clearConfirmContent": "すべてのメモリーをクリアしますか?この操作は元に戻せません。",
"listView": "リスト表示",
"mindmapView": "マインドマップ表示",
"centerNodeLabel": "ユーザーメモリー",
"manualAnalyze": "手動分析",
"analyzeNow": "今すぐ分析",
"startingAnalysis": "分析開始...",
"cannotAnalyze": "分析できません、設定を確認してください",
"selectTopic": "トピックを選択",
"selectTopicPlaceholder": "分析するトピックを選択",
"filterByCategory": "カテゴリーで絞り込み",
"allCategories": "すべて",
"uncategorized": "未分類",
"addList": "メモリーリストを追加",
"editList": "メモリーリストを編集",
"listName": "リスト名",
"listNamePlaceholder": "リスト名を入力",
"listDescription": "リストの説明",
"listDescriptionPlaceholder": "リストの説明を入力(オプション)",
"noLists": "メモリーリストなし",
"confirmDeleteList": "リスト削除の確認",
"confirmDeleteListContent": "{{name}} リストを削除しますか?この操作はリスト内のすべてのメモリーも削除し、元に戻せません。",
"toggleActive": "アクティブ状態を切り替え",
"clearConfirmContentList": "{{name}} のすべてのメモリーをクリアしますか?この操作は元に戻せません。",
"shortMemory": "短期メモリー",
"longMemory": "長期メモリー",
"toggleShortMemoryActive": "短期メモリー機能を切り替え",
"addShortMemory": "短期メモリーを追加",
"addShortMemoryPlaceholder": "短期メモリーの内容を入力、現在の会話のみ有効",
"noShortMemories": "短期メモリーなし",
"noCurrentTopic": "まず会話トピックを選択してください",
"confirmDelete": "削除の確認",
"confirmDeleteContent": "この短期メモリーを削除しますか?",
"delete": "削除",
"performanceStats": "パフォーマンス統計",
"totalAnalyses": "分析回数合計",
"successRate": "成功率",
"avgAnalysisTime": "平均分析時間"
}
},
"translate": {
@@ -1379,4 +1451,4 @@
"visualization": "可視化"
}
}
}
}

View File

@@ -556,7 +556,8 @@
"switch.disabled": "Пожалуйста, дождитесь завершения текущего ответа",
"tools": {
"completed": "Завершено",
"invoking": "Вызов"
"invoking": "Вызов",
"error": "Произошла ошибка"
},
"topic.added": "Новый топик добавлен",
"upgrade.success.button": "Перезапустить",
@@ -1062,7 +1063,6 @@
"newServer": "MCP сервер",
"npx_list": {
"actions": "Действия",
"desc": "Поиск и добавление npm пакетов в качестве MCP серверов",
"description": "Описание",
"no_packages": "Ничего не найдено",
"npm": "NPM",
@@ -1071,7 +1071,6 @@
"scope_required": "Пожалуйста, введите область npm",
"search": "Поиск",
"search_error": "Ошибка поиска",
"title": "Список пакетов NPX",
"usage": "Использование",
"version": "Версия"
},
@@ -1088,10 +1087,25 @@
"url": "URL",
"editMcpJson": "Редактировать MCP",
"installHelp": "Получить помощь по установке",
"tabs": {
"general": "Общие",
"tools": "Инструменты",
"prompts": "Подсказки",
"resources": "Ресурсы"
},
"tools": {
"inputSchema": "входные параметры",
"availableTools": "доступные инструменты",
"noToolsAvailable": "нет доступных инструментов"
"inputSchema": "Схема ввода",
"availableTools": "Доступные инструменты",
"noToolsAvailable": "Нет доступных инструментов",
"loadError": "Ошибка получения инструментов"
},
"prompts": {
"availablePrompts": "Доступные подсказки",
"noPromptsAvailable": "Нет доступных подсказок",
"arguments": "Аргументы",
"requiredField": "Обязательное поле",
"genericError": "Ошибка получения подсказки",
"loadError": "Ошибка получения подсказок"
},
"deleteServer": "Удалить сервер",
"deleteServerConfirm": "Вы уверены, что хотите удалить этот сервер?",
@@ -1337,6 +1351,60 @@
"privacy": {
"title": "Настройки приватности",
"enable_privacy_mode": "Анонимная отправка отчетов об ошибках и статистики"
},
"memory": {
"title": "[to be translated]:记忆功能",
"description": "[to be translated]:管理AI助手的长期记忆自动分析对话并提取重要信息",
"enableMemory": "[to be translated]:启用记忆功能",
"enableAutoAnalyze": "[to be translated]:启用自动分析",
"analyzeModel": "[to be translated]:分析模型",
"selectModel": "[to be translated]:选择模型",
"memoriesList": "[to be translated]:记忆列表",
"memoryLists": "[to be translated]:记忆角色",
"addMemory": "[to be translated]:添加记忆",
"editMemory": "[to be translated]:编辑记忆",
"clearAll": "[to be translated]:清空全部",
"noMemories": "[to be translated]:暂无记忆",
"memoryPlaceholder": "[to be translated]:输入要记住的内容",
"addSuccess": "[to be translated]:记忆添加成功",
"editSuccess": "[to be translated]:记忆编辑成功",
"deleteSuccess": "[to be translated]:记忆删除成功",
"clearSuccess": "[to be translated]:记忆清空成功",
"clearConfirmTitle": "[to be translated]:确认清空",
"clearConfirmContent": "[to be translated]:确定要清空所有记忆吗?此操作无法撤销。",
"listView": "[to be translated]:列表视图",
"mindmapView": "[to be translated]:思维导图",
"centerNodeLabel": "[to be translated]:用户记忆",
"manualAnalyze": "[to be translated]:手动分析",
"analyzeNow": "[to be translated]:立即分析",
"startingAnalysis": "[to be translated]:开始分析...",
"cannotAnalyze": "[to be translated]:无法分析,请检查设置",
"selectTopic": "[to be translated]:选择话题",
"selectTopicPlaceholder": "[to be translated]:选择要分析的话题",
"filterByCategory": "[to be translated]:按分类筛选",
"allCategories": "[to be translated]:全部",
"uncategorized": "[to be translated]:未分类",
"addList": "[to be translated]:添加记忆列表",
"editList": "[to be translated]:编辑记忆列表",
"listName": "[to be translated]:列表名称",
"listNamePlaceholder": "[to be translated]:输入列表名称",
"listDescription": "[to be translated]:列表描述",
"listDescriptionPlaceholder": "[to be translated]:输入列表描述(可选)",
"noLists": "[to be translated]:暂无记忆列表",
"confirmDeleteList": "[to be translated]:确认删除列表",
"confirmDeleteListContent": "[to be translated]:确定要删除 {{name}} 列表吗?此操作将同时删除列表中的所有记忆,且不可恢复。",
"toggleActive": "[to be translated]:切换激活状态",
"clearConfirmContentList": "[to be translated]:确定要清空 {{name}} 中的所有记忆吗?此操作不可恢复。",
"shortMemory": "[to be translated]:短期记忆",
"longMemory": "[to be translated]:长期记忆",
"toggleShortMemoryActive": "[to be translated]:切换短期记忆功能",
"addShortMemory": "[to be translated]:添加短期记忆",
"addShortMemoryPlaceholder": "[to be translated]:输入短期记忆内容,只在当前对话中有效",
"noShortMemories": "[to be translated]:暂无短期记忆",
"noCurrentTopic": "[to be translated]:请先选择一个对话话题",
"confirmDelete": "[to be translated]:确认删除",
"confirmDeleteContent": "[to be translated]:确定要删除这条短期记忆吗?",
"delete": "[to be translated]:删除"
}
},
"translate": {
@@ -1379,4 +1447,4 @@
"visualization": "Визуализация"
}
}
}
}

View File

@@ -498,6 +498,12 @@
"copied": "已复制",
"copy.failed": "复制失败",
"copy.success": "复制成功",
"copy_id": "复制消息ID",
"id_copied": "消息ID已复制",
"id_found": "已找到原始消息",
"reference": "引用消息",
"reference.error": "无法找到原始消息",
"referenced_message": "引用的消息",
"error.chunk_overlap_too_large": "分段重叠不能大于分段大小",
"error.dimension_too_large": "内容尺寸过大",
"error.enter.api.host": "请输入您的 API 地址",
@@ -556,7 +562,8 @@
"switch.disabled": "请等待当前回复完成后操作",
"tools": {
"completed": "已完成",
"invoking": "调用中"
"invoking": "调用中",
"error": "发生错误"
},
"topic.added": "话题添加成功",
"upgrade.success.button": "重启",
@@ -1023,6 +1030,188 @@
"launch.onboot": "开机自动启动",
"launch.title": "启动",
"launch.totray": "启动时最小化到托盘",
"memory": {
"historicalContext": {
"title": "历史对话上下文",
"description": "允许AI在需要时自动引用历史对话以提供更连贯的回答。",
"enable": "启用历史对话上下文",
"enableTip": "启用后AI会在需要时自动分析并引用历史对话以提供更连贯的回答",
"analyzeModelTip": "选择用于历史对话上下文分析的模型,建议选择响应较快的模型"
},
"title": "记忆功能",
"description": "管理AI助手的长期记忆自动分析对话并提取重要信息",
"enableMemory": "启用记忆功能",
"enableAutoAnalyze": "启用自动分析",
"analyzeModel": "长期记忆分析模型",
"shortMemoryAnalyzeModel": "短期记忆分析模型",
"selectModel": "选择模型",
"memoriesList": "记忆列表",
"memoryLists": "记忆角色",
"addMemory": "添加记忆",
"editMemory": "编辑记忆",
"clearAll": "清空全部",
"noMemories": "暂无记忆",
"memoryPlaceholder": "输入要记住的内容",
"addSuccess": "记忆添加成功",
"editSuccess": "记忆编辑成功",
"deleteSuccess": "记忆删除成功",
"clearSuccess": "记忆清空成功",
"clearConfirmTitle": "确认清空",
"clearConfirmContent": "确定要清空所有记忆吗?此操作无法撤销。",
"listView": "列表视图",
"mindmapView": "思维导图",
"centerNodeLabel": "用户记忆",
"manualAnalyze": "手动分析",
"analyzeNow": "立即分析",
"startingAnalysis": "开始分析...",
"cannotAnalyze": "无法分析,请检查设置",
"resetAnalyzingState": "重置分析状态",
"filterSensitiveInfo": "过滤敏感信息",
"filterSensitiveInfoTip": "启用后记忆功能将不会提取API密钥、密码等敏感信息",
"resetLongTermMemory": "重置分析标记",
"resetLongTermMemorySuccess": "长期记忆分析标记已重置",
"resetLongTermMemoryNoChange": "没有需要重置的分析标记",
"resetLongTermMemoryError": "重置长期记忆分析标记失败",
"saveAllSettings": "保存所有设置",
"saveAllSettingsDescription": "将所有记忆功能的设置保存到文件中,确保应用重启后设置仍然生效。",
"saveAllSettingsSuccess": "所有设置已成功保存",
"saveAllSettingsError": "保存设置失败",
"analyzeConversation": "分析对话",
"shortMemoryAnalysisSuccess": "分析成功",
"shortMemoryAnalysisSuccessContent": "已成功提取并添加重要信息到短期记忆",
"shortMemoryAnalysisNoNew": "无新信息",
"shortMemoryAnalysisNoNewContent": "未发现新的重要信息或所有信息已存在",
"shortMemoryAnalysisError": "分析失败",
"shortMemoryAnalysisErrorContent": "分析对话内容时出错",
"performanceStats": "性能统计",
"totalAnalyses": "总分析次数",
"successRate": "成功率",
"avgAnalysisTime": "平均分析时间",
"priorityManagement": {
"title": "智能优先级与时效性管理",
"description": "智能管理记忆的优先级、衰减和鲜度,确保最重要和最相关的记忆优先显示。",
"enable": "启用智能优先级管理",
"enableTip": "启用后,系统将根据重要性、访问频率和时间因素自动排序记忆",
"decay": "记忆衰减",
"decayRate": "衰减速率",
"decayRateTip": "值越大记忆衰减越快。0.05表示每天衰减5%",
"freshness": "记忆鲜度",
"freshnessTip": "考虑记忆的创建时间和最后访问时间,优先显示较新的记忆",
"updateNow": "立即更新",
"updateNowTip": "立即更新所有记忆的优先级排序",
"update": "更新"
},
"contextualRecommendation": {
"title": "上下文感知记忆推荐",
"description": "根据当前对话上下文,智能推荐相关的记忆内容。",
"enable": "启用上下文感知记忆推荐",
"enableTip": "启用后,系统将根据当前对话上下文自动推荐相关记忆",
"autoRecommend": "自动推荐记忆",
"autoRecommendTip": "启用后,系统将定期自动分析当前对话并推荐相关记忆",
"threshold": "推荐阈值",
"thresholdTip": "设置记忆推荐的相似度阈值,值越高要求越严格",
"clearRecommendations": "清除当前推荐",
"clearRecommendationsTip": "清除当前的记忆推荐列表",
"clear": "清除",
"decayTip": "随着时间推移,未访问的记忆重要性会逐渐降低",
"decayRate": "衰减速率",
"decayRateTip": "值越大记忆衰减越快。0.05表示每天衰减5%",
"freshness": "记忆鲜度",
"freshnessTip": "考虑记忆的创建时间和最后访问时间,优先显示较新的记忆",
"updateNow": "立即更新优先级",
"updateNowTip": "手动更新所有记忆的优先级和鲜度评分",
"update": "更新"
},
"deduplication": {
"title": "记忆去重与合并",
"description": "分析记忆库中的相似记忆,提供智能合并建议。",
"selectList": "选择记忆列表",
"allLists": "所有列表",
"selectTopic": "选择话题",
"similarityThreshold": "相似度阈值",
"startAnalysis": "开始分析",
"help": "帮助",
"helpTitle": "记忆去重与合并帮助",
"helpContent1": "该功能会分析记忆库中的相似记忆,并提供合并建议。",
"helpContent2": "相似度阈值决定了两条记忆被认为相似的程度,值越高,要求越严格。",
"helpContent3": "应用结果后,相似的记忆将被合并为一条新记忆,原记忆将被删除。",
"analyzing": "分析中...",
"noSimilarMemories": "未发现相似记忆",
"similarGroups": "相似记忆组",
"group": "组",
"items": "项",
"originalMemories": "原始记忆",
"mergedResult": "合并结果",
"other": "其他",
"applyResults": "应用结果",
"confirmApply": "确认应用去重结果",
"confirmApplyContent": "应用去重结果将合并相似记忆并删除原记忆,此操作不可撤销。确定要继续吗?",
"applySuccess": "应用成功",
"applySuccessContent": "记忆去重与合并已成功应用"
},
"shortMemoryDeduplication": {
"title": "短期记忆去重与合并",
"description": "分析短期记忆中的相似记忆,提供智能合并建议。",
"selectTopic": "选择话题",
"similarityThreshold": "相似度阈值",
"startAnalysis": "开始分析",
"help": "帮助",
"helpTitle": "短期记忆去重与合并帮助",
"helpContent1": "该功能会分析短期记忆中的相似记忆,并提供合并建议。",
"helpContent2": "相似度阈值决定了两条记忆被认为相似的程度,值越高,要求越严格。",
"helpContent3": "应用结果后,相似的记忆将被合并为一条新记忆,原记忆将被删除。",
"analyzing": "分析中...",
"noSimilarMemories": "未发现相似记忆",
"similarGroups": "相似记忆组",
"group": "组",
"items": "项",
"originalMemories": "原始记忆",
"mergedResult": "合并结果",
"other": "其他",
"applyResults": "应用结果",
"confirmApply": "确认应用去重结果",
"confirmApplyContent": "应用去重结果将合并相似记忆并删除原记忆,此操作不可撤销。确定要继续吗?",
"applySuccess": "应用成功",
"applySuccessContent": "短期记忆去重与合并已成功应用"
},
"selectTopic": "选择话题",
"selectTopicPlaceholder": "选择要分析的话题",
"filterByCategory": "按分类筛选",
"allCategories": "全部",
"uncategorized": "未分类",
"addList": "添加记忆列表",
"editList": "编辑记忆列表",
"listName": "列表名称",
"listNamePlaceholder": "输入列表名称",
"listDescription": "列表描述",
"listDescriptionPlaceholder": "输入列表描述(可选)",
"noLists": "暂无记忆列表",
"confirmDeleteList": "确认删除列表",
"confirmDeleteListContent": "确定要删除 {{name}} 列表吗?此操作将同时删除列表中的所有记忆,且不可恢复。",
"toggleActive": "切换激活状态",
"clearConfirmContentList": "确定要清空 {{name}} 中的所有记忆吗?此操作不可恢复。",
"shortMemory": "短期记忆",
"loading": "加载中...",
"longMemory": "长期记忆",
"shortMemorySettings": "短期记忆设置",
"shortMemoryDescription": "管理与当前对话相关的短期记忆",
"longMemorySettings": "长期记忆设置",
"longMemoryDescription": "管理跨对话的长期记忆",
"toggleShortMemoryActive": "切换短期记忆功能",
"addShortMemory": "添加短期记忆",
"addShortMemoryPlaceholder": "输入短期记忆内容,只在当前对话中有效",
"noShortMemories": "暂无短期记忆",
"noCurrentTopic": "请先选择一个对话话题",
"confirmDelete": "确认删除",
"confirmDeleteContent": "确定要删除这条短期记忆吗?",
"confirmDeleteAll": "确认删除全部",
"confirmDeleteAllContent": "确定要删除该话题下的所有短期记忆吗?",
"delete": "删除",
"cancel": "取消",
"allTopics": "所有话题",
"noTopics": "没有话题",
"shortMemoriesByTopic": "按话题分组的短期记忆"
},
"mcp": {
"actions": "操作",
"active": "启用",
@@ -1063,7 +1252,6 @@
"newServer": "MCP 服务器",
"npx_list": {
"actions": "操作",
"desc": "搜索并添加 npm 包作为 MCP 服务",
"description": "描述",
"no_packages": "未找到包",
"npm": "NPM",
@@ -1072,7 +1260,6 @@
"scope_required": "请输入 npm 作用域",
"search": "搜索",
"search_error": "搜索失败",
"title": "NPX 包列表",
"usage": "用法",
"version": "版本"
},
@@ -1089,10 +1276,25 @@
"url": "URL",
"editMcpJson": "编辑 MCP 配置",
"installHelp": "获取安装帮助",
"tabs": {
"general": "通用",
"tools": "工具",
"prompts": "提示",
"resources": "资源"
},
"tools": {
"inputSchema": "输入参数",
"inputSchema": "输入模式",
"availableTools": "可用工具",
"noToolsAvailable": "没有可用工具"
"noToolsAvailable": "可用工具",
"loadError": "获取工具失败"
},
"prompts": {
"availablePrompts": "可用提示",
"noPromptsAvailable": "无可用提示",
"arguments": "参数",
"requiredField": "必填字段",
"genericError": "获取提示错误",
"loadError": "获取提示失败"
},
"deleteServer": "删除服务器",
"deleteServerConfirm": "确定要删除此服务器吗?",

View File

@@ -556,7 +556,8 @@
"switch.disabled": "請等待當前回覆完成",
"tools": {
"completed": "已完成",
"invoking": "調用中"
"invoking": "調用中",
"error": "發生錯誤"
},
"topic.added": "新話題已新增",
"upgrade.success.button": "重新啟動",
@@ -1062,7 +1063,6 @@
"newServer": "MCP 伺服器",
"npx_list": {
"actions": "操作",
"desc": "搜索並添加 npm 包作為 MCP 服務",
"description": "描述",
"no_packages": "未找到包",
"npm": "NPM",
@@ -1071,7 +1071,6 @@
"scope_required": "請輸入 npm 作用域",
"search": "搜索",
"search_error": "搜索失敗",
"title": "NPX 包列表",
"usage": "用法",
"version": "版本"
},
@@ -1088,10 +1087,25 @@
"url": "URL",
"editMcpJson": "編輯 MCP 配置",
"installHelp": "獲取安裝幫助",
"tabs": {
"general": "通用",
"tools": "工具",
"prompts": "提示",
"resources": "資源"
},
"tools": {
"inputSchema": "輸入參數",
"inputSchema": "輸入模式",
"availableTools": "可用工具",
"noToolsAvailable": "沒有可用工具"
"noToolsAvailable": "可用工具",
"loadError": "獲取工具失敗"
},
"prompts": {
"availablePrompts": "可用提示",
"noPromptsAvailable": "無可用提示",
"arguments": "參數",
"requiredField": "必填欄位",
"genericError": "獲取提示錯誤",
"loadError": "獲取提示失敗"
},
"deleteServer": "刪除伺服器",
"deleteServerConfirm": "確定要刪除此伺服器嗎?",
@@ -1337,6 +1351,64 @@
"privacy": {
"title": "隱私設定",
"enable_privacy_mode": "匿名發送錯誤報告和資料統計"
},
"memory": {
"title": "記憶功能",
"description": "管理AI助手的長期記憶自動分析對話並提取重要信息",
"enableMemory": "啟用記憶功能",
"enableAutoAnalyze": "啟用自動分析",
"analyzeModel": "分析模型",
"selectModel": "選擇模型",
"memoriesList": "記憶列表",
"memoryLists": "記憶角色",
"addMemory": "添加記憶",
"editMemory": "編輯記憶",
"clearAll": "清空全部",
"noMemories": "暫無記憶",
"memoryPlaceholder": "輸入要記住的內容",
"addSuccess": "記憶添加成功",
"editSuccess": "記憶編輯成功",
"deleteSuccess": "記憶刪除成功",
"clearSuccess": "記憶清空成功",
"clearConfirmTitle": "確認清空",
"clearConfirmContent": "確定要清空所有記憶嗎?此操作無法撤銷。",
"listView": "列表視圖",
"mindmapView": "思維導圖",
"centerNodeLabel": "用戶記憶",
"manualAnalyze": "手動分析",
"analyzeNow": "立即分析",
"startingAnalysis": "開始分析...",
"cannotAnalyze": "無法分析,請檢查設置",
"selectTopic": "選擇話題",
"selectTopicPlaceholder": "選擇要分析的話題",
"filterByCategory": "按分類篩選",
"allCategories": "全部",
"uncategorized": "未分類",
"addList": "添加記憶列表",
"editList": "編輯記憶列表",
"listName": "列表名稱",
"listNamePlaceholder": "輸入列表名稱",
"listDescription": "列表描述",
"listDescriptionPlaceholder": "輸入列表描述(可選)",
"noLists": "暫無記憶列表",
"confirmDeleteList": "確認刪除列表",
"confirmDeleteListContent": "確定要刪除 {{name}} 列表嗎?此操作將同時刪除列表中的所有記憶,且不可恢復。",
"toggleActive": "切換激活狀態",
"clearConfirmContentList": "確定要清空 {{name}} 中的所有記憶嗎?此操作不可恢復。",
"shortMemory": "短期記憶",
"longMemory": "長期記憶",
"toggleShortMemoryActive": "切換短期記憶功能",
"addShortMemory": "添加短期記憶",
"addShortMemoryPlaceholder": "輸入短期記憶內容,只在當前對話中有效",
"noShortMemories": "暫無短期記憶",
"noCurrentTopic": "請先選擇一個對話話題",
"confirmDelete": "確認刪除",
"confirmDeleteContent": "確定要刪除這條短期記憶嗎?",
"delete": "刪除",
"performanceStats": "性能統計",
"totalAnalyses": "總分析次數",
"successRate": "成功率",
"avgAnalysisTime": "平均分析時間"
}
},
"translate": {
@@ -1379,4 +1451,4 @@
"visualization": "視覺化"
}
}
}
}

View File

@@ -912,7 +912,6 @@
"noServers": "Δεν έχουν ρυθμιστεί διακομιστές",
"npx_list": {
"actions": "Ενέργειες",
"desc": "Αναζητήστε και προσθέστε πακέτα npm ως υπηρεσίες MCP",
"description": "Περιγραφή",
"no_packages": "Δεν βρέθηκαν πακέτα",
"npm": "NPM",
@@ -921,7 +920,6 @@
"scope_required": "Παρακαλώ εισαγάγετε το σκοπό του npm",
"search": "Αναζήτηση",
"search_error": "Η αναζήτηση απέτυχε",
"title": "Λίστα πακέτων NPX",
"usage": "Χρήση",
"version": "Έκδοση"
},

View File

@@ -912,7 +912,6 @@
"noServers": "No se han configurado servidores",
"npx_list": {
"actions": "Acciones",
"desc": "Buscar y agregar paquetes npm como servicios MCP",
"description": "Descripción",
"no_packages": "No se encontraron paquetes",
"npm": "NPM",
@@ -921,7 +920,6 @@
"scope_required": "Por favor ingrese el ámbito npm",
"search": "Buscar",
"search_error": "Error de búsqueda",
"title": "Lista de paquetes NPX",
"usage": "Uso",
"version": "Versión"
},

View File

@@ -912,7 +912,6 @@
"noServers": "Aucun serveur configuré",
"npx_list": {
"actions": "Actions",
"desc": "Rechercher et ajouter un package npm en tant que service MCP",
"description": "Description",
"no_packages": "Aucun package trouvé",
"npm": "NPM",
@@ -921,7 +920,6 @@
"scope_required": "Veuillez entrer le scope npm",
"search": "Rechercher",
"search_error": "La recherche a échoué",
"title": "Liste des packages NPX",
"usage": "Utilisation",
"version": "Version"
},

View File

@@ -912,7 +912,6 @@
"noServers": "Nenhum servidor configurado",
"npx_list": {
"actions": "Ações",
"desc": "Pesquise e adicione pacotes npm como serviço MCP",
"description": "Descrição",
"no_packages": "Nenhum pacote encontrado",
"npm": "NPM",
@@ -921,7 +920,6 @@
"scope_required": "Insira o escopo npm",
"search": "Pesquisar",
"search_error": "Falha na pesquisa",
"title": "Lista de Pacotes NPX",
"usage": "Uso",
"version": "Versão"
},

View File

@@ -9,13 +9,12 @@ import {
HolderOutlined,
PaperClipOutlined,
PauseCircleOutlined,
QuestionCircleOutlined,
ThunderboltOutlined,
TranslationOutlined
} from '@ant-design/icons'
import { QuickPanelListItem, QuickPanelView, useQuickPanel } from '@renderer/components/QuickPanel'
import TranslateButton from '@renderer/components/TranslateButton'
import { isFunctionCallingModel, isGenerateImageModel, isVisionModel, isWebSearchModel } from '@renderer/config/models'
import { isGenerateImageModel, isVisionModel, isWebSearchModel } from '@renderer/config/models'
import db from '@renderer/databases'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { useKnowledgeBases } from '@renderer/hooks/useKnowledge'
@@ -28,7 +27,7 @@ import { useSidebarIconShow } from '@renderer/hooks/useSidebarIcon'
import { addAssistantMessagesToTopic, getDefaultTopic } from '@renderer/services/AssistantService'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import FileManager from '@renderer/services/FileManager'
import { checkRateLimit, getUserMessage } from '@renderer/services/MessagesService'
import { checkRateLimit, findMessageById, getUserMessage } from '@renderer/services/MessagesService'
import { getModelUniqId } from '@renderer/services/ModelService'
import { estimateMessageUsage, estimateTextTokens as estimateTxtTokens } from '@renderer/services/TokenService'
import { translateText } from '@renderer/services/TranslateService'
@@ -40,7 +39,7 @@ import { Assistant, FileType, KnowledgeBase, KnowledgeItem, MCPServer, Message,
import { classNames, delay, formatFileSize, getFileExtension } from '@renderer/utils'
import { getFilesFromDropEvent } from '@renderer/utils/input'
import { documentExts, imageExts, textExts } from '@shared/config/constant'
import { Button, Popconfirm, Tooltip } from 'antd'
import { Button, Tooltip } from 'antd'
import TextArea, { TextAreaRef } from 'antd/es/input/TextArea'
import dayjs from 'dayjs'
import Logger from 'electron-log/renderer'
@@ -119,7 +118,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
const quickPanel = useQuickPanel()
const showKnowledgeIcon = useSidebarIconShow('knowledge')
const showMCPToolsIcon = isFunctionCallingModel(model)
// const showMCPToolsIcon = isFunctionCallingModel(model)
const [tokenCount, setTokenCount] = useState(0)
@@ -181,9 +180,135 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
EventEmitter.emit(EVENT_NAMES.SEND_MESSAGE)
try {
// 检查用户输入是否包含消息ID
const uuidRegex = /[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/i
// 从文本中提取所有消息ID
const matches = text.match(new RegExp(uuidRegex, 'g'))
// 如果只有ID且没有其他内容则直接查找原始消息
if (matches && matches.length > 0 && text.trim() === matches.join(' ')) {
try {
// 创建引用消息
const userMessage = getUserMessage({ assistant, topic, type: 'text', content: '' })
userMessage.referencedMessages = []
// 处理所有匹配到的ID
let foundAnyMessage = false
for (const messageId of matches) {
console.log(`[引用消息] 尝试查找消息ID: ${messageId}`)
const originalMessage = await findMessageById(messageId)
if (originalMessage) {
userMessage.referencedMessages.push({
id: originalMessage.id,
content: originalMessage.content,
role: originalMessage.role,
createdAt: originalMessage.createdAt
})
foundAnyMessage = true
console.log(`[引用消息] 找到消息ID: ${messageId}`)
} else {
console.log(`[引用消息] 未找到消息ID: ${messageId}`)
}
}
if (foundAnyMessage) {
// 发送引用消息
userMessage.usage = await estimateMessageUsage(userMessage)
currentMessageId.current = userMessage.id
dispatch(
_sendMessage(userMessage, assistant, topic, {
mentions: mentionModels
})
)
// 清空输入框
setText('')
setFiles([])
setTimeout(() => setText(''), 500)
setTimeout(() => resizeTextArea(), 0)
setExpend(false)
window.message.success({
content:
t('message.ids_found', { count: userMessage.referencedMessages.length }) ||
`已找到${userMessage.referencedMessages.length}条原始消息`,
key: 'message-id-found'
})
return
} else {
window.message.error({
content: t('message.id_not_found') || '未找到原始消息',
key: 'message-id-not-found'
})
}
} catch (error) {
console.error(`[引用消息] 查找消息ID时出错:`, error)
window.message.error({ content: t('message.id_error') || '查找原始消息时出错', key: 'message-id-error' })
}
}
// 如果不是单独的ID或者没有找到原始消息则正常发送消息
// 先检查消息内容是否包含消息ID如果是则将其替换为空字符串
let messageContent = text
// 如果消息内容包含消息ID则将其替换为空字符串
if (matches && matches.length > 0) {
// 检查是否是纯消息ID
const isOnlyUUID = text.trim() === matches[0]
if (isOnlyUUID) {
messageContent = ''
} else {
// 如果消息内容包含消息ID则将消息ID替换为空字符串
for (const match of matches) {
messageContent = messageContent.replace(match, '')
}
// 去除多余的空格
messageContent = messageContent.replace(/\s+/g, ' ').trim()
}
}
// Dispatch the sendMessage action with all options
const uploadedFiles = await FileManager.uploadFiles(files)
const userMessage = getUserMessage({ assistant, topic, type: 'text', content: text })
const userMessage = getUserMessage({ assistant, topic, type: 'text', content: messageContent })
// 如果消息内容包含消息ID则添加引用
if (matches && matches.length > 0) {
try {
// 初始化引用消息数组
userMessage.referencedMessages = []
// 处理所有匹配到的ID
for (const messageId of matches) {
console.log(`[引用消息] 尝试查找消息ID作为引用: ${messageId}`)
const originalMessage = await findMessageById(messageId)
if (originalMessage) {
userMessage.referencedMessages.push({
id: originalMessage.id,
content: originalMessage.content,
role: originalMessage.role,
createdAt: originalMessage.createdAt
})
console.log(`[引用消息] 找到消息ID作为引用: ${messageId}`)
} else {
console.log(`[引用消息] 未找到消息ID作为引用: ${messageId}`)
}
}
// 如果找到了引用消息,显示成功提示
if (userMessage.referencedMessages.length > 0) {
window.message.success({
content:
t('message.ids_found', { count: userMessage.referencedMessages.length }) ||
`已找到${userMessage.referencedMessages.length}条原始消息`,
key: 'message-id-found'
})
}
} catch (error) {
console.error(`[引用消息] 查找消息ID作为引用时出错:`, error)
}
}
if (uploadedFiles) {
userMessage.files = uploadedFiles
@@ -199,10 +324,8 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
userMessage.mentions = mentionModels
}
if (isFunctionCallingModel(model)) {
if (!isEmpty(enabledMCPs) && !isEmpty(activedMcpServers)) {
userMessage.enabledMCPs = activedMcpServers.filter((server) => enabledMCPs?.some((s) => s.id === server.id))
}
if (!isEmpty(enabledMCPs) && !isEmpty(activedMcpServers)) {
userMessage.enabledMCPs = activedMcpServers.filter((server) => enabledMCPs?.some((s) => s.id === server.id))
}
userMessage.usage = await estimateMessageUsage(userMessage)
@@ -231,7 +354,6 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
inputEmpty,
loading,
mentionModels,
model,
resizeTextArea,
selectedKnowledgeBases,
text,
@@ -347,21 +469,29 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
description: '',
icon: <FileSearchOutlined />,
isMenu: true,
disabled: !showKnowledgeIcon || files.length > 0,
disabled: files.length > 0,
action: () => {
knowledgeBaseButtonRef.current?.openQuickPanel()
}
},
{
label: t('settings.mcp.title'),
description: showMCPToolsIcon ? '' : t('settings.mcp.not_support'),
description: t('settings.mcp.not_support'),
icon: <CodeOutlined />,
isMenu: true,
disabled: !showMCPToolsIcon,
action: () => {
mcpToolsButtonRef.current?.openQuickPanel()
}
},
{
label: 'MCP Prompt',
description: '',
icon: <CodeOutlined />,
isMenu: true,
action: () => {
mcpToolsButtonRef.current?.openPromptList()
}
},
{
label: isVisionModel(model) ? t('chat.input.upload') : t('chat.input.upload.document'),
description: '',
@@ -379,11 +509,25 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
}
}
]
}, [files.length, model, openSelectFileMenu, showKnowledgeIcon, showMCPToolsIcon, t, text, translate])
}, [files.length, model, openSelectFileMenu, t, text, translate])
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
const isEnterPressed = event.keyCode == 13
// 检查是否是消息ID格式
if (isEnterPressed && !event.shiftKey) {
const uuidRegex = /[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/i
const currentText = text.trim()
const isUUID = uuidRegex.test(currentText) && currentText.length === 36
if (isUUID) {
// 如果是消息ID格式则不显示ID在对话中
event.preventDefault()
sendMessage()
return
}
}
// 按下Tab键自动选中${xxx}
if (event.key === 'Tab' && inputFocus) {
event.preventDefault()
@@ -532,7 +676,19 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
const onChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newText = e.target.value
setText(newText)
// 检查是否包含UUID格式的消息ID
const uuidRegex = /[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/i
const matches = newText.match(new RegExp(uuidRegex, 'g'))
// 如果输入的内容只是一个UUID不更新文本框内容直接处理引用
if (matches && matches.length === 1 && newText.trim() === matches[0]) {
// 不立即更新文本框,等待用户按下回车键时再处理
setText(newText)
} else {
// 正常更新文本框内容
setText(newText)
}
const textArea = textareaRef.current?.resizableTextArea?.textArea
const cursorPosition = textArea?.selectionStart ?? 0
@@ -555,8 +711,41 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
async (event: ClipboardEvent) => {
const clipboardText = event.clipboardData?.getData('text')
if (clipboardText) {
// Prioritize the text when pasting.
// handled by the default event
// 检查粘贴的内容是否是消息ID
const uuidRegex = /[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/i
const isUUID = uuidRegex.test(clipboardText.trim()) && clipboardText.trim().length === 36
if (isUUID) {
// 如果是消息ID则阻止默认粘贴行为自定义处理
event.preventDefault()
// 获取当前文本框的内容和光标位置
const textArea = textareaRef.current?.resizableTextArea?.textArea
if (textArea) {
const currentText = textArea.value
const cursorPosition = textArea.selectionStart
const cursorEnd = textArea.selectionEnd
// 如果有选中文本,则替换选中文本;否则在光标位置插入
const newText =
currentText.substring(0, cursorPosition) + clipboardText.trim() + currentText.substring(cursorEnd)
setText(newText)
// 将光标移到插入的ID后面
const newCursorPosition = cursorPosition + clipboardText.trim().length
setTimeout(() => {
if (textArea) {
textArea.focus()
textArea.setSelectionRange(newCursorPosition, newCursorPosition)
}
}, 0)
} else {
// 如果无法获取textArea则直接设置文本
setText(clipboardText.trim())
}
}
// 其他文本内容由默认事件处理
} else {
for (const file of event.clipboardData?.files || []) {
event.preventDefault()
@@ -690,9 +879,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
textareaRef.current?.focus()
})
useShortcut('clear_topic', () => {
clearTopic()
})
useShortcut('clear_topic', clearTopic)
useEffect(() => {
const _setEstimateTokenCount = debounce(setEstimateTokenCount, 100, { leading: false, trailing: true })
@@ -959,14 +1146,14 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
disabled={files.length > 0}
/>
)}
{showMCPToolsIcon && (
<MCPToolsButton
ref={mcpToolsButtonRef}
enabledMCPs={enabledMCPs}
toggelEnableMCP={toggelEnableMCP}
ToolbarButton={ToolbarButton}
/>
)}
<MCPToolsButton
ref={mcpToolsButtonRef}
enabledMCPs={enabledMCPs}
toggelEnableMCP={toggelEnableMCP}
ToolbarButton={ToolbarButton}
setInputValue={setText}
resizeTextArea={resizeTextArea}
/>
<GenerateImageButton
model={model}
assistant={assistant}
@@ -986,17 +1173,9 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
ToolbarButton={ToolbarButton}
/>
<Tooltip placement="top" title={t('chat.input.clear', { Command: cleanTopicShortcut })} arrow>
<Popconfirm
title={t('chat.input.clear.content')}
placement="top"
onConfirm={clearTopic}
okButtonProps={{ danger: true }}
icon={<QuestionCircleOutlined style={{ color: 'red' }} />}
okText={t('chat.input.clear.title')}>
<ToolbarButton type="text">
<ClearOutlined style={{ fontSize: 17 }} />
</ToolbarButton>
</Popconfirm>
<ToolbarButton type="text" onClick={clearTopic}>
<ClearOutlined style={{ fontSize: 17 }} />
</ToolbarButton>
</Tooltip>
<Tooltip placement="top" title={isExpended ? t('chat.input.collapse') : t('chat.input.expand')} arrow>
<ToolbarButton type="text" onClick={onToggleExpended}>

View File

@@ -1,28 +1,40 @@
import { CodeOutlined, PlusOutlined } from '@ant-design/icons'
import { QuickPanelListItem, useQuickPanel } from '@renderer/components/QuickPanel'
import { useMCPServers } from '@renderer/hooks/useMCPServers'
import { MCPServer } from '@renderer/types'
import { Tooltip } from 'antd'
import { MCPPrompt, MCPServer } from '@renderer/types'
import { Form, Input, Modal, Tooltip } from 'antd'
import { FC, useCallback, useImperativeHandle, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router'
export interface MCPToolsButtonRef {
openQuickPanel: () => void
openPromptList: () => void
}
interface Props {
ref?: React.RefObject<MCPToolsButtonRef | null>
enabledMCPs: MCPServer[]
setInputValue: React.Dispatch<React.SetStateAction<string>>
resizeTextArea: () => void
toggelEnableMCP: (server: MCPServer) => void
ToolbarButton: any
}
const MCPToolsButton: FC<Props> = ({ ref, enabledMCPs, toggelEnableMCP, ToolbarButton }) => {
const MCPToolsButton: FC<Props> = ({
ref,
setInputValue,
resizeTextArea,
enabledMCPs,
toggelEnableMCP,
ToolbarButton
}) => {
const { activedMcpServers } = useMCPServers()
const { t } = useTranslation()
const quickPanel = useQuickPanel()
const navigate = useNavigate()
// Create form instance at the top level
const [form] = Form.useForm()
const availableMCPs = activedMcpServers.filter((server) => enabledMCPs.some((s) => s.id === server.id))
@@ -56,6 +68,220 @@ const MCPToolsButton: FC<Props> = ({ ref, enabledMCPs, toggelEnableMCP, ToolbarB
}
})
}, [menuItems, quickPanel, t])
// Extract and format all content from the prompt response
const extractPromptContent = useCallback((response: any): string | null => {
// Handle string response (backward compatibility)
if (typeof response === 'string') {
return response
}
// Handle GetMCPPromptResponse format
if (response && Array.isArray(response.messages)) {
let formattedContent = ''
for (const message of response.messages) {
if (!message.content) continue
// Add role prefix if available
const rolePrefix = message.role ? `**${message.role.charAt(0).toUpperCase() + message.role.slice(1)}:** ` : ''
// Process different content types
switch (message.content.type) {
case 'text':
// Add formatted text content with role
formattedContent += `${rolePrefix}${message.content.text}\n\n`
break
case 'image':
// Format image as markdown with proper attribution
if (message.content.data && message.content.mimeType) {
const imageData = message.content.data
const mimeType = message.content.mimeType
// Include role if available
if (rolePrefix) {
formattedContent += `${rolePrefix}\n`
}
formattedContent += `![Image](data:${mimeType};base64,${imageData})\n\n`
}
break
case 'audio':
// Add indicator for audio content with role
formattedContent += `${rolePrefix}[Audio content available]\n\n`
break
case 'resource':
// Add indicator for resource content with role
if (message.content.text) {
formattedContent += `${rolePrefix}${message.content.text}\n\n`
} else {
formattedContent += `${rolePrefix}[Resource content available]\n\n`
}
break
default:
// Add text content if available with role, otherwise show placeholder
if (message.content.text) {
formattedContent += `${rolePrefix}${message.content.text}\n\n`
}
}
}
return formattedContent.trim()
}
// Fallback handling for single message format
if (response && response.messages && response.messages.length > 0) {
const message = response.messages[0]
if (message.content && message.content.text) {
const rolePrefix = message.role ? `**${message.role.charAt(0).toUpperCase() + message.role.slice(1)}:** ` : ''
return `${rolePrefix}${message.content.text}`
}
}
return null
}, [])
// Helper function to insert prompt into text area
const insertPromptIntoTextArea = useCallback(
(promptText: string) => {
setInputValue((prev) => {
const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement
if (!textArea) return prev + promptText // Fallback if we can't find the textarea
const cursorPosition = textArea.selectionStart
const selectionStart = cursorPosition
const selectionEndPosition = cursorPosition + promptText.length
const newText = prev.slice(0, cursorPosition) + promptText + prev.slice(cursorPosition)
setTimeout(() => {
textArea.focus()
textArea.setSelectionRange(selectionStart, selectionEndPosition)
resizeTextArea()
}, 10)
return newText
})
},
[setInputValue, resizeTextArea]
)
const handlePromptSelect = useCallback(
(prompt: MCPPrompt) => {
setTimeout(async () => {
const server = enabledMCPs.find((s) => s.id === prompt.serverId)
if (server) {
try {
// Check if the prompt has arguments
if (prompt.arguments && prompt.arguments.length > 0) {
// Reset form when opening a new modal
form.resetFields()
Modal.confirm({
title: `${t('settings.mcp.prompts.arguments')}: ${prompt.name}`,
content: (
<Form form={form} layout="vertical">
{prompt.arguments.map((arg, index) => (
<Form.Item
key={index}
name={arg.name}
label={`${arg.name}${arg.required ? ' *' : ''}`}
tooltip={arg.description}
rules={
arg.required ? [{ required: true, message: t('settings.mcp.prompts.requiredField') }] : []
}>
<Input placeholder={arg.description || arg.name} />
</Form.Item>
))}
</Form>
),
onOk: async () => {
try {
// Validate and get form values
const values = await form.validateFields()
const response = await window.api.mcp.getPrompt({
server,
name: prompt.name,
args: values
})
// Extract and format prompt content from the response
const promptContent = extractPromptContent(response)
if (promptContent) {
insertPromptIntoTextArea(promptContent)
} else {
throw new Error('Invalid prompt response format')
}
return Promise.resolve()
} catch (error: Error | any) {
if (error.errorFields) {
// This is a form validation error, handled by Ant Design
return Promise.reject(error)
}
Modal.error({
title: t('common.error'),
content: error.message || t('settings.mcp.prompts.genericError')
})
return Promise.reject(error)
}
},
okText: t('common.confirm'),
cancelText: t('common.cancel')
})
} else {
// If no arguments, get the prompt directly
const response = await window.api.mcp.getPrompt({
server,
name: prompt.name
})
// Extract and format prompt content from the response
const promptContent = extractPromptContent(response)
if (promptContent) {
insertPromptIntoTextArea(promptContent)
} else {
throw new Error('Invalid prompt response format')
}
}
} catch (error: Error | any) {
Modal.error({
title: t('common.error'),
content: error.message || t('settings.mcp.prompt.genericError')
})
}
}
}, 10)
},
[enabledMCPs, form, t, extractPromptContent, insertPromptIntoTextArea] // Add form to dependencies
)
const promptList = useMemo(async () => {
const prompts: MCPPrompt[] = []
for (const server of enabledMCPs) {
const serverPrompts = await window.api.mcp.listPrompts(server)
prompts.push(...serverPrompts)
}
return prompts.map((prompt) => ({
label: prompt.name,
description: prompt.description,
icon: <CodeOutlined />,
action: () => handlePromptSelect(prompt)
}))
}, [handlePromptSelect, enabledMCPs])
const openPromptList = useCallback(async () => {
const prompts = await promptList
quickPanel.open({
title: t('settings.mcp.title'),
list: prompts,
symbol: 'mcp-prompt',
multiple: true
})
}, [promptList, quickPanel, t])
const handleOpenQuickPanel = useCallback(() => {
if (quickPanel.isVisible && quickPanel.symbol === 'mcp') {
@@ -66,7 +292,8 @@ const MCPToolsButton: FC<Props> = ({ ref, enabledMCPs, toggelEnableMCP, ToolbarB
}, [openQuickPanel, quickPanel])
useImperativeHandle(ref, () => ({
openQuickPanel
openQuickPanel,
openPromptList
}))
if (activedMcpServers.length === 0) {

View File

@@ -3,6 +3,7 @@ import { useShortcut, useShortcutDisplay } from '@renderer/hooks/useShortcuts'
import { Tooltip } from 'antd'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface Props {
onNewContext: () => void
@@ -16,12 +17,20 @@ const NewContextButton: FC<Props> = ({ onNewContext, ToolbarButton }) => {
useShortcut('toggle_new_context', onNewContext)
return (
<Tooltip placement="top" title={t('chat.input.new.context', { Command: newContextShortcut })} arrow>
<ToolbarButton type="text" onClick={onNewContext}>
<PicCenterOutlined />
</ToolbarButton>
</Tooltip>
<Container>
<Tooltip placement="top" title={t('chat.input.new.context', { Command: newContextShortcut })} arrow>
<ToolbarButton type="text" onClick={onNewContext}>
<PicCenterOutlined />
</ToolbarButton>
</Tooltip>
</Container>
)
}
const Container = styled.div`
@media (max-width: 800px) {
display: none;
}
`
export default NewContextButton

View File

@@ -25,7 +25,7 @@ import ImagePreview from './ImagePreview'
import Link from './Link'
const ALLOWED_ELEMENTS =
/<(style|p|div|span|b|i|strong|em|ul|ol|li|table|tr|td|th|thead|tbody|h[1-6]|blockquote|pre|code|br|hr|svg|path|circle|rect|line|polyline|polygon|text|g|defs|title|desc|tspan|sub|sup)/i
/<(style|p|div|span|b|i|strong|em|ul|ol|li|table|tr|td|th|thead|tbody|h[1-6]|blockquote|pre|code|br|hr|svg|path|circle|rect|line|polyline|polygon|text|g|defs|title|desc|tspan|sub|sup|think)/i
interface Props {
message: Message
@@ -56,7 +56,27 @@ const Markdown: FC<Props> = ({ message }) => {
a: (props: any) => <Link {...props} citationData={parseJSON(findCitationInChildren(props.children))} />,
code: CodeBlock,
img: ImagePreview,
pre: (props: any) => <pre style={{ overflow: 'visible' }} {...props} />
pre: (props: any) => <pre style={{ overflow: 'visible' }} {...props} />,
// 自定义处理think标签
think: (props: any) => {
// 将think标签内容渲染为带样式的div
return (
<div
className="thinking-content"
style={{
backgroundColor: 'rgba(0, 0, 0, 0.05)',
padding: '10px 15px',
borderRadius: '8px',
marginBottom: '15px',
borderLeft: '3px solid var(--color-primary)',
fontStyle: 'italic',
color: 'var(--color-text-2)'
}}>
<div style={{ fontWeight: 'bold', marginBottom: '5px' }}>:</div>
{props.children}
</div>
)
}
} as Partial<Components>
return baseComponents
}, [])

View File

@@ -255,15 +255,19 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => {
if (now - lastMoveTime.current < 50) return
lastMoveTime.current = now
const triggerWidth = 10
let rightOffset = 5
// Calculate if the mouse is in the trigger area
const triggerWidth = 80 // Same as the width in styled component
// Safe way to calculate position when using calc expressions
let rightOffset = 16 // Default right offset
if (showRightTopics) {
rightOffset = 5 + 300
// When topics are shown on right, we need to account for topic list width
rightOffset = 16 + 300 // Assuming topic list width is 300px, adjust if different
}
const rightPosition = window.innerWidth - rightOffset - triggerWidth
const topPosition = window.innerHeight * 0.35
const height = window.innerHeight * 0.3
const topPosition = window.innerHeight * 0.3 // 30% from top
const height = window.innerHeight * 0.4 // 40% of window height
const isInTriggerArea =
e.clientX > rightPosition &&
@@ -403,32 +407,31 @@ const ButtonGroup = styled.div`
display: flex;
flex-direction: column;
background: var(--bg-color);
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
overflow: hidden;
backdrop-filter: blur(12px);
backdrop-filter: blur(8px);
border: 1px solid var(--color-border);
`
const NavigationButton = styled(Button)`
width: 32px;
height: 32px;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0;
border: none;
color: var(--color-text);
transition: all 0.25s ease-in-out;
transition: all 0.2s ease-in-out;
&:hover {
background-color: var(--color-hover);
color: var(--color-primary);
transform: scale(1.05);
}
.anticon {
font-size: 16px;
font-size: 14px;
}
`

View File

@@ -8,6 +8,7 @@ import { getModelUniqId } from '@renderer/services/ModelService'
import { Assistant, Message, Topic } from '@renderer/types'
import { classNames } from '@renderer/utils'
import { Divider, Dropdown } from 'antd'
import { ItemType } from 'antd/es/menu/interface'
import { Dispatch, FC, memo, SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@@ -65,7 +66,11 @@ const MessageItem: FC<Props> = ({
const handleContextMenu = useCallback((e: React.MouseEvent) => {
e.preventDefault()
const _selectedText = window.getSelection()?.toString()
const _selectedText = window.getSelection()?.toString() || ''
// 无论是否选中文本,都设置上下文菜单位置
setContextMenuPosition({ x: e.clientX, y: e.clientY })
if (_selectedText) {
const quotedText =
_selectedText
@@ -73,8 +78,10 @@ const MessageItem: FC<Props> = ({
.map((line) => `> ${line}`)
.join('\n') + '\n-------------'
setSelectedQuoteText(quotedText)
setContextMenuPosition({ x: e.clientX, y: e.clientY })
setSelectedText(_selectedText)
} else {
setSelectedQuoteText('')
setSelectedText('')
}
}, [])
@@ -134,7 +141,7 @@ const MessageItem: FC<Props> = ({
{contextMenuPosition && (
<Dropdown
overlayStyle={{ left: contextMenuPosition.x, top: contextMenuPosition.y, zIndex: 1000 }}
menu={{ items: getContextMenuItems(t, selectedQuoteText, selectedText) }}
menu={{ items: getContextMenuItems(t, selectedQuoteText, selectedText, message) }}
open={true}
trigger={['contextMenu']}>
<div />
@@ -181,23 +188,46 @@ const getMessageBackground = (isBubbleStyle: boolean, isAssistantMessage: boolea
: undefined
}
const getContextMenuItems = (t: (key: string) => string, selectedQuoteText: string, selectedText: string) => [
{
key: 'copy',
label: t('common.copy'),
onClick: () => {
navigator.clipboard.writeText(selectedText)
window.message.success({ content: t('message.copied'), key: 'copy-message' })
}
},
{
key: 'quote',
label: t('chat.message.quote'),
onClick: () => {
EventEmitter.emit(EVENT_NAMES.QUOTE_TEXT, selectedQuoteText)
}
const getContextMenuItems = (
t: (key: string) => string,
selectedQuoteText: string,
selectedText: string,
message: Message
): ItemType[] => {
const items: ItemType[] = []
// 只有在选中文本时,才添加复制和引用选项
if (selectedText) {
items.push({
key: 'copy',
label: t('common.copy'),
onClick: () => {
navigator.clipboard.writeText(selectedText)
window.message.success({ content: t('message.copied'), key: 'copy-message' })
}
})
items.push({
key: 'quote',
label: t('chat.message.quote'),
onClick: () => {
EventEmitter.emit(EVENT_NAMES.QUOTE_TEXT, selectedQuoteText)
}
})
}
]
// 添加复制消息ID选项但不显示ID
items.push({
key: 'copy_id',
label: t('message.copy_id') || '复制消息ID',
onClick: () => {
navigator.clipboard.writeText(message.id)
window.message.success({ content: t('message.id_copied') || '消息ID已复制', key: 'copy-message-id' })
}
})
return items
}
const MessageContainer = styled.div`
display: flex;

View File

@@ -4,7 +4,7 @@ import { getModelUniqId } from '@renderer/services/ModelService'
import { Message, Model } from '@renderer/types'
import { getBriefInfo } from '@renderer/utils'
import { withMessageThought } from '@renderer/utils/formats'
import { Divider, Flex } from 'antd'
import { Collapse, Divider, Flex } from 'antd'
import { clone } from 'lodash'
import React, { Fragment, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
@@ -197,15 +197,107 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
const content = `[@${model.name}](#) ${getBriefInfo(message.content)}`
return <Markdown message={{ ...message, content }} />
}
const toolUseRegex = /<tool_use>([\s\S]*?)<\/tool_use>/g
return (
<Fragment>
<Flex gap="8px" wrap style={{ marginBottom: 10 }}>
{message.mentions?.map((model) => <MentionTag key={getModelUniqId(model)}>{'@' + model.name}</MentionTag>)}
</Flex>
<MessageThought message={message} />
<MessageTools message={message} />
<Markdown message={{ ...message, content: processedContent }} />
{message.referencedMessages && message.referencedMessages.length > 0 && (
<div>
{message.referencedMessages.map((refMsg, index) => (
<Collapse
key={refMsg.id}
className="reference-collapse"
defaultActiveKey={['1']}
size="small"
items={[
{
key: '1',
label: (
<div className="reference-header-label">
<span className="reference-title">
{t('message.referenced_message')}{' '}
{message.referencedMessages && message.referencedMessages.length > 1
? `(${index + 1}/${message.referencedMessages.length})`
: ''}
</span>
<span className="reference-role">{refMsg.role === 'user' ? t('common.you') : 'AI'}</span>
</div>
),
extra: (
<span
className="reference-id"
onClick={(e) => {
e.stopPropagation()
navigator.clipboard.writeText(refMsg.id)
window.message.success({
content: t('message.id_copied') || '消息ID已复制',
key: 'copy-reference-id'
})
}}>
ID: {refMsg.id}
</span>
),
children: (
<div className="reference-content">
<div className="reference-text">{refMsg.content}</div>
<div className="reference-bottom-spacing"></div>
</div>
)
}
]}
/>
))}
</div>
)}
{/* 兼容旧版本的referencedMessage */}
{!message.referencedMessages && (message as any).referencedMessage && (
<Collapse
className="reference-collapse"
defaultActiveKey={['1']}
size="small"
items={[
{
key: '1',
label: (
<div className="reference-header-label">
<span className="reference-title">{t('message.referenced_message')}</span>
<span className="reference-role">
{(message as any).referencedMessage.role === 'user' ? t('common.you') : 'AI'}
</span>
</div>
),
extra: (
<span
className="reference-id"
onClick={(e) => {
e.stopPropagation()
navigator.clipboard.writeText((message as any).referencedMessage.id)
window.message.success({
content: t('message.id_copied') || '消息ID已复制',
key: 'copy-reference-id'
})
}}>
ID: {(message as any).referencedMessage.id}
</span>
),
children: (
<div className="reference-content">
<div className="reference-text">{(message as any).referencedMessage.content}</div>
<div className="reference-bottom-spacing"></div>
</div>
)
}
]}
/>
)}
<div className="message-content-tools">
<MessageThought message={message} />
<MessageTools message={message} />
</div>
<Markdown message={{ ...message, content: processedContent.replace(toolUseRegex, '') }} />
{message.metadata?.generateImage && <MessageImage message={message} />}
{message.translatedContent && (
<Fragment>
@@ -312,4 +404,132 @@ const SearchEntryPoint = styled.div`
margin: 10px 2px;
`
// 引用消息样式 - 使用全局样式
const referenceStyles = `
.reference-collapse {
margin-bottom: 8px;
border: 1px solid var(--color-border) !important;
border-radius: 8px !important;
overflow: hidden;
background-color: var(--color-bg-1) !important;
.ant-collapse-header {
padding: 2px 8px !important;
background-color: var(--color-bg-2);
border-bottom: 1px solid var(--color-border);
font-size: 10px;
display: flex;
justify-content: space-between;
height: 18px;
min-height: 18px;
line-height: 14px;
}
.ant-collapse-expand-icon {
height: 18px;
line-height: 14px;
padding-top: 0 !important;
margin-top: -2px;
margin-right: 2px;
}
.ant-collapse-header-text {
flex: 0 1 auto;
max-width: 70%;
}
.ant-collapse-extra {
flex: 0 0 auto;
margin-left: 10px;
padding-right: 0;
position: relative;
right: 20px;
}
.reference-header-label {
display: flex;
align-items: center;
gap: 4px;
height: 14px;
line-height: 14px;
}
.reference-title {
font-weight: 500;
color: var(--color-text-1);
font-size: 10px;
}
.reference-role {
color: var(--color-text-2);
font-size: 9px;
}
.reference-id {
color: var(--color-text-3);
font-size: 9px;
cursor: pointer;
padding: 1px 4px;
border-radius: 3px;
transition: background-color 0.2s ease;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
display: inline-block;
&:hover {
background-color: var(--color-bg-3);
color: var(--color-text-2);
}
}
.ant-collapse-extra {
margin-left: auto;
display: flex;
align-items: center;
}
.ant-collapse-content-box {
padding: 12px !important;
padding-top: 8px !important;
padding-bottom: 2px !important;
}
.reference-content {
max-height: 200px;
overflow-y: auto;
padding-bottom: 10px;
.reference-text {
color: var(--color-text-1);
font-size: 14px;
white-space: pre-wrap;
word-break: break-word;
}
.reference-bottom-spacing {
height: 10px;
}
}
}
`
// 将样式添加到文档中
try {
if (typeof document !== 'undefined') {
const styleElement = document.createElement('style')
styleElement.textContent =
referenceStyles +
`
.message-content-tools {
margin-top: 20px;
}
`
document.head.appendChild(styleElement)
}
} catch (error) {
console.error('Failed to add reference styles:', error)
}
export default React.memo(MessageContent)

View File

@@ -7,6 +7,31 @@ import styled from 'styled-components'
import Markdown from '../Markdown/Markdown'
const MessageError: FC<{ message: Message }> = ({ message }) => {
const { t } = useTranslation()
// 首先检查是否存在已知的问题错误
if (message.error && typeof message.error === 'object') {
// 处理 rememberInstructions 错误
if (message.error.message === 'rememberInstructions is not defined') {
return (
<>
<Markdown message={message} />
<Alert description="消息加载时发生错误" type="error" />
</>
)
}
// 处理网络错误
if (message.error.message === 'network error') {
return (
<>
<Markdown message={message} />
<Alert description={t('error.network')} type="error" />
</>
)
}
}
return (
<>
<Markdown message={message} />
@@ -28,7 +53,13 @@ const MessageErrorInfo: FC<{ message: Message }> = ({ message }) => {
const HTTP_ERROR_CODES = [400, 401, 403, 404, 429, 500, 502, 503, 504]
if (message.error && HTTP_ERROR_CODES.includes(message.error?.status)) {
// Add more robust checks: ensure error is an object and status is a number before accessing/including
if (
message.error &&
typeof message.error === 'object' && // Check if error is an object
typeof message.error.status === 'number' && // Check if status is a number
HTTP_ERROR_CODES.includes(message.error.status) // Now safe to access status
) {
return <Alert description={t(`error.http.${message.error.status}`)} type="error" />
}

View File

@@ -9,6 +9,7 @@ interface Props {
interface State {
hasError: boolean
errorMessage?: string
}
const ErrorFallback = ({ fallback }: { fallback?: React.ReactNode }) => {
@@ -26,16 +27,52 @@ class MessageErrorBoundary extends React.Component<Props, State> {
this.state = { hasError: false }
}
static getDerivedStateFromError() {
return { hasError: true }
static getDerivedStateFromError(error: Error) {
// 检查是否是特定错误
let errorMessage: string | undefined = undefined
if (error.message === 'rememberInstructions is not defined') {
errorMessage = '消息加载时发生错误'
} else if (error.message === 'network error') {
errorMessage = '网络连接错误,请检查您的网络连接并重试'
} else if (
typeof error.message === 'string' &&
(error.message.includes('network') || error.message.includes('timeout') || error.message.includes('connection'))
) {
errorMessage = '网络连接问题'
}
return { hasError: true, errorMessage }
}
// 正确缩进 componentDidCatch
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
// Log the detailed error information to the console
console.error('MessageErrorBoundary caught an error:', error, errorInfo)
// 如果是特定错误,记录更多信息
if (error.message === 'rememberInstructions is not defined') {
console.warn('Known issue with rememberInstructions detected in MessageErrorBoundary')
} else if (error.message === 'network error') {
console.warn('Network error detected in MessageErrorBoundary')
} else if (
typeof error.message === 'string' &&
(error.message.includes('network') || error.message.includes('timeout') || error.message.includes('connection'))
) {
console.warn('Network-related error detected in MessageErrorBoundary:', error.message)
}
}
// 正确缩进 render
render() {
if (this.state.hasError) {
// 如果有特定错误消息,显示自定义错误
if (this.state.errorMessage) {
return <Alert message="渲染错误" description={this.state.errorMessage} type="error" showIcon />
}
return <ErrorFallback fallback={this.props.fallback} />
}
return this.props.children
}
}
} // MessageErrorBoundary 类的结束括号,已删除多余的括号
export default MessageErrorBoundary

View File

@@ -148,35 +148,42 @@ const MessageMenubar: FC<Props> = (props) => {
const imageUrls: string[] = []
let match
let content = editedText
while ((match = imageRegex.exec(editedText)) !== null) {
imageUrls.push(match[1])
content = content.replace(match[0], '')
}
// 更新消息内容,保留图片信息
await editMessage(message.id, {
await editMessage(message.id, {
content: content.trim(),
metadata: {
...message.metadata,
generateImage: imageUrls.length > 0 ? {
type: 'url',
images: imageUrls
} : undefined
}
})
resendMessage && handleResendUserMessage({
...message,
content: content.trim(),
metadata: {
...message.metadata,
generateImage: imageUrls.length > 0 ? {
type: 'url',
images: imageUrls
} : undefined
generateImage:
imageUrls.length > 0
? {
type: 'url',
images: imageUrls
}
: undefined
}
})
resendMessage &&
handleResendUserMessage({
...message,
content: content.trim(),
metadata: {
...message.metadata,
generateImage:
imageUrls.length > 0
? {
type: 'url',
images: imageUrls
}
: undefined
}
})
}
}, [message, editMessage, handleResendUserMessage, t])

View File

@@ -1,16 +1,93 @@
import { LinkOutlined } from '@ant-design/icons'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { findMessageById } from '@renderer/services/MessagesService'
import { Message } from '@renderer/types'
import { Button, Modal, Tooltip } from 'antd'
import { t } from 'i18next'
import { useState } from 'react'
import styled from 'styled-components'
// 添加引用消息的弹窗组件
const ReferenceModal: React.FC<{ message: Message | null; visible: boolean; onClose: () => void }> = ({
message,
visible,
onClose
}) => {
if (!message) return null
return (
<Modal title={`引用消息`} open={visible} onCancel={onClose} footer={null} width={600}>
<ReferenceContent>
<div className="message-role">{message.role === 'user' ? t('common.you') : 'AI'}</div>
<div className="message-content">{message.content}</div>
<div className="message-time">{new Date(message.createdAt).toLocaleString()}</div>
</ReferenceContent>
</Modal>
)
}
const ReferenceContent = styled.div`
padding: 10px;
.message-role {
font-weight: bold;
margin-bottom: 5px;
}
.message-content {
white-space: pre-wrap;
word-break: break-word;
margin-bottom: 10px;
}
.message-time {
font-size: 12px;
color: var(--color-text-3);
text-align: right;
}
`
const MessgeTokens: React.FC<{ message: Message; isLastMessage: boolean }> = ({ message, isLastMessage }) => {
const { generating } = useRuntime()
const [isModalVisible, setIsModalVisible] = useState(false)
const [referencedMessage, setReferencedMessage] = useState<Message | null>(null)
// 渲染引用消息弹窗
const renderReferenceModal = () => {
return <ReferenceModal message={referencedMessage} visible={isModalVisible} onClose={handleModalClose} />
}
const locateMessage = () => {
EventEmitter.emit(EVENT_NAMES.LOCATE_MESSAGE + ':' + message.id, false)
}
const showReferenceModal = async (e: React.MouseEvent) => {
e.stopPropagation() // 防止触发父元素的点击事件
try {
// 复制ID到剪贴板便于用户手动使用
navigator.clipboard.writeText(message.id)
// 查找原始消息
const originalMessage = await findMessageById(message.id)
if (originalMessage) {
setReferencedMessage(originalMessage)
setIsModalVisible(true)
}
} catch (error) {
console.error('Failed to find referenced message:', error)
window.message.error({
content: t('message.reference.error') || '无法找到原始消息',
key: 'reference-message-error'
})
}
}
const handleModalClose = () => {
setIsModalVisible(false)
}
if (!message.usage) {
return <div />
}
@@ -18,7 +95,16 @@ const MessgeTokens: React.FC<{ message: Message; isLastMessage: boolean }> = ({
if (message.role === 'user') {
return (
<MessageMetadata className="message-tokens" onClick={locateMessage}>
Tokens: {message?.usage?.total_tokens}
<span className="tokens">Tokens: {message?.usage?.total_tokens}</span>
<Tooltip title={t('message.reference') || '引用消息'}>
<Button
type="text"
size="small"
icon={<LinkOutlined />}
onClick={showReferenceModal}
className="reference-button"
/>
</Tooltip>
</MessageMetadata>
)
}
@@ -47,11 +133,25 @@ const MessgeTokens: React.FC<{ message: Message; isLastMessage: boolean }> = ({
<span className="tokens">
Tokens: {message?.usage?.total_tokens} {message?.usage?.prompt_tokens} {message?.usage?.completion_tokens}
</span>
<Tooltip title={t('message.reference') || '引用消息'}>
<Button
type="text"
size="small"
icon={<LinkOutlined />}
onClick={showReferenceModal}
className="reference-button"
/>
</Tooltip>
</MessageMetadata>
)
}
return null
return (
<>
{renderReferenceModal()}
{null}
</>
)
}
const MessageMetadata = styled.div`
@@ -61,6 +161,10 @@ const MessageMetadata = styled.div`
margin: 2px 0;
cursor: pointer;
text-align: right;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 5px;
.metrics {
display: none;
@@ -79,6 +183,26 @@ const MessageMetadata = styled.div`
display: none;
}
}
.reference-button {
padding: 0;
height: 16px;
width: 16px;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-2);
opacity: 0.7;
&:hover {
opacity: 1;
color: var(--color-primary);
}
.anticon {
font-size: 12px;
}
}
`
export default MessgeTokens

View File

@@ -1,4 +1,4 @@
import { CheckOutlined, ExpandOutlined, LoadingOutlined } from '@ant-design/icons'
import { CheckOutlined, ExpandOutlined, LoadingOutlined, WarningOutlined } from '@ant-design/icons'
import { useSettings } from '@renderer/hooks/useSettings'
import { Message } from '@renderer/types'
import { Collapse, message as antdMessage, Modal, Tooltip } from 'antd'
@@ -48,6 +48,7 @@ const MessageTools: FC<Props> = ({ message }) => {
const { id, tool, status, response } = toolResponse
const isInvoking = status === 'invoking'
const isDone = status === 'done'
const hasError = isDone && response?.isError === true
const result = {
params: tool.inputSchema,
response: toolResponse.response
@@ -59,10 +60,15 @@ const MessageTools: FC<Props> = ({ message }) => {
<MessageTitleLabel>
<TitleContent>
<ToolName>{tool.name}</ToolName>
<StatusIndicator $isInvoking={isInvoking}>
{isInvoking ? t('message.tools.invoking') : t('message.tools.completed')}
<StatusIndicator $isInvoking={isInvoking} $hasError={hasError}>
{isInvoking
? t('message.tools.invoking')
: hasError
? t('message.tools.error')
: t('message.tools.completed')}
{isInvoking && <LoadingOutlined spin style={{ marginLeft: 6 }} />}
{isDone && <CheckOutlined style={{ marginLeft: 6 }} />}
{isDone && !hasError && <CheckOutlined style={{ marginLeft: 6 }} />}
{hasError && <WarningOutlined style={{ marginLeft: 6 }} />}
</StatusIndicator>
</TitleContent>
<ActionButtonsContainer>
@@ -195,8 +201,12 @@ const ToolName = styled.span`
font-size: 13px;
`
const StatusIndicator = styled.span<{ $isInvoking: boolean }>`
color: ${(props) => (props.$isInvoking ? 'var(--color-primary)' : 'var(--color-success, #52c41a)')};
const StatusIndicator = styled.span<{ $isInvoking: boolean; $hasError?: boolean }>`
color: ${(props) => {
if (props.$hasError) return 'var(--color-error, #ff4d4f)'
if (props.$isInvoking) return 'var(--color-primary)'
return 'var(--color-success, #52c41a)'
}};
font-size: 11px;
display: flex;
align-items: center;

View File

@@ -38,42 +38,6 @@ interface MessagesProps {
setActiveTopic: (topic: Topic) => void
}
const computeDisplayMessages = (messages: Message[], startIndex: number, displayCount: number) => {
const reversedMessages = [...messages].reverse()
// 如果剩余消息数量小于 displayCount直接返回所有剩余消息
if (reversedMessages.length - startIndex <= displayCount) {
return reversedMessages.slice(startIndex)
}
const userIdSet = new Set() // 用户消息 id 集合
const assistantIdSet = new Set() // 助手消息 askId 集合
const displayMessages: Message[] = []
// 处理单条消息的函数
const processMessage = (message: Message) => {
if (!message) return
const idSet = message.role === 'user' ? userIdSet : assistantIdSet
const messageId = message.role === 'user' ? message.id : message.askId
if (!idSet.has(messageId)) {
idSet.add(messageId)
displayMessages.push(message)
return
}
// 如果是相同 askId 的助手消息,也要显示
displayMessages.push(message)
}
// 遍历消息直到满足显示数量要求
for (let i = startIndex; i < reversedMessages.length && userIdSet.size + assistantIdSet.size < displayCount; i++) {
processMessage(reversedMessages[i])
}
return displayMessages
}
const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic }) => {
const { t } = useTranslation()
const { showTopics, topicPosition, showAssistants, messageNavigation } = useSettings()
@@ -118,24 +82,36 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic })
}
}, [])
const clearTopic = useCallback(
async (data: Topic) => {
const defaultTopic = getDefaultTopic(assistant.id)
if (data && data.id !== topic.id) {
await clearTopicMessages(data.id)
updateTopic({ ...data, name: defaultTopic.name } as Topic)
return
}
await clearTopicMessages()
setDisplayMessages([])
const _topic = getTopic(assistant, topic.id)
_topic && updateTopic({ ..._topic, name: defaultTopic.name } as Topic)
},
[assistant, clearTopicMessages, topic.id, updateTopic]
)
useEffect(() => {
const unsubscribes = [
EventEmitter.on(EVENT_NAMES.SEND_MESSAGE, scrollToBottom),
EventEmitter.on(EVENT_NAMES.CLEAR_MESSAGES, async (data: Topic) => {
const defaultTopic = getDefaultTopic(assistant.id)
if (data && data.id !== topic.id) {
await clearTopicMessages(data.id)
updateTopic({ ...data, name: defaultTopic.name } as Topic)
return
}
await clearTopicMessages()
setDisplayMessages([])
const _topic = getTopic(assistant, topic.id)
if (_topic) {
updateTopic({ ..._topic, name: defaultTopic.name } as Topic)
}
window.modal.confirm({
title: t('chat.input.clear.title'),
content: t('chat.input.clear.content'),
centered: true,
onOk: () => clearTopic(data)
})
}),
EventEmitter.on(EVENT_NAMES.COPY_TOPIC_IMAGE, async () => {
await captureScrollableDivAsBlob(containerRef, async (blob) => {
@@ -280,11 +256,43 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic })
)
}
interface LoaderProps {
$loading: boolean
const computeDisplayMessages = (messages: Message[], startIndex: number, displayCount: number) => {
const reversedMessages = [...messages].reverse()
// 如果剩余消息数量小于 displayCount直接返回所有剩余消息
if (reversedMessages.length - startIndex <= displayCount) {
return reversedMessages.slice(startIndex)
}
const userIdSet = new Set() // 用户消息 id 集合
const assistantIdSet = new Set() // 助手消息 askId 集合
const displayMessages: Message[] = []
// 处理单条消息的函数
const processMessage = (message: Message) => {
if (!message) return
const idSet = message.role === 'user' ? userIdSet : assistantIdSet
const messageId = message.role === 'user' ? message.id : message.askId
if (!idSet.has(messageId)) {
idSet.add(messageId)
displayMessages.push(message)
return
}
// 如果是相同 askId 的助手消息,也要显示
displayMessages.push(message)
}
// 遍历消息直到满足显示数量要求
for (let i = startIndex; i < reversedMessages.length && userIdSet.size + assistantIdSet.size < displayCount; i++) {
processMessage(reversedMessages[i])
}
return displayMessages
}
const LoaderContainer = styled.div<LoaderProps>`
const LoaderContainer = styled.div<{ $loading: boolean }>`
display: flex;
justify-content: center;
padding: 10px;
@@ -298,6 +306,7 @@ const LoaderContainer = styled.div<LoaderProps>`
const ScrollContainer = styled.div`
display: flex;
flex-direction: column-reverse;
margin-bottom: -20px; // 添加负的底部外边距来减少空间
`
interface ContainerProps {
@@ -307,7 +316,7 @@ interface ContainerProps {
const Container = styled(Scrollbar)<ContainerProps>`
display: flex;
flex-direction: column-reverse;
padding: 10px 0 20px;
padding: 10px 0 10px;
overflow-x: hidden;
background-color: var(--color-background);
z-index: 1;

View File

@@ -31,6 +31,8 @@ const Container = styled.div`
align-items: center;
margin-bottom: 10px;
margin-top: -10px;
padding: 0;
min-height: auto;
`
const Button = styled(AntdButton)<{ $theme: ThemeMode }>`

View File

@@ -1,8 +1,9 @@
import { FormOutlined, SearchOutlined } from '@ant-design/icons'
import { BookOutlined, FormOutlined, SearchOutlined } from '@ant-design/icons'
import { Navbar, NavbarLeft, NavbarRight } from '@renderer/components/app/Navbar'
import { HStack } from '@renderer/components/Layout'
import MinAppsPopover from '@renderer/components/Popups/MinAppsPopover'
import SearchPopup from '@renderer/components/Popups/SearchPopup'
import ShortMemoryPopup from '@renderer/components/Popups/ShortMemoryPopup'
import { isMac } from '@renderer/config/constant'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { modelGenerating } from '@renderer/hooks/useRuntime'
@@ -10,10 +11,11 @@ import { useSettings } from '@renderer/hooks/useSettings'
import { useShortcut } from '@renderer/hooks/useShortcuts'
import { useShowAssistants, useShowTopics } from '@renderer/hooks/useStore'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { analyzeAndAddShortMemories } from '@renderer/services/MemoryService'
import { useAppDispatch } from '@renderer/store'
import { setNarrowMode } from '@renderer/store/settings'
import { Assistant, Topic } from '@renderer/types'
import { Tooltip } from 'antd'
import { Button, Tooltip } from 'antd'
import { t } from 'i18next'
import { FC } from 'react'
import styled from 'styled-components'
@@ -27,7 +29,7 @@ interface Props {
setActiveTopic: (topic: Topic) => void
}
const HeaderNavbar: FC<Props> = ({ activeAssistant }) => {
const HeaderNavbar: FC<Props> = ({ activeAssistant, activeTopic }) => {
const { assistant } = useAssistant(activeAssistant.id)
const { showAssistants, toggleShowAssistants } = useShowAssistants()
const { topicPosition, sidebarIcons, narrowMode } = useSettings()
@@ -55,6 +57,28 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant }) => {
dispatch(setNarrowMode(!narrowMode))
}
const handleShowShortMemory = () => {
if (activeTopic && activeTopic.id) {
ShortMemoryPopup.show({ topicId: activeTopic.id })
}
}
const handleAnalyzeShortMemory = async () => {
if (activeTopic && activeTopic.id) {
try {
const result = await analyzeAndAddShortMemories(activeTopic.id)
if (result) {
window.message.success(t('settings.memory.shortMemoryAnalysisSuccess') || '分析成功')
} else {
window.message.info(t('settings.memory.shortMemoryAnalysisNoNew') || '无新信息')
}
} catch (error) {
console.error('Failed to analyze conversation for short memory:', error)
window.message.error(t('settings.memory.shortMemoryAnalysisError') || '分析失败')
}
}
}
return (
<Navbar className="home-navbar">
{showAssistants && (
@@ -86,6 +110,14 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant }) => {
</HStack>
<HStack alignItems="center" gap={8}>
<UpdateAppButton />
<Tooltip title={t('settings.memory.shortMemory')} mouseEnterDelay={0.8}>
<NarrowIcon onClick={handleShowShortMemory}>
<BookOutlined />
</NarrowIcon>
</Tooltip>
<AnalyzeButton onClick={handleAnalyzeShortMemory}>
{t('settings.memory.analyzeConversation') || '分析对话'}
</AnalyzeButton>
<Tooltip title={t('chat.assistant.search.placeholder')} mouseEnterDelay={0.8}>
<NarrowIcon onClick={() => SearchPopup.show()}>
<SearchOutlined />
@@ -156,4 +188,17 @@ const NarrowIcon = styled(NavbarIcon)`
}
`
const AnalyzeButton = styled(Button)`
font-size: 12px;
height: 28px;
padding: 0 10px;
border-radius: 4px;
margin-right: 8px;
-webkit-app-region: none;
@media (max-width: 1000px) {
display: none;
}
`
export default HeaderNavbar

View File

@@ -149,7 +149,9 @@ const SettingsTab: FC<Props> = (props) => {
setMaxTokens(assistant?.settings?.maxTokens ?? DEFAULT_MAX_TOKENS)
setStreamOutput(assistant?.settings?.streamOutput ?? true)
setReasoningEffort(assistant?.settings?.reasoning_effort)
}, [assistant])
useEffect(() => {
// 当是Grok模型时处理reasoning_effort的设置
// For Grok models, only 'low' and 'high' reasoning efforts are supported.
// This ensures compatibility with the model's capabilities and avoids unsupported configurations.
@@ -163,7 +165,7 @@ const SettingsTab: FC<Props> = (props) => {
onReasoningEffortChange('high')
}
}
}, [assistant, onReasoningEffortChange])
}, [assistant?.model, assistant?.settings?.reasoning_effort, onReasoningEffortChange])
const formatSliderTooltip = (value?: number) => {
if (value === undefined) return ''

View File

@@ -1,9 +1,9 @@
import { CheckCircleOutlined, QuestionCircleOutlined, WarningOutlined } from '@ant-design/icons'
import { Center, VStack } from '@renderer/components/Layout'
import { EventEmitter } from '@renderer/services/EventService'
import { Alert, Button } from 'antd'
import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router'
import styled from 'styled-components'
import { SettingDescription, SettingRow, SettingSubtitle } from '..'
@@ -21,6 +21,7 @@ const InstallNpxUv: FC<Props> = ({ mini = false }) => {
const [bunPath, setBunPath] = useState<string | null>(null)
const [binariesDir, setBinariesDir] = useState<string | null>(null)
const { t } = useTranslation()
const navigate = useNavigate()
const checkBinaries = async () => {
const uvExists = await window.api.isBinaryExist('uv')
@@ -78,7 +79,7 @@ const InstallNpxUv: FC<Props> = ({ mini = false }) => {
icon={installed ? <CheckCircleOutlined /> : <WarningOutlined />}
className="nodrag"
color={installed ? 'green' : 'danger'}
onClick={() => EventEmitter.emit('mcp:mcp-install')}
onClick={() => navigate('/settings/mcp/mcp-install')}
/>
)
}

View File

@@ -0,0 +1,96 @@
import { MCPPrompt } from '@renderer/types'
import { Collapse, Descriptions, Empty, Flex, Tag, Tooltip, Typography } from 'antd'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface MCPPromptsSectionProps {
prompts: MCPPrompt[]
}
const MCPPromptsSection = ({ prompts }: MCPPromptsSectionProps) => {
const { t } = useTranslation()
// Render prompt arguments
const renderPromptArguments = (prompt: MCPPrompt) => {
if (!prompt.arguments || prompt.arguments.length === 0) return null
return (
<div style={{ marginTop: 12 }}>
<Typography.Title level={5}>{t('settings.mcp.tools.inputSchema')}:</Typography.Title>
<Descriptions bordered size="small" column={1} style={{ marginTop: 8 }}>
{prompt.arguments.map((arg, index) => (
<Descriptions.Item
key={index}
label={
<Flex align="center" gap={8}>
<Typography.Text strong>{arg.name}</Typography.Text>
{arg.required && (
<Tooltip title="Required field">
<Tag color="red">Required</Tag>
</Tooltip>
)}
</Flex>
}>
<Flex vertical gap={4}>
{arg.description && (
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
{arg.description}
</Typography.Paragraph>
)}
</Flex>
</Descriptions.Item>
))}
</Descriptions>
</div>
)
}
return (
<Section>
<SectionTitle>{t('settings.mcp.prompts.availablePrompts')}</SectionTitle>
{prompts.length > 0 ? (
<Collapse bordered={false} ghost>
{prompts.map((prompt) => (
<Collapse.Panel
key={prompt.id || prompt.name}
header={
<Flex vertical align="flex-start">
<Flex align="center" style={{ width: '100%' }}>
<Typography.Text strong>{prompt.name}</Typography.Text>
</Flex>
{prompt.description && (
<Typography.Text type="secondary" style={{ fontSize: '13px', marginTop: 4 }}>
{prompt.description}
</Typography.Text>
)}
</Flex>
}>
<SelectableContent>{renderPromptArguments(prompt)}</SelectableContent>
</Collapse.Panel>
))}
</Collapse>
) : (
<Empty description={t('settings.mcp.prompts.noPromptsAvailable')} image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</Section>
)
}
const Section = styled.div`
margin-top: 8px;
padding-top: 8px;
`
const SectionTitle = styled.h3`
font-size: 14px;
font-weight: 500;
margin-bottom: 8px;
color: var(--color-text-secondary);
`
const SelectableContent = styled.div`
user-select: text;
padding: 0 12px;
`
export default MCPPromptsSection

View File

@@ -1,13 +1,15 @@
import { DeleteOutlined, SaveOutlined } from '@ant-design/icons'
import { useMCPServers } from '@renderer/hooks/useMCPServers'
import { MCPServer, MCPTool } from '@renderer/types'
import { Button, Flex, Form, Input, Radio, Switch } from 'antd'
import { MCPPrompt, MCPServer, MCPTool } from '@renderer/types'
import { Button, Flex, Form, Input, Radio, Switch, Tabs } from 'antd'
import TextArea from 'antd/es/input/TextArea'
import React, { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router'
import styled from 'styled-components'
import { SettingContainer, SettingDivider, SettingGroup, SettingTitle } from '..'
import MCPPromptsSection from './McpPrompt'
import MCPToolsSection from './McpTool'
interface Props {
@@ -40,6 +42,8 @@ const PipRegistry: Registry[] = [
{ name: '腾讯云', url: 'https://mirrors.cloud.tencent.com/pypi/simple/' }
]
type TabKey = 'settings' | 'tools' | 'prompts'
const McpSettings: React.FC<Props> = ({ server }) => {
const { t } = useTranslation()
const { deleteMCPServer, updateMCPServer } = useMCPServers()
@@ -48,11 +52,15 @@ const McpSettings: React.FC<Props> = ({ server }) => {
const [loading, setLoading] = useState(false)
const [isFormChanged, setIsFormChanged] = useState(false)
const [loadingServer, setLoadingServer] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState<TabKey>('settings')
const [tools, setTools] = useState<MCPTool[]>([])
const [prompts, setPrompts] = useState<MCPPrompt[]>([])
const [isShowRegistry, setIsShowRegistry] = useState(false)
const [registry, setRegistry] = useState<Registry[]>()
const navigate = useNavigate()
useEffect(() => {
const serverType: MCPServer['type'] = server.type || (server.baseUrl ? 'sse' : 'stdio')
setServerType(serverType)
@@ -109,10 +117,9 @@ const McpSettings: React.FC<Props> = ({ server }) => {
setLoadingServer(server.id)
const localTools = await window.api.mcp.listTools(server)
setTools(localTools)
// window.message.success(t('settings.mcp.toolsLoaded'))
} catch (error) {
window.message.error({
content: t('settings.mcp.toolsLoadError') + formatError(error),
content: t('settings.mcp.tools.loadError') + ' ' + formatError(error),
key: 'mcp-tools-error'
})
} finally {
@@ -121,9 +128,28 @@ const McpSettings: React.FC<Props> = ({ server }) => {
}
}
const fetchPrompts = async () => {
if (server.isActive) {
try {
setLoadingServer(server.id)
const localPrompts = await window.api.mcp.listPrompts(server)
setPrompts(localPrompts)
} catch (error) {
window.message.error({
content: t('settings.mcp.prompts.loadError') + ' ' + formatError(error),
key: 'mcp-prompts-error'
})
setPrompts([])
} finally {
setLoadingServer(null)
}
}
}
useEffect(() => {
if (server.isActive) {
fetchTools()
fetchPrompts()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [server.id, server.isActive])
@@ -234,6 +260,7 @@ const McpSettings: React.FC<Props> = ({ server }) => {
await window.api.mcp.removeServer(server)
deleteMCPServer(server.id)
window.message.success({ content: t('settings.mcp.deleteSuccess'), key: 'mcp-list' })
navigate('/settings/mcp')
}
})
} catch (error: any) {
@@ -264,6 +291,9 @@ const McpSettings: React.FC<Props> = ({ server }) => {
if (active) {
const localTools = await window.api.mcp.listTools(server)
setTools(localTools)
const localPrompts = await window.api.mcp.listPrompts(server)
setPrompts(localPrompts)
} else {
await window.api.mcp.stopServer(server)
}
@@ -309,35 +339,16 @@ const McpSettings: React.FC<Props> = ({ server }) => {
[server, updateMCPServer]
)
return (
<SettingContainer>
<SettingGroup style={{ marginBottom: 0 }}>
<SettingTitle>
<Flex justify="space-between" align="center" gap={5} style={{ marginRight: 10 }}>
<ServerName className="text-nowrap">{server?.name}</ServerName>
{!(server.type === 'inMemory') && (
<Button danger icon={<DeleteOutlined />} type="text" onClick={() => onDeleteMcpServer(server)} />
)}
</Flex>
<Flex align="center" gap={16}>
<Switch
value={server.isActive}
key={server.id}
loading={loadingServer === server.id}
onChange={onToggleActive}
/>
<Button type="primary" icon={<SaveOutlined />} onClick={onSave} loading={loading} disabled={!isFormChanged}>
{t('common.save')}
</Button>
</Flex>
</SettingTitle>
<SettingDivider />
const tabs = [
{
key: 'settings',
label: t('settings.mcp.tabs.general'),
children: (
<Form
form={form}
layout="vertical"
onValuesChange={() => setIsFormChanged(true)}
style={{
// height: 'calc(100vh - var(--navbar-height) - 315px)',
overflowY: 'auto',
width: 'calc(100% + 10px)',
paddingRight: '10px'
@@ -440,7 +451,58 @@ const McpSettings: React.FC<Props> = ({ server }) => {
</>
)}
</Form>
{server.isActive && <MCPToolsSection tools={tools} server={server} onToggleTool={handleToggleTool} />}
)
}
]
if (server.isActive) {
tabs.push(
{
key: 'tools',
label: t('settings.mcp.tabs.tools'),
children: <MCPToolsSection tools={tools} server={server} onToggleTool={handleToggleTool} />
},
{
key: 'prompts',
label: t('settings.mcp.tabs.prompts'),
children: <MCPPromptsSection prompts={prompts} />
}
)
}
return (
<SettingContainer>
<SettingGroup style={{ marginBottom: 0 }}>
<SettingTitle>
<Flex justify="space-between" align="center" gap={5} style={{ marginRight: 10 }}>
<ServerName className="text-nowrap">{server?.name}</ServerName>
<Button danger icon={<DeleteOutlined />} type="text" onClick={() => onDeleteMcpServer(server)} />
</Flex>
<Flex align="center" gap={16}>
<Switch
value={server.isActive}
key={server.id}
loading={loadingServer === server.id}
onChange={onToggleActive}
/>
<Button
type="primary"
icon={<SaveOutlined />}
onClick={onSave}
loading={loading}
disabled={!isFormChanged || activeTab !== 'settings'}>
{t('common.save')}
</Button>
</Flex>
</SettingTitle>
<SettingDivider />
<Tabs
defaultActiveKey="settings"
items={tabs}
onChange={(key) => setActiveTab(key as TabKey)}
style={{ marginTop: 8 }}
/>
</SettingGroup>
</SettingContainer>
)

View File

@@ -2,15 +2,16 @@ import { EditOutlined, ExportOutlined, SearchOutlined } from '@ant-design/icons'
import { NavbarRight } from '@renderer/components/app/Navbar'
import { HStack } from '@renderer/components/Layout'
import { isWindows } from '@renderer/config/constant'
import { EventEmitter } from '@renderer/services/EventService'
import { Button } from 'antd'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router'
import EditMcpJsonPopup from './EditMcpJsonPopup'
import InstallNpxUv from './InstallNpxUv'
export const McpSettingsNavbar = () => {
const { t } = useTranslation()
const navigate = useNavigate()
const onClick = () => window.open('https://mcp.so/', '_blank')
return (
@@ -19,7 +20,7 @@ export const McpSettingsNavbar = () => {
<Button
size="small"
type="text"
onClick={() => EventEmitter.emit('mcp:npx-search')}
onClick={() => navigate('/settings/mcp/npx-search')}
icon={<SearchOutlined />}
className="nodrag"
style={{ fontSize: 13, height: 28, borderRadius: 20 }}>

View File

@@ -112,7 +112,7 @@ const MCPToolsSection = ({ tools, server, onToggleTool }: MCPToolsSectionProps)
</Flex>
{tool.description && (
<Typography.Text type="secondary" style={{ fontSize: '13px', marginTop: 4 }}>
{tool.description}
{tool.description.length > 100 ? `${tool.description.substring(0, 100)}...` : tool.description}
</Typography.Text>
)}
</Flex>
@@ -138,7 +138,6 @@ const MCPToolsSection = ({ tools, server, onToggleTool }: MCPToolsSectionProps)
const Section = styled.div`
margin-top: 8px;
border-top: 1px solid var(--color-border);
padding-top: 8px;
`

View File

@@ -1,7 +1,7 @@
import { PlusOutlined, SearchOutlined } from '@ant-design/icons'
import { CheckOutlined, PlusOutlined } from '@ant-design/icons'
import { nanoid } from '@reduxjs/toolkit'
import { HStack } from '@renderer/components/Layout'
import { useTheme } from '@renderer/context/ThemeProvider'
import npmLogo from '@renderer/assets/images/mcp/npm.svg'
import { Center, HStack } from '@renderer/components/Layout'
import { useMCPServers } from '@renderer/hooks/useMCPServers'
import { builtinMCPServers } from '@renderer/store/mcp'
import { MCPServer } from '@renderer/types'
@@ -9,9 +9,7 @@ import { Button, Card, Flex, Input, Space, Spin, Tag, Typography } from 'antd'
import { npxFinder } from 'npx-scope-finder'
import { type FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled, { css } from 'styled-components'
import { SettingDivider, SettingGroup, SettingTitle } from '..'
import styled from 'styled-components'
interface SearchResult {
name: string
@@ -27,8 +25,9 @@ const npmScopes = ['@cherry', '@modelcontextprotocol', '@gongrzhe', '@mcpmarket'
let _searchResults: SearchResult[] = []
const NpxSearch: FC = () => {
const { theme } = useTheme()
const NpxSearch: FC<{
setSelectedMcpServer: (server: MCPServer) => void
}> = ({ setSelectedMcpServer }) => {
const { t } = useTranslation()
const { Text, Link } = Typography
@@ -36,7 +35,7 @@ const NpxSearch: FC = () => {
const [npmScope, setNpmScope] = useState('@cherry')
const [searchLoading, setSearchLoading] = useState(false)
const [searchResults, setSearchResults] = useState<SearchResult[]>(_searchResults)
const { addMCPServer } = useMCPServers()
const { addMCPServer, mcpServers } = useMCPServers()
_searchResults = searchResults
@@ -116,119 +115,134 @@ const NpxSearch: FC = () => {
}, [])
return (
<SettingGroup theme={theme} css={SettingGroupCss}>
<div>
<SettingTitle>
{t('settings.mcp.npx_list.title')} <Text type="secondary">{t('settings.mcp.npx_list.desc')}</Text>
</SettingTitle>
<SettingDivider />
<Space direction="vertical" style={{ width: '100%' }}>
<Space.Compact style={{ width: '100%', marginBottom: 10 }}>
<Container>
<Center>
<Space direction="vertical" style={{ marginBottom: 20, width: 500 }}>
<Center style={{ marginBottom: 20 }}>
<img src={npmLogo} alt="npm" width={100} />
</Center>
<Space.Compact style={{ width: '100%' }}>
<Input
placeholder={t('settings.mcp.npx_list.scope_placeholder')}
value={npmScope}
onChange={(e) => setNpmScope(e.target.value)}
onPressEnter={() => handleNpmSearch(npmScope)}
size="large"
styles={{ input: { borderRadius: 100 } }}
/>
<Button icon={<SearchOutlined />} onClick={() => handleNpmSearch(npmScope)} disabled={searchLoading}>
{t('settings.mcp.npx_list.search')}
</Button>
</Space.Compact>
<HStack alignItems="center" mt="-5px" mb="5px">
<HStack alignItems="center" justifyContent="center">
{npmScopes.map((scope) => (
<Tag
key={scope}
bordered={false}
onClick={() => {
setNpmScope(scope)
handleNpmSearch(scope)
}}
style={{ cursor: searchLoading ? 'not-allowed' : 'pointer' }}>
style={{
cursor: searchLoading ? 'not-allowed' : 'pointer',
borderRadius: 100,
backgroundColor: 'var(--color-background-mute)'
}}>
{scope}
</Tag>
))}
</HStack>
</Space>
</div>
<ResultList>
{searchLoading ? (
<div style={{ textAlign: 'center', padding: '20px' }}>
<Spin />
</div>
) : (
searchResults?.map((record) => (
<Card
size="small"
key={record.name}
title={
<Typography.Title level={5} style={{ margin: 0 }} className="selectable">
{record.name}
</Typography.Title>
}
extra={
<Flex>
<Tag bordered={false} color="processing">
v{record.version}
</Tag>
<Button
type="text"
icon={<PlusOutlined />}
size="small"
onClick={() => {
const buildInServer = builtinMCPServers.find((server) => server.name === record.name)
if (buildInServer) {
addMCPServer(buildInServer)
return
</Center>
{searchLoading && (
<Center>
<Spin />
</Center>
)}
{!searchLoading && (
<ResultList>
{searchResults?.map((record) => {
const isInstalled = mcpServers.some((server) => server.name === record.name)
return (
<Card
size="small"
key={record.name}
title={
<Typography.Title level={5} style={{ margin: 0 }} className="selectable">
{record.name}
</Typography.Title>
}
extra={
<Flex>
<Tag bordered={false} color="processing">
v{record.version}
</Tag>
<Button
type="text"
icon={
isInstalled ? <CheckOutlined style={{ color: 'var(--color-primary)' }} /> : <PlusOutlined />
}
size="small"
onClick={() => {
if (isInstalled) {
return
}
addMCPServer({
id: nanoid(),
name: record.name,
description: `${record.description}\n\n${t('settings.mcp.npx_list.usage')}: ${record.usage}\n${t('settings.mcp.npx_list.npm')}: ${record.npmLink}`,
command: 'npx',
args: ['-y', record.fullName],
isActive: false,
type: record.type
})
}}
/>
</Flex>
}>
<Space direction="vertical" size="small">
<Text className="selectable">{record.description}</Text>
<Text type="secondary" className="selectable">
{t('settings.mcp.npx_list.usage')}: {record.usage}
</Text>
<Link href={record.npmLink} target="_blank" rel="noopener noreferrer">
{record.npmLink}
</Link>
</Space>
</Card>
))
)}
</ResultList>
</SettingGroup>
const buildInServer = builtinMCPServers.find((server) => server.name === record.name)
if (buildInServer) {
addMCPServer(buildInServer)
window.message.success({ content: t('settings.mcp.addSuccess'), key: 'mcp-add-server' })
setSelectedMcpServer(buildInServer)
return
}
const newServer = {
id: nanoid(),
name: record.name,
description: `${record.description}\n\n${t('settings.mcp.npx_list.usage')}: ${record.usage}\n${t('settings.mcp.npx_list.npm')}: ${record.npmLink}`,
command: 'npx',
args: ['-y', record.fullName],
isActive: false,
type: record.type
}
addMCPServer(newServer)
window.message.success({ content: t('settings.mcp.addSuccess'), key: 'mcp-add-server' })
setSelectedMcpServer(newServer)
}}
/>
</Flex>
}>
<Space direction="vertical" size="small">
<Text className="selectable">{record.description}</Text>
<Text type="secondary" className="selectable">
{t('settings.mcp.npx_list.usage')}: {record.usage}
</Text>
<Link href={record.npmLink} target="_blank" rel="noopener noreferrer">
{record.npmLink}
</Link>
</Space>
</Card>
)
})}
</ResultList>
)}
</Container>
)
}
const SettingGroupCss = css`
height: 100%;
const Container = styled.div`
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 0;
gap: 8px;
`
const ResultList = styled.div`
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
width: calc(100% + 10px);
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
width: 100%;
padding-right: 4px;
overflow-y: scroll;
overflow-y: auto;
`
export default NpxSearch

View File

@@ -1,41 +1,32 @@
import { CodeOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons'
import { ArrowLeftOutlined, CodeOutlined, PlusOutlined } from '@ant-design/icons'
import { nanoid } from '@reduxjs/toolkit'
import DragableList from '@renderer/components/DragableList'
import IndicatorLight from '@renderer/components/IndicatorLight'
import { HStack, VStack } from '@renderer/components/Layout'
import ListItem from '@renderer/components/ListItem'
import Scrollbar from '@renderer/components/Scrollbar'
import { VStack } from '@renderer/components/Layout'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useMCPServers } from '@renderer/hooks/useMCPServers'
import { EventEmitter } from '@renderer/services/EventService'
import { MCPServer } from '@renderer/types'
import { Dropdown, MenuProps } from 'antd'
import { isEmpty } from 'lodash'
import { FC, useCallback, useEffect, useMemo, useState } from 'react'
import { FC, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Route, Routes, useLocation, useNavigate } from 'react-router'
import { Link } from 'react-router-dom'
import styled from 'styled-components'
import { SettingContainer } from '..'
import { SettingContainer, SettingTitle } from '..'
import InstallNpxUv from './InstallNpxUv'
import McpSettings from './McpSettings'
import NpxSearch from './NpxSearch'
const MCPSettings: FC = () => {
const { t } = useTranslation()
const { mcpServers, addMCPServer, updateMcpServers, deleteMCPServer } = useMCPServers()
const [selectedMcpServer, setSelectedMcpServer] = useState<MCPServer | null>(mcpServers[0])
const [route, setRoute] = useState<'npx-search' | 'mcp-install' | null>(null)
const { mcpServers, addMCPServer } = useMCPServers()
const [selectedMcpServer, setSelectedMcpServer] = useState<MCPServer | null>(null)
const { theme } = useTheme()
const navigate = useNavigate()
useEffect(() => {
const unsubs = [
EventEmitter.on('mcp:npx-search', () => setRoute('npx-search')),
EventEmitter.on('mcp:mcp-install', () => setRoute('mcp-install'))
]
return () => unsubs.forEach((unsub) => unsub())
}, [])
const location = useLocation()
const pathname = location.pathname
const onAddMcpServer = async () => {
const onAddMcpServer = useCallback(async () => {
const newServer = {
id: nanoid(),
name: t('settings.mcp.newServer'),
@@ -49,136 +40,228 @@ const MCPSettings: FC = () => {
addMCPServer(newServer)
window.message.success({ content: t('settings.mcp.addSuccess'), key: 'mcp-list' })
setSelectedMcpServer(newServer)
}
const onDeleteMcpServer = useCallback(
async (server: MCPServer) => {
try {
await window.api.mcp.removeServer(server)
await deleteMCPServer(server.id)
window.message.success({ content: t('settings.mcp.deleteSuccess'), key: 'mcp-list' })
} catch (error: any) {
window.message.error({
content: `${t('settings.mcp.deleteError')}: ${error.message}`,
key: 'mcp-list'
})
}
},
[deleteMCPServer, t]
)
const getMenuItems = useCallback(
(server: MCPServer) => {
const menus: MenuProps['items'] = [
{
label: t('common.delete'),
danger: true,
key: 'delete',
icon: <DeleteOutlined />,
onClick: () => onDeleteMcpServer(server)
}
]
return menus
},
[onDeleteMcpServer, t]
)
}, [addMCPServer, t])
useEffect(() => {
const _selectedMcpServer = mcpServers.find((server) => server.id === selectedMcpServer?.id)
setSelectedMcpServer(_selectedMcpServer || mcpServers[0])
}, [mcpServers, route, selectedMcpServer])
}, [mcpServers, selectedMcpServer])
const MainContent = useMemo(() => {
if (route === 'npx-search' || isEmpty(mcpServers)) {
return (
<SettingContainer theme={theme}>
<NpxSearch />
</SettingContainer>
)
}
if (route === 'mcp-install') {
return (
<SettingContainer theme={theme}>
<InstallNpxUv />
</SettingContainer>
)
}
useEffect(() => {
// Check if the selected server still exists in the updated mcpServers list
if (selectedMcpServer) {
return <McpSettings server={selectedMcpServer} />
const serverExists = mcpServers.some((server) => server.id === selectedMcpServer.id)
if (!serverExists) {
setSelectedMcpServer(null)
}
} else {
setSelectedMcpServer(null)
}
}, [mcpServers, selectedMcpServer])
return <NpxSearch />
}, [mcpServers, route, selectedMcpServer, theme])
const McpServersList = useCallback(
() => (
<GridContainer>
<GridHeader>
<SettingTitle>{t('settings.mcp.newServer')}</SettingTitle>
</GridHeader>
<ServersGrid>
<AddServerCard onClick={onAddMcpServer}>
<PlusOutlined style={{ fontSize: 24 }} />
<AddServerText>{t('settings.mcp.addServer')}</AddServerText>
</AddServerCard>
{mcpServers.map((server) => (
<ServerCard
key={server.id}
onClick={() => {
setSelectedMcpServer(server)
navigate(`/settings/mcp/server/${server.id}`)
}}>
<ServerHeader>
<ServerIcon>
<CodeOutlined />
</ServerIcon>
<ServerName>{server.name}</ServerName>
<StatusIndicator>
<IndicatorLight
size={6}
color={server.isActive ? 'green' : 'var(--color-text-3)'}
animation={server.isActive}
shadow={false}
/>
</StatusIndicator>
</ServerHeader>
<ServerDescription>
{server.description &&
server.description.substring(0, 60) + (server.description.length > 60 ? '...' : '')}
</ServerDescription>
</ServerCard>
))}
</ServersGrid>
</GridContainer>
),
[mcpServers, navigate, onAddMcpServer, t]
)
const isHome = pathname === '/settings/mcp'
return (
<Container>
<McpListContainer>
<McpList>
<ListItem
key="add"
title={t('settings.mcp.addServer')}
active={false}
onClick={onAddMcpServer}
icon={<PlusOutlined />}
titleStyle={{ fontWeight: 500 }}
style={{ width: '100%', marginTop: -2 }}
{!isHome && (
<BackButtonContainer>
<Link to="/settings/mcp">
<BackButton>
<ArrowLeftOutlined /> {t('common.back')}
</BackButton>
</Link>
</BackButtonContainer>
)}
<MainContainer>
<Routes>
<Route path="/" element={<McpServersList />} />
<Route path="server/:id" element={selectedMcpServer ? <McpSettings server={selectedMcpServer} /> : null} />
<Route
path="npx-search"
element={
<SettingContainer theme={theme}>
<NpxSearch setSelectedMcpServer={setSelectedMcpServer} />
</SettingContainer>
}
/>
<DragableList list={mcpServers} onUpdate={updateMcpServers}>
{(server: MCPServer) => (
<Dropdown menu={{ items: getMenuItems(server) }} trigger={['contextMenu']} key={server.id}>
<div>
<ListItem
key={server.id}
title={server.name}
active={selectedMcpServer?.id === server.id}
onClick={() => {
setSelectedMcpServer(server)
setRoute(null)
}}
titleStyle={{ fontWeight: 500 }}
icon={<CodeOutlined />}
rightContent={
<IndicatorLight
size={6}
color={server.isActive ? 'green' : 'var(--color-text-3)'}
animation={server.isActive}
shadow={false}
style={{ marginRight: 4 }}
/>
}
/>
</div>
</Dropdown>
)}
</DragableList>
</McpList>
</McpListContainer>
{MainContent}
<Route
path="mcp-install"
element={
<SettingContainer theme={theme}>
<InstallNpxUv />
</SettingContainer>
}
/>
</Routes>
</MainContainer>
</Container>
)
}
const Container = styled(HStack)`
const Container = styled(VStack)`
flex: 1;
`
const McpListContainer = styled(VStack)`
width: var(--settings-width);
border-right: 0.5px solid var(--color-border);
height: calc(100vh - var(--navbar-height));
`
const McpList = styled(Scrollbar)`
display: flex;
flex: 1;
flex-direction: column;
gap: 5px;
const GridContainer = styled(VStack)`
width: 100%;
padding: 12px;
.iconfont {
color: var(--color-text-2);
line-height: 16px;
height: calc(100vh - var(--navbar-height));
padding: 20px;
`
const GridHeader = styled.div`
width: 100%;
padding-bottom: 16px;
h2 {
font-size: 20px;
margin: 0;
}
`
const ServersGrid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 16px;
width: 100%;
overflow-y: auto;
padding: 2px;
`
const ServerCard = styled.div`
display: flex;
flex-direction: column;
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 16px;
cursor: pointer;
transition: all 0.2s ease;
height: 140px;
background-color: var(--color-bg-1);
&:hover {
border-color: var(--color-primary);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
`
const ServerHeader = styled.div`
display: flex;
align-items: center;
margin-bottom: 12px;
`
const ServerIcon = styled.div`
font-size: 18px;
color: var(--color-primary);
margin-right: 8px;
`
const ServerName = styled.div`
font-weight: 500;
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`
const StatusIndicator = styled.div`
margin-left: 8px;
`
const ServerDescription = styled.div`
font-size: 12px;
color: var(--color-text-2);
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
`
const AddServerCard = styled(ServerCard)`
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
border-style: dashed;
background-color: transparent;
color: var(--color-text-2);
`
const AddServerText = styled.div`
margin-top: 12px;
font-weight: 500;
`
const BackButtonContainer = styled.div`
padding: 12px 0 0 12px;
width: 100%;
background-color: var(--color-background);
`
const MainContainer = styled.div`
flex: 1;
width: 100%;
`
const BackButton = styled.div`
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--color-text-1);
cursor: pointer;
padding: 6px 12px;
border-radius: 4px;
margin-bottom: 10px;
background-color: var(--color-bg-1);
&:hover {
color: var(--color-primary);
background-color: var(--color-bg-2);
}
`

View File

@@ -0,0 +1,30 @@
import { Handle, Position } from '@xyflow/react'
import { Card, Typography } from 'antd'
import styled from 'styled-components'
interface CenterNodeProps {
data: {
label: string
}
}
const CenterNode: React.FC<CenterNodeProps> = ({ data }) => {
return (
<NodeContainer>
<Card>
<Typography.Title level={4}>{data.label}</Typography.Title>
</Card>
<Handle type="source" position={Position.Bottom} id="b" />
<Handle type="source" position={Position.Right} id="r" />
<Handle type="source" position={Position.Left} id="l" />
<Handle type="source" position={Position.Top} id="t" />
</NodeContainer>
)
}
const NodeContainer = styled.div`
width: 150px;
text-align: center;
`
export default CenterNode

View File

@@ -0,0 +1,499 @@
import { ClearOutlined, DeleteOutlined } from '@ant-design/icons'
import { TopicManager } from '@renderer/hooks/useTopic'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import store from '@renderer/store'
import { deleteShortMemory } from '@renderer/store/memory'
import { Button, Collapse, Empty, List, Modal, Pagination, Tooltip, Typography } from 'antd'
import { memo, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
// 定义话题和记忆的接口
interface TopicWithMemories {
topic: {
id: string
name: string
assistantId: string
createdAt: string
updatedAt: string
messages: any[]
}
memories: ShortMemory[]
currentPage?: number // 当前页码
}
// 短期记忆接口
interface ShortMemory {
id: string
content: string
topicId: string
createdAt: string
updatedAt?: string // 可选属性
}
// 记忆项组件的属性
interface MemoryItemProps {
memory: ShortMemory
onDelete: (id: string) => void
t: any
index: number // 添加索引属性,用于显示序号
}
// 样式组件
const LoadingContainer = styled.div`
display: flex;
justify-content: center;
align-items: center;
height: 200px;
width: 100%;
color: var(--color-text-2);
`
const StyledCollapse = styled(Collapse)`
width: 100%;
background-color: transparent;
border: none;
.ant-collapse-item {
margin-bottom: 8px;
border: 1px solid var(--color-border);
border-radius: 4px;
overflow: hidden;
}
.ant-collapse-header {
background-color: var(--color-bg-2);
padding: 8px 16px !important;
position: relative;
}
/* 确保折叠图标不会遮挡内容 */
.ant-collapse-expand-icon {
margin-right: 8px;
}
.ant-collapse-content {
border-top: 1px solid var(--color-border);
}
.ant-collapse-content-box {
padding: 4px 0 !important; /* 减少上下内边距保持左右为0 */
}
`
const CollapseHeader = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
padding-right: 24px; /* 为删除按钮留出空间 */
/* 左侧内容区域,包含话题名称和记忆数量 */
> span {
margin-right: auto;
display: flex;
align-items: center;
}
/* 删除按钮样式 */
.ant-btn {
margin-left: 8px;
}
`
const MemoryCount = styled.span`
background-color: var(--color-primary);
color: white;
border-radius: 10px;
padding: 0 8px;
font-size: 12px;
margin-left: 8px;
min-width: 24px;
text-align: center;
display: inline-block;
z-index: 1; /* 确保计数显示在最上层 */
`
const MemoryContent = styled.div`
word-break: break-word;
font-size: 14px;
line-height: 1.6;
margin-bottom: 4px;
padding: 4px 0;
`
const PaginationContainer = styled.div`
display: flex;
justify-content: center;
padding: 12px 0;
border-top: 1px solid var(--color-border);
`
const AnimatedListItem = styled(List.Item)`
transition: all 0.3s ease;
padding: 8px 24px; /* 增加左右内边距,减少上下内边距 */
margin: 4px 0; /* 减少上下外边距 */
border-bottom: 1px solid var(--color-border);
&:last-child {
border-bottom: none;
}
&.deleting {
opacity: 0;
transform: translateX(100%);
}
/* 增加内容区域的内边距 */
.ant-list-item-meta {
padding-left: 24px;
}
/* 调整内容区域的标题和描述文字间距 */
.ant-list-item-meta-title {
margin-bottom: 4px; /* 减少标题和描述之间的间距 */
}
.ant-list-item-meta-description {
padding-left: 4px;
}
`
// 记忆项组件
const MemoryItem = memo(({ memory, onDelete, t, index }: MemoryItemProps) => {
const [isDeleting, setIsDeleting] = useState(false)
const handleDelete = async () => {
setIsDeleting(true)
// 添加小延迟,让动画有时间播放
setTimeout(() => {
onDelete(memory.id)
}, 300)
}
return (
<AnimatedListItem
className={isDeleting ? 'deleting' : ''}
actions={[
<Tooltip title={t('settings.memory.delete')} key="delete">
<Button icon={<DeleteOutlined />} onClick={handleDelete} type="text" danger />
</Tooltip>
]}>
<List.Item.Meta
title={
<MemoryContent>
<strong>{index + 1}. </strong>
{memory.content}
</MemoryContent>
}
description={new Date(memory.createdAt).toLocaleString()}
/>
</AnimatedListItem>
)
})
// 主组件
const CollapsibleShortMemoryManager = () => {
const { t } = useTranslation()
const dispatch = useAppDispatch()
// 获取短期记忆
const shortMemories = useAppSelector((state) => state.memory?.shortMemories || [])
// 本地状态
const [loading, setLoading] = useState(true)
const [topicsWithMemories, setTopicsWithMemories] = useState<TopicWithMemories[]>([])
const [activeKeys, setActiveKeys] = useState<string[]>([])
// 加载所有话题和对应的短期记忆
useEffect(() => {
const loadTopicsWithMemories = async () => {
try {
setLoading(true)
// 从数据库获取所有话题
const allTopics = await TopicManager.getAllTopics()
// 获取所有助手及其话题,确保我们使用与左侧列表相同的话题名称
const assistants = store.getState().assistants?.assistants || []
const allAssistantTopics = assistants.flatMap((assistant) => assistant.topics || [])
if (allTopics && allTopics.length > 0) {
// 创建话题和记忆的映射
const topicsMemories: TopicWithMemories[] = []
for (const dbTopic of allTopics) {
// 获取该话题的短期记忆
const topicMemories = shortMemories.filter((memory) => memory.topicId === dbTopic.id)
// 只添加有短期记忆的话题
if (topicMemories.length > 0) {
// 首先尝试从助手的话题列表中找到完整的话题信息
let topicInfo = allAssistantTopics.find((topic) => topic.id === dbTopic.id)
// 如果在助手话题中找不到,则尝试从数据库获取
if (!topicInfo) {
try {
const fullTopic = await TopicManager.getTopic(dbTopic.id)
if (fullTopic) {
// 数据库中的话题可能没有name属性所以需要手动构造
// 使用默认的话题名称格式
const topicName = `话题 ${dbTopic.id.substring(0, 8)}`
topicInfo = {
id: dbTopic.id,
assistantId: '',
name: topicName,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
messages: []
}
}
} catch (error) {
console.error(`Failed to get topic name for ${dbTopic.id}:`, error)
}
}
// 如果还是找不到,使用默认名称
if (!topicInfo) {
topicInfo = {
id: dbTopic.id,
assistantId: '',
name: `话题 ${dbTopic.id.substring(0, 8)}`,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
messages: []
}
}
topicsMemories.push({
topic: topicInfo,
memories: topicMemories,
currentPage: 1 // 初始化为第一页
})
}
}
// 按更新时间排序,最新的在前
const sortedTopicsMemories = topicsMemories.sort((a, b) => {
// 使用最新记忆的时间进行排序
const aLatestMemory = a.memories.sort(
(m1, m2) => new Date(m2.createdAt).getTime() - new Date(m1.createdAt).getTime()
)[0]
const bLatestMemory = b.memories.sort(
(m1, m2) => new Date(m2.createdAt).getTime() - new Date(m1.createdAt).getTime()
)[0]
return new Date(bLatestMemory.createdAt).getTime() - new Date(aLatestMemory.createdAt).getTime()
})
setTopicsWithMemories(sortedTopicsMemories)
}
} catch (error) {
console.error('Failed to load topics with memories:', error)
} finally {
setLoading(false)
}
}
if (shortMemories.length > 0) {
loadTopicsWithMemories()
} else {
setTopicsWithMemories([])
setLoading(false)
}
}, [shortMemories.length])
// 处理折叠面板变化
const handleCollapseChange = (keys: string | string[]) => {
setActiveKeys(Array.isArray(keys) ? keys : [keys])
}
// 处理分页变化
const handlePageChange = useCallback((page: number, topicId: string) => {
setTopicsWithMemories((prev) =>
prev.map((item) => (item.topic.id === topicId ? { ...item, currentPage: page } : item))
)
}, [])
// 删除话题下的所有短期记忆
const handleDeleteTopicMemories = useCallback(
async (topicId: string) => {
// 显示确认对话框
Modal.confirm({
title: t('settings.memory.confirmDeleteAll'),
content: t('settings.memory.confirmDeleteAllContent'),
okText: t('settings.memory.delete'),
cancelText: t('settings.memory.cancel'),
onOk: async () => {
// 获取该话题的所有记忆
const state = store.getState().memory
const topicMemories = state.shortMemories.filter((memory) => memory.topicId === topicId)
const memoryIds = topicMemories.map((memory) => memory.id)
// 过滤掉要删除的记忆
const filteredShortMemories = state.shortMemories.filter((memory) => memory.topicId !== topicId)
// 更新本地状态
setTopicsWithMemories((prev) => prev.filter((item) => item.topic.id !== topicId))
// 更新 Redux store
for (const id of memoryIds) {
dispatch(deleteShortMemory(id))
}
// 保存到本地存储
try {
const currentData = await window.api.memory.loadData()
const newData = {
...currentData,
shortMemories: filteredShortMemories
}
const result = await window.api.memory.saveData(newData, true)
if (result) {
console.log(`[CollapsibleShortMemoryManager] Successfully deleted all memories for topic ${topicId}`)
} else {
console.error(`[CollapsibleShortMemoryManager] Failed to delete all memories for topic ${topicId}`)
}
} catch (error) {
console.error('[CollapsibleShortMemoryManager] Failed to delete all memories:', error)
}
}
})
},
[dispatch, t]
)
// 删除短记忆 - 直接删除无需确认
const handleDeleteMemory = useCallback(
async (id: string) => {
// 先从当前状态中获取要删除的记忆之外的所有记忆
const state = store.getState().memory
const filteredShortMemories = state.shortMemories.filter((memory) => memory.id !== id)
// 在本地更新topicsWithMemories避免触发useEffect
setTopicsWithMemories((prev) => {
return prev
.map((item) => {
// 如果该话题包含要删除的记忆,则更新该话题的记忆列表
if (item.memories.some((memory) => memory.id === id)) {
return {
...item,
memories: item.memories.filter((memory) => memory.id !== id)
}
}
return item
})
.filter((item) => item.memories.length > 0) // 移除没有记忆的话题
})
// 执行删除操作
dispatch(deleteShortMemory(id))
// 直接使用 window.api.memory.saveData 方法保存过滤后的列表
try {
// 加载当前文件数据
const currentData = await window.api.memory.loadData()
// 替换 shortMemories 数组
const newData = {
...currentData,
shortMemories: filteredShortMemories
}
// 使用 true 参数强制覆盖文件
const result = await window.api.memory.saveData(newData, true)
if (result) {
console.log(`[CollapsibleShortMemoryManager] Successfully deleted short memory with ID ${id}`)
// 使用App组件而不是静态方法避免触发重新渲染
// message.success(t('settings.memory.deleteSuccess') || '删除成功')
} else {
console.error(`[CollapsibleShortMemoryManager] Failed to delete short memory with ID ${id}`)
// message.error(t('settings.memory.deleteError') || '删除失败')
}
} catch (error) {
console.error('[CollapsibleShortMemoryManager] Failed to delete short memory:', error)
// message.error(t('settings.memory.deleteError') || '删除失败')
}
},
[dispatch]
)
return (
<div>
<Typography.Title level={4}>
{t('settings.memory.shortMemoriesByTopic') || '按话题分组的短期记忆'}
</Typography.Title>
{loading ? (
<LoadingContainer>{t('settings.memory.loading') || '加载中...'}</LoadingContainer>
) : topicsWithMemories.length > 0 ? (
<StyledCollapse
activeKey={activeKeys}
onChange={handleCollapseChange}
items={topicsWithMemories.map(({ topic, memories, currentPage }) => ({
key: topic.id,
label: (
<CollapseHeader>
<span>
{topic.name}
<MemoryCount>{memories.length}</MemoryCount>
</span>
<Tooltip title={t('settings.memory.confirmDeleteAll')}>
<Button
icon={<ClearOutlined />}
onClick={(e) => {
e.stopPropagation() // 阻止事件冒泡,避免触发折叠面板的展开/收起
handleDeleteTopicMemories(topic.id)
}}
type="text"
danger
size="small"
/>
</Tooltip>
</CollapseHeader>
),
children: (
<div>
<List
itemLayout="horizontal"
dataSource={memories.slice(
(currentPage ? currentPage - 1 : 0) * 15,
(currentPage ? currentPage - 1 : 0) * 15 + 15
)}
style={{ padding: '4px 0' }}
renderItem={(memory, index) => (
<MemoryItem
key={memory.id}
memory={memory}
onDelete={handleDeleteMemory}
t={t}
index={(currentPage ? currentPage - 1 : 0) * 15 + index}
/>
)}
/>
{memories.length > 15 && (
<PaginationContainer>
<Pagination
current={currentPage || 1}
onChange={(page) => handlePageChange(page, topic.id)}
total={memories.length}
pageSize={15}
size="small"
showSizeChanger={false}
/>
</PaginationContainer>
)}
</div>
)
}))}
/>
) : (
<Empty description={t('settings.memory.noShortMemories') || '没有短期记忆'} />
)}
</div>
)
}
export default CollapsibleShortMemoryManager

View File

@@ -0,0 +1,174 @@
import { InfoCircleOutlined } from '@ant-design/icons'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import {
clearCurrentRecommendations,
saveMemoryData,
setAutoRecommendMemories,
setContextualRecommendationEnabled,
setRecommendationThreshold
} from '@renderer/store/memory'
import { Button, InputNumber, Slider, Switch, Tooltip } from 'antd'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import { SettingDivider, SettingGroup, SettingHelpText, SettingRow, SettingRowTitle, SettingTitle } from '..'
const SliderContainer = styled.div`
display: flex;
align-items: center;
width: 100%;
max-width: 300px;
margin-right: 16px;
`
const ContextualRecommendationSettings: FC = () => {
const { t } = useTranslation()
const dispatch = useAppDispatch()
// 获取相关状态
const contextualRecommendationEnabled = useAppSelector((state) => state.memory.contextualRecommendationEnabled)
const autoRecommendMemories = useAppSelector((state) => state.memory.autoRecommendMemories)
const recommendationThreshold = useAppSelector((state) => state.memory.recommendationThreshold)
// 处理开关状态变化
const handleContextualRecommendationToggle = async (checked: boolean) => {
dispatch(setContextualRecommendationEnabled(checked))
// 保存设置
try {
await dispatch(saveMemoryData({ contextualRecommendationEnabled: checked })).unwrap()
console.log('[ContextualRecommendationSettings] Contextual recommendation enabled setting saved:', checked)
} catch (error) {
console.error(
'[ContextualRecommendationSettings] Failed to save contextual recommendation enabled setting:',
error
)
}
}
const handleAutoRecommendToggle = async (checked: boolean) => {
dispatch(setAutoRecommendMemories(checked))
// 保存设置
try {
await dispatch(saveMemoryData({ autoRecommendMemories: checked })).unwrap()
console.log('[ContextualRecommendationSettings] Auto recommend memories setting saved:', checked)
} catch (error) {
console.error('[ContextualRecommendationSettings] Failed to save auto recommend memories setting:', error)
}
}
// 处理推荐阈值变化
const handleThresholdChange = async (value: number | null) => {
if (value !== null) {
dispatch(setRecommendationThreshold(value))
// 保存设置
try {
await dispatch(saveMemoryData({ recommendationThreshold: value })).unwrap()
console.log('[ContextualRecommendationSettings] Recommendation threshold setting saved:', value)
} catch (error) {
console.error('[ContextualRecommendationSettings] Failed to save recommendation threshold setting:', error)
}
}
}
// 清除当前推荐
const handleClearRecommendations = () => {
dispatch(clearCurrentRecommendations())
}
return (
<SettingGroup>
<SettingTitle>{t('settings.memory.contextualRecommendation.title') || '上下文感知记忆推荐'}</SettingTitle>
<SettingHelpText>
{t('settings.memory.contextualRecommendation.description') ||
'根据当前对话上下文智能推荐相关记忆提高AI回复的相关性和连贯性。'}
</SettingHelpText>
<SettingRow>
<SettingRowTitle>
{t('settings.memory.contextualRecommendation.enable') || '启用上下文感知记忆推荐'}
<Tooltip
title={
t('settings.memory.contextualRecommendation.enableTip') ||
'启用后,系统将根据当前对话上下文自动推荐相关记忆'
}>
<InfoCircleOutlined style={{ marginLeft: 8 }} />
</Tooltip>
</SettingRowTitle>
<Switch checked={contextualRecommendationEnabled} onChange={handleContextualRecommendationToggle} />
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>
{t('settings.memory.contextualRecommendation.autoRecommend') || '自动推荐记忆'}
<Tooltip
title={
t('settings.memory.contextualRecommendation.autoRecommendTip') ||
'启用后,系统将定期自动分析当前对话并推荐相关记忆'
}>
<InfoCircleOutlined style={{ marginLeft: 8 }} />
</Tooltip>
</SettingRowTitle>
<Switch
checked={autoRecommendMemories}
onChange={handleAutoRecommendToggle}
disabled={!contextualRecommendationEnabled}
/>
</SettingRow>
<SettingRow>
<SettingRowTitle>
{t('settings.memory.contextualRecommendation.threshold') || '推荐阈值'}
<Tooltip
title={
t('settings.memory.contextualRecommendation.thresholdTip') || '设置记忆推荐的相似度阈值,值越高要求越严格'
}>
<InfoCircleOutlined style={{ marginLeft: 8 }} />
</Tooltip>
</SettingRowTitle>
<div style={{ display: 'flex', alignItems: 'center' }}>
<SliderContainer>
<Slider
min={0.1}
max={0.9}
step={0.05}
value={recommendationThreshold}
onChange={handleThresholdChange}
disabled={!contextualRecommendationEnabled}
style={{ flex: 1 }}
/>
</SliderContainer>
<InputNumber
min={0.1}
max={0.9}
step={0.05}
value={recommendationThreshold}
onChange={handleThresholdChange}
disabled={!contextualRecommendationEnabled}
style={{ width: 70 }}
/>
</div>
</SettingRow>
<SettingRow>
<SettingRowTitle>
{t('settings.memory.contextualRecommendation.clearRecommendations') || '清除当前推荐'}
<Tooltip
title={t('settings.memory.contextualRecommendation.clearRecommendationsTip') || '清除当前的记忆推荐列表'}>
<InfoCircleOutlined style={{ marginLeft: 8 }} />
</Tooltip>
</SettingRowTitle>
<Button onClick={handleClearRecommendations} disabled={!contextualRecommendationEnabled}>
{t('settings.memory.contextualRecommendation.clear') || '清除'}
</Button>
</SettingRow>
</SettingGroup>
)
}
export default ContextualRecommendationSettings

View File

@@ -0,0 +1,115 @@
import { InfoCircleOutlined } from '@ant-design/icons'
import SelectModelPopup from '@renderer/components/Popups/SelectModelPopup'
import { useProviders } from '@renderer/hooks/useProvider'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { saveMemoryData, setHistoricalContextAnalyzeModel } from '@renderer/store/memory'
import { setEnableHistoricalContext } from '@renderer/store/settings'
import { Button, Switch, Tooltip } from 'antd'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { SettingGroup, SettingHelpText, SettingRow, SettingRowTitle, SettingTitle } from '..'
const HistoricalContextSettings: FC = () => {
const { t } = useTranslation()
const dispatch = useAppDispatch()
const { providers } = useProviders()
// 获取相关状态
const enableHistoricalContext = useAppSelector((state) => state.settings.enableHistoricalContext)
const historicalContextAnalyzeModel = useAppSelector((state) => state.memory.historicalContextAnalyzeModel)
// 处理开关状态变化
const handleHistoricalContextToggle = (checked: boolean) => {
dispatch(setEnableHistoricalContext(checked))
}
// 处理模型选择变化
const handleModelChange = async (modelId: string) => {
dispatch(setHistoricalContextAnalyzeModel(modelId))
console.log('[HistoricalContextSettings] Historical context analyze model set:', modelId)
// 使用Redux Thunk保存到JSON文件
try {
await dispatch(saveMemoryData({ historicalContextAnalyzeModel: modelId })).unwrap()
console.log('[HistoricalContextSettings] Historical context analyze model saved to file successfully:', modelId)
} catch (error) {
console.error('[HistoricalContextSettings] Failed to save historical context analyze model to file:', error)
}
}
// 获取当前选中模型的名称
const getSelectedModelName = () => {
if (!historicalContextAnalyzeModel) return ''
// 遍历所有服务商的模型找到匹配的模型
for (const provider of Object.values(providers)) {
const model = provider.models.find((m) => m.id === historicalContextAnalyzeModel)
if (model) {
return `${model.name} | ${provider.name}`
}
}
return historicalContextAnalyzeModel
}
return (
<SettingGroup>
<SettingTitle>{t('settings.memory.historicalContext.title') || '历史对话上下文'}</SettingTitle>
<SettingHelpText>
{t('settings.memory.historicalContext.description') || '允许AI在需要时自动引用历史对话以提供更连贯的回答。'}
</SettingHelpText>
<SettingRow>
<SettingRowTitle>
{t('settings.memory.historicalContext.enable') || '启用历史对话上下文'}
<Tooltip
title={
t('settings.memory.historicalContext.enableTip') ||
'启用后AI会在需要时自动分析并引用历史对话以提供更连贯的回答'
}>
<InfoCircleOutlined style={{ marginLeft: 8 }} />
</Tooltip>
</SettingRowTitle>
<Switch checked={enableHistoricalContext} onChange={handleHistoricalContextToggle} />
</SettingRow>
<SettingRow>
<SettingRowTitle>
{t('settings.memory.analyzeModel') || '分析模型'}
<Tooltip
title={
t('settings.memory.historicalContext.analyzeModelTip') ||
'选择用于历史对话上下文分析的模型,建议选择响应较快的模型'
}>
<InfoCircleOutlined style={{ marginLeft: 8 }} />
</Tooltip>
</SettingRowTitle>
<Button
onClick={async () => {
// 找到当前选中的模型对象
let currentModel: { id: string; provider: string; name: string; group: string } | undefined
if (historicalContextAnalyzeModel) {
for (const provider of Object.values(providers)) {
const model = provider.models.find((m) => m.id === historicalContextAnalyzeModel)
if (model) {
currentModel = model
break
}
}
}
const selectedModel = await SelectModelPopup.show({ model: currentModel })
if (selectedModel) {
handleModelChange(selectedModel.id)
}
}}
style={{ width: 300 }}>
{historicalContextAnalyzeModel ? getSelectedModelName() : t('settings.memory.selectModel') || '选择模型'}
</Button>
</SettingRow>
</SettingGroup>
)
}
export default HistoricalContextSettings

View File

@@ -0,0 +1,448 @@
import {
CheckCircleOutlined,
DownOutlined,
MergeCellsOutlined,
QuestionCircleOutlined,
RightOutlined
} from '@ant-design/icons'
import { TopicManager } from '@renderer/hooks/useTopic'
import {
applyDeduplicationResult,
deduplicateAndMergeMemories,
DeduplicationResult
} from '@renderer/services/MemoryDeduplicationService'
import { useAppSelector } from '@renderer/store'
import store from '@renderer/store'
import { Topic } from '@renderer/types'
import { Button, Card, Collapse, Empty, List, Modal, Slider, Space, Spin, Tag, Typography } from 'antd'
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
// 使用items属性不再需要Panel组件
const { Title, Text, Paragraph } = Typography
interface MemoryDeduplicationPanelProps {
title?: string
description?: string
translationPrefix?: string
applyResults?: (result: DeduplicationResult) => void
isShortMemory?: boolean
}
const MemoryDeduplicationPanel: React.FC<MemoryDeduplicationPanelProps> = ({
title,
description,
translationPrefix = 'settings.memory.deduplication',
applyResults,
isShortMemory = false
}) => {
const { t } = useTranslation()
const [isExpanded, setIsExpanded] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [deduplicationResult, setDeduplicationResult] = useState<DeduplicationResult | null>(null)
const [threshold, setThreshold] = useState(0.75) // 降低默认阈值以捕获更多相似记忆
const [selectedListId, setSelectedListId] = useState<string | undefined>(undefined)
const [selectedTopicId, setSelectedTopicId] = useState<string | undefined>(undefined)
const [topicsList, setTopicsList] = useState<Topic[]>([])
const [loadingTopics, setLoadingTopics] = useState(false)
// 获取记忆列表
const memoryLists = useAppSelector((state) => state.memory?.memoryLists || [])
const memories = useAppSelector((state) =>
isShortMemory ? state.memory?.shortMemories || [] : state.memory?.memories || []
)
// 加载有短期记忆的话题
useEffect(() => {
const loadTopics = async () => {
try {
setLoadingTopics(true)
// 获取短期记忆
const shortMemories = store.getState().memory?.shortMemories || []
// 获取所有有短期记忆的话题ID
const topicIds = Array.from(new Set(shortMemories.map((memory) => memory.topicId)))
if (topicIds.length > 0) {
// 获取所有助手及其话题,确保我们使用与左侧列表相同的话题名称
const assistants = store.getState().assistants?.assistants || []
const allAssistantTopics = assistants.flatMap((assistant) => assistant.topics || [])
// 创建完整的话题列表
const fullTopics: Topic[] = []
for (const topicId of topicIds) {
// 首先尝试从助手的话题列表中找到完整的话题信息
let topicInfo = allAssistantTopics.find((topic) => topic.id === topicId)
// 如果在助手话题中找不到,则尝试从数据库获取
if (!topicInfo) {
try {
const dbTopic = await TopicManager.getTopic(topicId)
if (dbTopic) {
topicInfo = {
id: dbTopic.id,
assistantId: '',
name: `话题 ${dbTopic.id.substring(0, 8)}`,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
messages: []
}
}
} catch (error) {
console.error(`Failed to get topic name for ${topicId}:`, error)
}
}
// 如果找到了话题信息,添加到列表中
if (topicInfo) {
fullTopics.push(topicInfo)
}
}
// 按更新时间排序,最新的在前
const sortedTopics = fullTopics.sort((a, b) => {
return new Date(b.updatedAt || b.createdAt).getTime() - new Date(a.updatedAt || a.createdAt).getTime()
})
setTopicsList(sortedTopics)
}
} catch (error) {
console.error('Failed to load topics:', error)
} finally {
setLoadingTopics(false)
}
}
loadTopics()
}, [])
// 开始去重分析
const handleDeduplication = async () => {
setIsLoading(true)
try {
if (isShortMemory) {
// 短期记忆去重
const result = await deduplicateAndMergeMemories(undefined, true, selectedTopicId)
setDeduplicationResult(result)
} else {
// 长期记忆去重
const result = await deduplicateAndMergeMemories(selectedListId, false)
setDeduplicationResult(result)
}
} finally {
setIsLoading(false)
}
}
// 应用去重结果
const handleApplyResult = () => {
if (!deduplicationResult) return
Modal.confirm({
title: t(`${translationPrefix}.confirmApply`),
content: t(`${translationPrefix}.confirmApplyContent`),
onOk: async () => {
try {
if (applyResults) {
// 使用自定义的应用函数
applyResults(deduplicationResult)
} else {
// 使用默认的应用函数
await applyDeduplicationResult(deduplicationResult, true, isShortMemory)
}
setDeduplicationResult(null)
Modal.success({
title: t(`${translationPrefix}.applySuccess`),
content: t(`${translationPrefix}.applySuccessContent`)
})
} catch (error) {
console.error('[Memory Deduplication Panel] Error applying deduplication result:', error)
Modal.error({
title: t(`${translationPrefix}.applyError`) || '应用失败',
content: t(`${translationPrefix}.applyErrorContent`) || '应用去重结果时发生错误,请重试'
})
}
}
})
}
// 获取记忆内容 - 这个函数在renderItem中使用确保没有删除错误
const getMemoryContent = (index: string) => {
const memoryIndex = parseInt(index) - 1
if (memoryIndex >= 0 && memoryIndex < memories.length) {
const memory = memories[memoryIndex]
return {
content: memory.content,
category: 'category' in memory ? memory.category || '其他' : '其他'
}
}
return { content: '', category: '' }
}
// 函数 getMemories 在第38行报错未使用不是 getMemoryContent
// 将删除报错的 getMemories 函数 (实际检查代码发现没有 getMemories 函数,可能之前已删除或误报,先跳过此文件)
// 渲染结果
const renderResult = () => {
if (!deduplicationResult) return null
if (deduplicationResult.similarGroups.length === 0) {
return (
<Empty
description={t('settings.memory.deduplication.noSimilarMemories')}
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
)
}
return (
<div>
<Title level={5}>{t('settings.memory.deduplication.similarGroups')}</Title>
<Collapse
items={deduplicationResult.similarGroups.map((group) => ({
key: group.groupId,
label: (
<Space>
<Text strong>
{t('settings.memory.deduplication.group')} {group.groupId}
</Text>
<Text type="secondary">
({group.memoryIds.length} {t('settings.memory.deduplication.items')})
</Text>
{group.category && <Tag color="blue">{group.category}</Tag>}
</Space>
),
children: (
<>
<Card
title={t('settings.memory.deduplication.originalMemories')}
size="small"
style={{ marginBottom: 16 }}>
<List
size="small"
dataSource={group.memoryIds}
renderItem={(id) => {
const memory = getMemoryContent(id)
return (
<List.Item>
<List.Item.Meta
title={<Text code>{id}</Text>}
description={
<>
<Tag color="cyan">{memory.category}</Tag>
<Text>{memory.content}</Text>
</>
}
/>
</List.Item>
)
}}
/>
</Card>
<Card title={t('settings.memory.deduplication.mergedResult')} size="small">
<Paragraph>
<Tag color="green">{group.category || t('settings.memory.deduplication.other')}</Tag>
<Text strong>{group.mergedContent}</Text>
</Paragraph>
</Card>
</>
)
}))}
/>
<div style={{ marginTop: 16, textAlign: 'center' }}>
<Button type="primary" icon={<CheckCircleOutlined />} onClick={handleApplyResult}>
{t('settings.memory.deduplication.applyResults')}
</Button>
</div>
</div>
)
}
// 切换折叠状态
const toggleExpand = () => {
setIsExpanded(!isExpanded)
}
return (
<StyledCard>
<CollapsibleHeader onClick={toggleExpand}>
<HeaderContent>
<Title level={5}>{title || t(`${translationPrefix}.title`)}</Title>
{isExpanded ? <DownOutlined /> : <RightOutlined />}
</HeaderContent>
</CollapsibleHeader>
{isExpanded && (
<CollapsibleContent>
<Paragraph>{description || t(`${translationPrefix}.description`)}</Paragraph>
<ControlsContainer>
{!isShortMemory ? (
<div>
<Text>{t(`${translationPrefix}.selectList`)}</Text>
<Select
value={selectedListId || 'all'}
onChange={(e) => setSelectedListId(e.target.value === 'all' ? undefined : e.target.value)}>
<option value="all">{t(`${translationPrefix}.allLists`)}</option>
{memoryLists.map((list) => (
<option key={list.id} value={list.id}>
{list.name}
</option>
))}
</Select>
</div>
) : (
<div>
<Text>{t(`${translationPrefix}.selectTopic`) || '选择话题'}</Text>
<Select
value={selectedTopicId || 'all'}
onChange={(e) => setSelectedTopicId(e.target.value === 'all' ? undefined : e.target.value)}>
<option value="all">{t('settings.memory.allTopics') || '所有话题'}</option>
{loadingTopics ? (
<option disabled>{t('settings.memory.loading') || '加载中...'}</option>
) : topicsList.length > 0 ? (
topicsList.map((topic) => (
<option key={topic.id} value={topic.id}>
{topic.name || `话题 ${topic.id.substring(0, 8)}`}
</option>
))
) : (
<option disabled>{t('settings.memory.noTopics') || '没有话题'}</option>
)}
</Select>
</div>
)}
<div>
<Text>
{t(`${translationPrefix}.similarityThreshold`)}: {threshold}
</Text>
<Slider
min={0.5}
max={0.95}
step={0.05}
value={threshold}
onChange={setThreshold}
style={{ width: 200 }}
/>
</div>
</ControlsContainer>
<ButtonContainer>
<Button
type="primary"
icon={<MergeCellsOutlined />}
onClick={handleDeduplication}
loading={isLoading}
disabled={memories.length < 2}>
{t(`${translationPrefix}.startAnalysis`)}
</Button>
<Button
icon={<QuestionCircleOutlined />}
onClick={() => {
Modal.info({
title: t(`${translationPrefix}.helpTitle`),
content: (
<div>
<Paragraph>{t(`${translationPrefix}.helpContent1`)}</Paragraph>
<Paragraph>{t(`${translationPrefix}.helpContent2`)}</Paragraph>
<Paragraph>{t(`${translationPrefix}.helpContent3`)}</Paragraph>
</div>
)
})
}}>
{t(`${translationPrefix}.help`)}
</Button>
</ButtonContainer>
{isLoading ? (
<LoadingContainer>
<Spin size="large" />
<Text>{t(`${translationPrefix}.analyzing`)}</Text>
</LoadingContainer>
) : (
<ResultContainer>{renderResult()}</ResultContainer>
)}
</CollapsibleContent>
)}
</StyledCard>
)
}
const StyledCard = styled(Card)`
margin-bottom: 24px;
border-radius: 8px;
overflow: hidden;
`
const CollapsibleHeader = styled.div`
cursor: pointer;
padding: 12px 16px;
background-color: var(--color-background-secondary, #f5f5f5);
border-bottom: 1px solid var(--color-border, #e8e8e8);
transition: background-color 0.3s;
&:hover {
background-color: var(--color-background-hover, #e6f7ff);
}
`
const HeaderContent = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
`
const CollapsibleContent = styled.div`
padding: 16px;
`
const ControlsContainer = styled.div`
display: flex;
flex-wrap: wrap;
gap: 24px;
margin-bottom: 16px;
`
const ButtonContainer = styled.div`
display: flex;
gap: 8px;
margin-bottom: 24px;
`
const LoadingContainer = styled.div`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 0;
gap: 16px;
`
const ResultContainer = styled.div`
margin-top: 16px;
`
// ApplyButtonContainer seems unused, removing it.
// const ApplyButtonContainer = styled.div`
// margin-top: 16px;
// text-align: center;
// `
const Select = styled.select`
display: block;
width: 100%;
margin-top: 8px;
padding: 8px;
border: 1px solid var(--color-border);
border-radius: 4px;
background-color: var(--color-background);
color: var(--color-text);
`
export default MemoryDeduplicationPanel

View File

@@ -0,0 +1,319 @@
import { DeleteOutlined, EditOutlined, ExclamationCircleOutlined, PlusOutlined } from '@ant-design/icons'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import store from '@renderer/store'
import {
addMemoryList,
deleteMemoryList,
editMemoryList,
MemoryList,
saveLongTermMemoryData,
setCurrentMemoryList,
toggleMemoryListActive
} from '@renderer/store/memory'
import { Button, Empty, Input, List, Modal, Switch, Tooltip, Typography } from 'antd'
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
const { Title } = Typography
const { confirm } = Modal
interface MemoryListManagerProps {
onSelectList?: (listId: string) => void
}
const MemoryListManager: React.FC<MemoryListManagerProps> = ({ onSelectList }) => {
const { t } = useTranslation()
const dispatch = useAppDispatch()
const memoryLists = useAppSelector((state) => state.memory?.memoryLists || [])
const currentListId = useAppSelector((state) => state.memory?.currentListId)
const [isModalVisible, setIsModalVisible] = useState(false)
const [editingList, setEditingList] = useState<MemoryList | null>(null)
const [newListName, setNewListName] = useState('')
const [newListDescription, setNewListDescription] = useState('')
// 打开添加/编辑列表的模态框
const showModal = (list?: MemoryList) => {
if (list) {
setEditingList(list)
setNewListName(list.name)
setNewListDescription(list.description || '')
} else {
setEditingList(null)
setNewListName('')
setNewListDescription('')
}
setIsModalVisible(true)
}
// 处理模态框确认
const handleOk = async () => {
if (!newListName.trim()) {
return // 名称不能为空
}
if (editingList) {
// 编辑现有列表
await dispatch(
editMemoryList({
id: editingList.id,
name: newListName,
description: newListDescription
})
)
} else {
// 添加新列表
await dispatch(
addMemoryList({
name: newListName,
description: newListDescription,
isActive: false
})
)
}
// 保存到长期记忆文件
try {
const state = store.getState().memory
await dispatch(
saveLongTermMemoryData({
memoryLists: state.memoryLists,
currentListId: state.currentListId
})
).unwrap()
console.log('[MemoryListManager] Memory lists saved to file after edit')
} catch (error) {
console.error('[MemoryListManager] Failed to save memory lists after edit:', error)
}
setIsModalVisible(false)
setNewListName('')
setNewListDescription('')
setEditingList(null)
}
// 处理模态框取消
const handleCancel = () => {
setIsModalVisible(false)
setNewListName('')
setNewListDescription('')
setEditingList(null)
}
// 删除记忆列表
const handleDelete = (list: MemoryList) => {
confirm({
title: t('settings.memory.confirmDeleteList'),
icon: <ExclamationCircleOutlined />,
content: t('settings.memory.confirmDeleteListContent', { name: list.name }),
okText: t('common.delete'),
okType: 'danger',
cancelText: t('common.cancel'),
async onOk() {
dispatch(deleteMemoryList(list.id))
// 保存到长期记忆文件
try {
const state = store.getState().memory
await dispatch(
saveLongTermMemoryData({
memoryLists: state.memoryLists,
currentListId: state.currentListId
})
).unwrap()
console.log('[MemoryListManager] Memory lists saved to file after delete')
} catch (error) {
console.error('[MemoryListManager] Failed to save memory lists after delete:', error)
}
}
})
}
// 切换列表激活状态
const handleToggleActive = async (list: MemoryList, checked: boolean) => {
dispatch(toggleMemoryListActive({ id: list.id, isActive: checked }))
// 保存到长期记忆文件
try {
const state = store.getState().memory
await dispatch(
saveLongTermMemoryData({
memoryLists: state.memoryLists,
currentListId: state.currentListId
})
).unwrap()
console.log('[MemoryListManager] Memory lists saved to file after toggle active')
} catch (error) {
console.error('[MemoryListManager] Failed to save memory lists after toggle active:', error)
}
}
// 选择列表
const handleSelectList = async (listId: string) => {
dispatch(setCurrentMemoryList(listId))
if (onSelectList) {
onSelectList(listId)
}
// 保存到长期记忆文件
try {
const state = store.getState().memory
await dispatch(
saveLongTermMemoryData({
memoryLists: state.memoryLists,
currentListId: state.currentListId
})
).unwrap()
console.log('[MemoryListManager] Memory lists saved to file after select list')
} catch (error) {
console.error('[MemoryListManager] Failed to save memory lists after select list:', error)
}
}
return (
<Container>
<Header>
<Title level={4}>{t('settings.memory.memoryLists')}</Title>
<Button type="primary" icon={<PlusOutlined />} onClick={() => showModal()}>
{t('settings.memory.addList')}
</Button>
</Header>
{memoryLists.length === 0 ? (
<Empty description={t('settings.memory.noLists')} />
) : (
<List
dataSource={memoryLists}
renderItem={(list) => (
<ListItem onClick={() => handleSelectList(list.id)} $isActive={list.id === currentListId}>
<ListItemContent>
<div>
<ListItemTitle>{list.name}</ListItemTitle>
{list.description && <ListItemDescription>{list.description}</ListItemDescription>}
</div>
<ListItemActions onClick={(e) => e.stopPropagation()}>
<Tooltip title={t('settings.memory.toggleActive')}>
<Switch
checked={list.isActive}
onChange={(checked) => handleToggleActive(list, checked)}
size="small"
/>
</Tooltip>
<Tooltip title={t('common.edit')}>
<Button
icon={<EditOutlined />}
type="text"
size="small"
onClick={(e) => {
e.stopPropagation()
showModal(list)
}}
/>
</Tooltip>
<Tooltip title={t('common.delete')}>
<Button
icon={<DeleteOutlined />}
type="text"
size="small"
danger
onClick={(e) => {
e.stopPropagation()
handleDelete(list)
}}
disabled={memoryLists.length <= 1} // 至少保留一个列表
/>
</Tooltip>
</ListItemActions>
</ListItemContent>
</ListItem>
)}
/>
)}
<Modal
title={editingList ? t('settings.memory.editList') : t('settings.memory.addList')}
open={isModalVisible}
onOk={handleOk}
onCancel={handleCancel}
okButtonProps={{ disabled: !newListName.trim() }}>
<FormItem>
<Label>{t('settings.memory.listName')}</Label>
<Input
value={newListName}
onChange={(e) => setNewListName(e.target.value)}
placeholder={t('settings.memory.listNamePlaceholder')}
maxLength={50}
/>
</FormItem>
<FormItem>
<Label>{t('settings.memory.listDescription')}</Label>
<Input.TextArea
value={newListDescription}
onChange={(e) => setNewListDescription(e.target.value)}
placeholder={t('settings.memory.listDescriptionPlaceholder')}
maxLength={200}
rows={3}
/>
</FormItem>
</Modal>
</Container>
)
}
const Container = styled.div`
margin-bottom: 20px;
`
const Header = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
`
const ListItem = styled.div<{ $isActive: boolean }>`
padding: 12px;
border-radius: 4px;
cursor: pointer;
background-color: ${(props) => (props.$isActive ? 'var(--color-bg-2)' : 'transparent')};
border: 1px solid ${(props) => (props.$isActive ? 'var(--color-primary)' : 'var(--color-border)')};
margin-bottom: 8px;
&:hover {
background-color: var(--color-bg-2);
}
`
const ListItemContent = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
`
const ListItemTitle = styled.div`
font-weight: 500;
margin-bottom: 4px;
`
const ListItemDescription = styled.div`
font-size: 12px;
color: var(--color-text-3);
`
const ListItemActions = styled.div`
display: flex;
gap: 8px;
align-items: center;
`
const FormItem = styled.div`
margin-bottom: 16px;
`
const Label = styled.div`
margin-bottom: 8px;
font-weight: 500;
`
export default MemoryListManager

View File

@@ -0,0 +1,220 @@
import { Memory } from '@renderer/store/memory'
import { applyNodeChanges, Background, Controls, MiniMap, ReactFlow, ReactFlowProvider } from '@xyflow/react'
import { Edge, Node, NodeTypes } from '@xyflow/react'
import { Empty } from 'antd'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import CenterNode from './CenterNode'
import MemoryNode from './MemoryNode'
interface MemoryMindMapProps {
memories: Memory[]
onEditMemory: (id: string) => void
onDeleteMemory: (id: string) => void
}
const MemoryMindMap: React.FC<MemoryMindMapProps> = ({ memories, onEditMemory, onDeleteMemory }) => {
const { t } = useTranslation()
const [nodes, setNodes] = useState<Node[]>([])
const [edges, setEdges] = useState<Edge[]>([])
// 处理节点拖动事件
const onNodesChange = useCallback((changes) => {
setNodes((nds) => {
// 中心节点不允许拖动
const filteredChanges = changes.filter((change) => {
if (change.type === 'position' && change.id === 'center') {
return false
}
return true
})
return applyNodeChanges(filteredChanges, nds)
})
}, [])
// 定义节点类型
const nodeTypes = useMemo<NodeTypes>(
() => ({
memoryNode: MemoryNode,
centerNode: CenterNode
}),
[]
)
// 转换记忆为节点和边
useMemo(() => {
if (memories.length === 0) {
setNodes([])
setEdges([])
return
}
// 创建中心节点
const centerNode: Node = {
id: 'center',
type: 'centerNode',
position: { x: 0, y: 0 },
data: { label: t('settings.memory.centerNodeLabel') },
draggable: false // 中心节点不允许拖动
}
// 计算合适的半径,确保节点不会太拥挤
const calculateRadius = () => {
const baseRadius = 300
if (memories.length <= 4) return baseRadius
if (memories.length <= 8) return baseRadius + 50
return baseRadius + 100
}
// 按分类组织记忆
const categorizedMemories: Record<string, Memory[]> = {}
// 将记忆分组
memories.forEach((memory) => {
const category = memory.category || t('settings.memory.uncategorized')
if (!categorizedMemories[category]) {
categorizedMemories[category] = []
}
categorizedMemories[category].push(memory)
})
// 创建记忆节点和边
const memoryNodes: Node[] = []
let categoryIndex = 0
const categories = Object.keys(categorizedMemories)
// 为每个分类创建节点
categories.forEach((category) => {
const categoryMemories = categorizedMemories[category]
const categoryAngle = (categoryIndex / categories.length) * 2 * Math.PI
// const categoryRadius = calculateRadius() * 0.5 // 分类节点距离中心较近
// 分类内的记忆节点
categoryMemories.forEach((memory, memIndex) => {
// 计算节点位置(围绕分类的圆形布局)
const memAngle = categoryAngle + ((memIndex / categoryMemories.length - 0.5) * Math.PI) / 2
const memRadius = calculateRadius()
const x = Math.cos(memAngle) * memRadius
const y = Math.sin(memAngle) * memRadius
memoryNodes.push({
id: memory.id,
type: 'memoryNode',
position: { x, y },
data: {
memory,
onEdit: onEditMemory,
onDelete: onDeleteMemory
},
draggable: true
})
})
categoryIndex++
})
// 创建从中心到每个记忆的边
const newEdges: Edge[] = memories.map((memory, index) => {
// 根据节点位置决定使用哪个连接点
const angle = (index / memories.length) * 2 * Math.PI
let sourceHandle = 'b' // 默认使用底部连接点
if (angle > Math.PI * 0.25 && angle < Math.PI * 0.75) {
sourceHandle = 't' // 上部
} else if (angle >= Math.PI * 0.75 && angle < Math.PI * 1.25) {
sourceHandle = 'r' // 右侧
} else if (angle >= Math.PI * 1.25 && angle < Math.PI * 1.75) {
sourceHandle = 'b' // 底部
} else {
sourceHandle = 'l' // 左侧
}
return {
id: `center-${memory.id}`,
source: 'center',
sourceHandle,
target: memory.id,
type: 'smoothstep',
animated: true
}
})
setNodes([centerNode, ...memoryNodes])
setEdges(newEdges)
}, [memories, onEditMemory, onDeleteMemory, t])
if (memories.length === 0) {
return <Empty description={t('settings.memory.noMemories')} />
}
return (
<Container>
<ReactFlowProvider>
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
onNodesChange={onNodesChange}
fitView
minZoom={0.5}
maxZoom={2}
defaultViewport={{ x: 0, y: 0, zoom: 1 }}
defaultEdgeOptions={{
animated: true
}}
nodesDraggable={true}
nodesConnectable={false}
elementsSelectable={true}>
<Controls position="bottom-left" />
<MiniMap
nodeColor={(node) => {
return node.id === 'center' ? '#1890ff' : '#91d5ff'
}}
/>
<Background color="#aaa" gap={16} />
</ReactFlow>
</ReactFlowProvider>
</Container>
)
}
const Container = styled.div`
width: 100%;
height: 100%;
border: 1px solid var(--color-border);
border-radius: 4px;
position: relative;
/* 只增强选中的连接线样式 */
.react-flow__edge.selected {
.react-flow__edge-path {
stroke: #f5222d !important;
stroke-width: 4px !important;
}
}
/* 正常连接线样式 */
.react-flow__edge:not(.selected) {
.react-flow__edge-path {
stroke: #1890ff;
stroke-width: 1.5px;
stroke-dasharray: none;
}
}
/* 鼠标悬停在节点上时的样式 */
.react-flow__node:hover {
cursor: move;
}
/* 控制按钮样式 */
.react-flow__controls {
bottom: 10px;
left: 10px;
top: auto;
}
`
export default MemoryMindMap

View File

@@ -0,0 +1,65 @@
import { DeleteOutlined, EditOutlined, TagOutlined } from '@ant-design/icons'
import { Memory } from '@renderer/store/memory'
import { Handle, Position } from '@xyflow/react'
import { Button, Card, Tag, Tooltip, Typography } from 'antd'
import styled from 'styled-components'
interface MemoryNodeProps {
data: {
memory: Memory
onEdit: (id: string) => void
onDelete: (id: string) => void
}
}
const MemoryNode: React.FC<MemoryNodeProps> = ({ data }) => {
const { memory, onEdit, onDelete } = data
return (
<NodeContainer>
<Handle type="target" position={Position.Top} />
<Card
size="small"
title={
<div>
{memory.category && (
<Tag color="blue" icon={<TagOutlined />} style={{ marginBottom: 4 }}>
{memory.category}
</Tag>
)}
<Typography.Text ellipsis style={{ width: 180, display: 'block' }}>
{memory.content}
</Typography.Text>
</div>
}
extra={
<div>
<Tooltip title="编辑">
<Button icon={<EditOutlined />} type="text" size="small" onClick={() => onEdit(memory.id)} />
</Tooltip>
<Tooltip title="删除">
<Button icon={<DeleteOutlined />} type="text" danger size="small" onClick={() => onDelete(memory.id)} />
</Tooltip>
</div>
}>
<MemoryMeta>
<span>{new Date(memory.createdAt).toLocaleString()}</span>
{memory.source && <span>{memory.source}</span>}
</MemoryMeta>
</Card>
</NodeContainer>
)
}
const NodeContainer = styled.div`
width: 220px;
`
const MemoryMeta = styled.div`
display: flex;
flex-direction: column;
font-size: 12px;
color: var(--color-text-secondary);
`
export default MemoryNode

View File

@@ -0,0 +1,189 @@
import { InfoCircleOutlined } from '@ant-design/icons'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import {
saveMemoryData,
setDecayEnabled,
setDecayRate,
setFreshnessEnabled,
setPriorityManagementEnabled,
updateMemoryPriorities
} from '@renderer/store/memory'
import { Button, InputNumber, Slider, Switch, Tooltip } from 'antd'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import { SettingDivider, SettingGroup, SettingHelpText, SettingRow, SettingRowTitle, SettingTitle } from '..'
const SliderContainer = styled.div`
display: flex;
align-items: center;
width: 100%;
max-width: 300px;
margin-right: 16px;
`
const PriorityManagementSettings: FC = () => {
const { t } = useTranslation()
const dispatch = useAppDispatch()
// 获取相关状态
const priorityManagementEnabled = useAppSelector((state) => state.memory.priorityManagementEnabled)
const decayEnabled = useAppSelector((state) => state.memory.decayEnabled)
const freshnessEnabled = useAppSelector((state) => state.memory.freshnessEnabled)
const decayRate = useAppSelector((state) => state.memory.decayRate)
// 处理开关状态变化
const handlePriorityManagementToggle = async (checked: boolean) => {
dispatch(setPriorityManagementEnabled(checked))
// 保存设置
try {
await dispatch(saveMemoryData({ priorityManagementEnabled: checked })).unwrap()
console.log('[PriorityManagementSettings] Priority management enabled setting saved:', checked)
} catch (error) {
console.error('[PriorityManagementSettings] Failed to save priority management enabled setting:', error)
}
}
const handleDecayToggle = async (checked: boolean) => {
dispatch(setDecayEnabled(checked))
// 保存设置
try {
await dispatch(saveMemoryData({ decayEnabled: checked })).unwrap()
console.log('[PriorityManagementSettings] Decay enabled setting saved:', checked)
} catch (error) {
console.error('[PriorityManagementSettings] Failed to save decay enabled setting:', error)
}
}
const handleFreshnessToggle = async (checked: boolean) => {
dispatch(setFreshnessEnabled(checked))
// 保存设置
try {
await dispatch(saveMemoryData({ freshnessEnabled: checked })).unwrap()
console.log('[PriorityManagementSettings] Freshness enabled setting saved:', checked)
} catch (error) {
console.error('[PriorityManagementSettings] Failed to save freshness enabled setting:', error)
}
}
// 处理衰减率变化
const handleDecayRateChange = async (value: number | null) => {
if (value !== null) {
dispatch(setDecayRate(value))
// 保存设置
try {
await dispatch(saveMemoryData({ decayRate: value })).unwrap()
console.log('[PriorityManagementSettings] Decay rate setting saved:', value)
} catch (error) {
console.error('[PriorityManagementSettings] Failed to save decay rate setting:', error)
}
}
}
// 手动更新记忆优先级
const handleUpdatePriorities = () => {
dispatch(updateMemoryPriorities())
}
return (
<SettingGroup>
<SettingTitle>{t('settings.memory.priorityManagement.title') || '智能优先级与时效性管理'}</SettingTitle>
<SettingHelpText>
{t('settings.memory.priorityManagement.description') ||
'智能管理记忆的优先级、衰减和鲜度,确保最重要和最相关的记忆优先显示。'}
</SettingHelpText>
<SettingRow>
<SettingRowTitle>
{t('settings.memory.priorityManagement.enable') || '启用智能优先级管理'}
<Tooltip
title={
t('settings.memory.priorityManagement.enableTip') ||
'启用后,系统将根据重要性、访问频率和时间因素自动排序记忆'
}>
<InfoCircleOutlined style={{ marginLeft: 8 }} />
</Tooltip>
</SettingRowTitle>
<Switch checked={priorityManagementEnabled} onChange={handlePriorityManagementToggle} />
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>
{t('settings.memory.priorityManagement.decay') || '记忆衰减'}
<Tooltip
title={t('settings.memory.priorityManagement.decayTip') || '随着时间推移,未访问的记忆重要性会逐渐降低'}>
<InfoCircleOutlined style={{ marginLeft: 8 }} />
</Tooltip>
</SettingRowTitle>
<Switch checked={decayEnabled} onChange={handleDecayToggle} disabled={!priorityManagementEnabled} />
</SettingRow>
<SettingRow>
<SettingRowTitle>
{t('settings.memory.priorityManagement.decayRate') || '衰减速率'}
<Tooltip
title={t('settings.memory.priorityManagement.decayRateTip') || '值越大记忆衰减越快。0.05表示每天衰减5%'}>
<InfoCircleOutlined style={{ marginLeft: 8 }} />
</Tooltip>
</SettingRowTitle>
<div style={{ display: 'flex', alignItems: 'center' }}>
<SliderContainer>
<Slider
min={0.01}
max={0.2}
step={0.01}
value={decayRate}
onChange={handleDecayRateChange}
disabled={!priorityManagementEnabled || !decayEnabled}
style={{ flex: 1 }}
/>
</SliderContainer>
<InputNumber
min={0.01}
max={0.2}
step={0.01}
value={decayRate}
onChange={handleDecayRateChange}
disabled={!priorityManagementEnabled || !decayEnabled}
style={{ width: 70 }}
/>
</div>
</SettingRow>
<SettingRow>
<SettingRowTitle>
{t('settings.memory.priorityManagement.freshness') || '记忆鲜度'}
<Tooltip
title={
t('settings.memory.priorityManagement.freshnessTip') ||
'考虑记忆的创建时间和最后访问时间,优先显示较新的记忆'
}>
<InfoCircleOutlined style={{ marginLeft: 8 }} />
</Tooltip>
</SettingRowTitle>
<Switch checked={freshnessEnabled} onChange={handleFreshnessToggle} disabled={!priorityManagementEnabled} />
</SettingRow>
<SettingRow>
<SettingRowTitle>
{t('settings.memory.priorityManagement.updateNow') || '立即更新优先级'}
<Tooltip title={t('settings.memory.priorityManagement.updateNowTip') || '手动更新所有记忆的优先级和鲜度评分'}>
<InfoCircleOutlined style={{ marginLeft: 8 }} />
</Tooltip>
</SettingRowTitle>
<Button onClick={handleUpdatePriorities} disabled={!priorityManagementEnabled}>
{t('settings.memory.priorityManagement.update') || '更新'}
</Button>
</SettingRow>
</SettingGroup>
)
}
export default PriorityManagementSettings

View File

@@ -0,0 +1,145 @@
import { DeleteOutlined } from '@ant-design/icons'
import { addShortMemoryItem } from '@renderer/services/MemoryService'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import store from '@renderer/store'
import { deleteShortMemory, setShortMemoryActive } from '@renderer/store/memory'
import { Button, Empty, Input, List, Switch, Tooltip, Typography } from 'antd'
import _ from 'lodash'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
const { Title } = Typography
// 不再需要确认对话框
const ShortMemoryManager = () => {
const { t } = useTranslation()
const dispatch = useAppDispatch()
// 获取当前话题ID
const currentTopicId = useAppSelector((state) => state.messages?.currentTopic?.id)
// 获取短记忆状态
const shortMemoryActive = useAppSelector((state) => state.memory?.shortMemoryActive || false)
const shortMemories = useAppSelector((state) => {
const allShortMemories = state.memory?.shortMemories || []
// 只显示当前话题的短记忆
return currentTopicId ? allShortMemories.filter((memory) => memory.topicId === currentTopicId) : []
})
// 添加短记忆的状态
const [newMemoryContent, setNewMemoryContent] = useState('')
// 切换短记忆功能激活状态
const handleToggleActive = (checked: boolean) => {
dispatch(setShortMemoryActive(checked))
}
// 添加新的短记忆 - 使用防抖减少频繁更新
const handleAddMemory = useCallback(
_.debounce(() => {
if (newMemoryContent.trim() && currentTopicId) {
addShortMemoryItem(newMemoryContent.trim(), currentTopicId)
setNewMemoryContent('') // 清空输入框
}
}, 300),
[newMemoryContent, currentTopicId]
)
// 删除短记忆 - 直接删除无需确认,使用节流避免频繁删除操作
const handleDeleteMemory = useCallback(
_.throttle(async (id: string) => {
// 先从当前状态中获取要删除的记忆之外的所有记忆
const state = store.getState().memory
const filteredShortMemories = state.shortMemories.filter((memory) => memory.id !== id)
// 执行删除操作
dispatch(deleteShortMemory(id))
// 直接使用 window.api.memory.saveData 方法保存过滤后的列表
try {
// 加载当前文件数据
const currentData = await window.api.memory.loadData()
// 替换 shortMemories 数组
const newData = {
...currentData,
shortMemories: filteredShortMemories
}
// 使用 true 参数强制覆盖文件
const result = await window.api.memory.saveData(newData, true)
if (result) {
console.log(`[ShortMemoryManager] Successfully deleted short memory with ID ${id}`)
// 移除消息提示,避免触发界面重新渲染
} else {
console.error(`[ShortMemoryManager] Failed to delete short memory with ID ${id}`)
}
} catch (error) {
console.error('[ShortMemoryManager] Failed to delete short memory:', error)
}
}, 500),
[dispatch]
)
return (
<div className="short-memory-manager">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<Title level={4}>{t('settings.memory.shortMemory')}</Title>
<Tooltip title={t('settings.memory.toggleShortMemoryActive')}>
<Switch checked={shortMemoryActive} onChange={handleToggleActive} />
</Tooltip>
</div>
<div style={{ marginBottom: 16 }}>
<Input.TextArea
value={newMemoryContent}
onChange={(e) => setNewMemoryContent(e.target.value)}
placeholder={t('settings.memory.addShortMemoryPlaceholder')}
autoSize={{ minRows: 2, maxRows: 4 }}
disabled={!shortMemoryActive || !currentTopicId}
/>
<Button
type="primary"
onClick={() => handleAddMemory()}
style={{ marginTop: 8 }}
disabled={!shortMemoryActive || !newMemoryContent.trim() || !currentTopicId}>
{t('settings.memory.addShortMemory')}
</Button>
</div>
<div className="short-memories-list">
{shortMemories.length > 0 ? (
<List
itemLayout="horizontal"
dataSource={shortMemories}
renderItem={(memory) => (
<List.Item
actions={[
<Tooltip title={t('settings.memory.delete')} key="delete">
<Button
icon={<DeleteOutlined />}
onClick={() => handleDeleteMemory(memory.id)}
type="text"
danger
/>
</Tooltip>
]}>
<List.Item.Meta
title={<div style={{ wordBreak: 'break-word' }}>{memory.content}</div>}
description={new Date(memory.createdAt).toLocaleString()}
/>
</List.Item>
)}
/>
) : (
<Empty
description={!currentTopicId ? t('settings.memory.noCurrentTopic') : t('settings.memory.noShortMemories')}
/>
)}
</div>
</div>
)
}
export default ShortMemoryManager

File diff suppressed because it is too large Load Diff

View File

@@ -199,7 +199,8 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
return (
<CustomCollapse
key={i}
defaultActiveKey={i >= 5 ? [] : ['1']}
defaultActiveKey={['1']}
styles={{ body: { padding: '0 10px' } }}
label={
<Flex align="center" gap={10}>
<span style={{ fontWeight: 600 }}>{group}</span>
@@ -233,13 +234,15 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
/>
</Tooltip>
}>
<FlexColumn>
<FlexColumn style={{ margin: '10px 0' }}>
{modelGroups[group].map((model) => (
<FileItem
style={{
backgroundColor: isModelInProvider(provider, model.id)
? 'rgba(0, 126, 0, 0.06)'
: 'rgba(255, 255, 255, 0.04)'
: 'rgba(255, 255, 255, 0.04)',
border: 'none',
boxShadow: 'none'
}}
key={model.id}
fileInfo={{

View File

@@ -2,6 +2,7 @@ import {
AppstoreOutlined,
CloudOutlined,
CodeOutlined,
ExperimentOutlined,
GlobalOutlined,
InfoCircleOutlined,
LayoutOutlined,
@@ -12,7 +13,6 @@ import {
ThunderboltOutlined
} from '@ant-design/icons'
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
import { isLocalAi } from '@renderer/config/env'
import { useSidebarIconShow } from '@renderer/hooks/useSidebarIcon'
import ModelSettings from '@renderer/pages/settings/ModelSettings/ModelSettings'
// 导入useAppSelector
@@ -27,6 +27,7 @@ import DisplaySettings from './DisplaySettings/DisplaySettings'
import GeneralSettings from './GeneralSettings'
import MCPSettings from './MCPSettings'
import { McpSettingsNavbar } from './MCPSettings/McpSettingsNavbar'
import MemorySettings from './MemorySettings'
import MiniAppSettings from './MiniappSettings/MiniAppSettings'
import ProvidersList from './ProviderSettings'
import QuickAssistantSettings from './QuickAssistantSettings'
@@ -46,26 +47,22 @@ const SettingsPage: FC = () => {
<Container>
<Navbar>
<NavbarCenter style={{ borderRight: 'none' }}>{t('settings.title')}</NavbarCenter>
{pathname === '/settings/mcp' && <McpSettingsNavbar />}
{pathname.includes('/settings/mcp') && <McpSettingsNavbar />}
</Navbar>
<ContentContainer id="content-container">
<SettingMenus>
{!isLocalAi && (
<>
<MenuItemLink to="/settings/provider">
<MenuItem className={isRoute('/settings/provider')}>
<CloudOutlined />
{t('settings.provider.title')}
</MenuItem>
</MenuItemLink>
<MenuItemLink to="/settings/model">
<MenuItem className={isRoute('/settings/model')}>
<i className="iconfont icon-ai-model" />
{t('settings.model')}
</MenuItem>
</MenuItemLink>
</>
)}
<MenuItemLink to="/settings/provider">
<MenuItem className={isRoute('/settings/provider')}>
<CloudOutlined />
{t('settings.provider.title')}
</MenuItem>
</MenuItemLink>
<MenuItemLink to="/settings/model">
<MenuItem className={isRoute('/settings/model')}>
<i className="iconfont icon-ai-model" />
{t('settings.model')}
</MenuItem>
</MenuItemLink>
<MenuItemLink to="/settings/web-search">
<MenuItem className={isRoute('/settings/web-search')}>
<GlobalOutlined />
@@ -78,6 +75,12 @@ const SettingsPage: FC = () => {
{t('settings.mcp.title')}
</MenuItem>
</MenuItemLink>
<MenuItemLink to="/settings/memory">
<MenuItem className={isRoute('/settings/memory')}>
<ExperimentOutlined />
{t('settings.memory.title')}
</MenuItem>
</MenuItemLink>
<MenuItemLink to="/settings/general">
<MenuItem className={isRoute('/settings/general')}>
<SettingOutlined />
@@ -134,13 +137,14 @@ const SettingsPage: FC = () => {
<Route path="provider" element={<ProvidersList />} />
<Route path="model" element={<ModelSettings />} />
<Route path="web-search" element={<WebSearchSettings />} />
<Route path="mcp" element={<MCPSettings />} />
<Route path="general/*" element={<GeneralSettings />} />
<Route path="mcp/*" element={<MCPSettings />} />
<Route path="memory" element={<MemorySettings />} />
<Route path="general" element={<GeneralSettings />} />
<Route path="display" element={<DisplaySettings />} />
{showMiniAppSettings && <Route path="miniapps" element={<MiniAppSettings />} />}
<Route path="shortcut" element={<ShortcutSettings />} />
<Route path="quickAssistant" element={<QuickAssistantSettings />} />
<Route path="data/*" element={<DataSettings />} />
<Route path="data" element={<DataSettings />} />
<Route path="about" element={<AboutSettings />} />
<Route path="quickPhrase" element={<QuickPhraseSettings />} />
</Routes>
@@ -160,6 +164,8 @@ const ContentContainer = styled.div`
display: flex;
flex: 1;
flex-direction: row;
height: calc(100vh - var(--navbar-height)); /* 设置高度为视口高度减去导航栏高度 */
overflow: hidden; /* 防止内容溢出 */
`
const SettingMenus = styled.ul`
@@ -169,6 +175,26 @@ const SettingMenus = styled.ul`
border-right: 0.5px solid var(--color-border);
padding: 10px;
user-select: none;
overflow-y: auto; /* 允许菜单滚动 */
/* 添加滚动条样式 */
&::-webkit-scrollbar {
width: 6px;
height: 6px;
}
&::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: 3px;
}
&::-webkit-scrollbar-thumb:hover {
background: var(--color-primary);
}
&::-webkit-scrollbar-track {
background: transparent;
}
`
const MenuItemLink = styled(Link)`
@@ -213,6 +239,26 @@ const SettingContent = styled.div`
height: 100%;
flex: 1;
border-right: 0.5px solid var(--color-border);
overflow-y: auto; /* 添加滚动属性,允许内容滚动 */
/* 添加滚动条样式 */
&::-webkit-scrollbar {
width: 8px;
height: 8px;
}
&::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: 4px;
}
&::-webkit-scrollbar-thumb:hover {
background: var(--color-primary);
}
&::-webkit-scrollbar-track {
background: transparent;
}
`
export default SettingsPage

View File

@@ -7,15 +7,58 @@ export const SettingContainer = styled.div<{ theme?: ThemeMode }>`
display: flex;
flex-direction: column;
flex: 1;
height: calc(100vh - var(--navbar-height));
min-height: calc(100vh - var(--navbar-height));
height: auto;
padding: 20px;
padding-top: 15px;
overflow-y: scroll;
padding-bottom: 75px;
overflow-y: auto; /* 改为auto只在需要时显示滚动条 */
font-family: Ubuntu;
background: ${(props) => (props.theme === 'dark' ? 'transparent' : 'var(--color-background-soft)')};
/* 添加滚动指示器 */
&::after {
content: '';
position: fixed;
bottom: 20px;
right: 20px;
width: 40px;
height: 40px;
border-radius: 50%;
background-color: var(--color-primary);
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease;
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white"><path d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z"/></svg>');
background-repeat: no-repeat;
background-position: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
&.scrollable::after {
opacity: 0.7;
}
&::-webkit-scrollbar {
display: none;
width: 10px;
height: 10px;
}
&::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: 5px;
border: 2px solid transparent;
background-clip: content-box;
}
&::-webkit-scrollbar-thumb:hover {
background: var(--color-primary);
border: 2px solid transparent;
background-clip: content-box;
}
&::-webkit-scrollbar-track {
background: transparent;
}
`

View File

@@ -1,10 +1,5 @@
import Anthropic from '@anthropic-ai/sdk'
import {
MessageCreateParamsNonStreaming,
MessageParam,
ToolResultBlockParam,
ToolUseBlock
} from '@anthropic-ai/sdk/resources'
import { MessageCreateParamsNonStreaming, MessageParam } from '@anthropic-ai/sdk/resources'
import { DEFAULT_MAX_TOKENS } from '@renderer/config/constant'
import { isReasoningModel } from '@renderer/config/models'
import { getStoreSetting } from '@renderer/hooks/useSettings'
@@ -15,15 +10,13 @@ import {
filterEmptyMessages,
filterUserRoleStartMessages
} from '@renderer/services/MessagesService'
import store from '@renderer/store'
import { getActiveServers } from '@renderer/store/mcp'
import { Assistant, FileTypes, MCPToolResponse, Message, Model, Provider, Suggestion } from '@renderer/types'
import { removeSpecialCharactersForTopicName } from '@renderer/utils'
import {
anthropicToolUseToMcpTool,
callMCPTool,
mcpToolsToAnthropicTools,
upsertMCPToolResponse
} from '@renderer/utils/mcp-tools'
import { first, flatten, isEmpty, sum, takeRight } from 'lodash'
import { parseAndCallTools } from '@renderer/utils/mcp-tools'
import { buildSystemPrompt } from '@renderer/utils/prompt'
import { first, flatten, sum, takeRight } from 'lodash'
import OpenAI from 'openai'
import { CompletionsParams } from '.'
@@ -182,16 +175,21 @@ export default class AnthropicProvider extends BaseProvider {
const userMessages = flatten(userMessagesParams)
const lastUserMessage = _messages.findLast((m) => m.role === 'user')
const tools = mcpTools ? mcpToolsToAnthropicTools(mcpTools) : undefined
// const tools = mcpTools ? mcpToolsToAnthropicTools(mcpTools) : undefined
let systemPrompt = assistant.prompt
if (mcpTools && mcpTools.length > 0) {
systemPrompt = await buildSystemPrompt(systemPrompt, mcpTools, getActiveServers(store.getState()))
}
const body: MessageCreateParamsNonStreaming = {
model: model.id,
messages: userMessages,
tools: isEmpty(tools) ? undefined : tools,
// tools: isEmpty(tools) ? undefined : tools,
max_tokens: maxTokens || DEFAULT_MAX_TOKENS,
temperature: this.getTemperature(assistant, model),
top_p: this.getTopP(assistant, model),
system: assistant.prompt,
system: systemPrompt,
// @ts-ignore thinking
thinking: this.getReasoningEffort(assistant, model),
...this.getCustomParameters(assistant)
@@ -239,7 +237,6 @@ export default class AnthropicProvider extends BaseProvider {
const processStream = (body: MessageCreateParamsNonStreaming, idx: number) => {
return new Promise<void>((resolve, reject) => {
const toolCalls: ToolUseBlock[] = []
let hasThinkingContent = false
this.sdk.messages
.stream({ ...body, stream: true }, { signal })
@@ -292,30 +289,11 @@ export default class AnthropicProvider extends BaseProvider {
}
})
})
.on('contentBlock', (content) => {
if (content.type == 'tool_use') {
toolCalls.push(content)
}
})
.on('finalMessage', async (message) => {
if (toolCalls.length > 0) {
const toolCallResults: ToolResultBlockParam[] = []
for (const toolCall of toolCalls) {
const mcpTool = anthropicToolUseToMcpTool(mcpTools, toolCall)
if (mcpTool) {
upsertMCPToolResponse(toolResponses, { tool: mcpTool, status: 'invoking', id: toolCall.id }, onChunk)
const resp = await callMCPTool(mcpTool)
toolCallResults.push({ type: 'tool_result', tool_use_id: toolCall.id, content: resp.content })
upsertMCPToolResponse(
toolResponses,
{ tool: mcpTool, status: 'done', response: resp, id: toolCall.id },
onChunk
)
}
}
if (toolCallResults.length > 0) {
const content = message.content[0]
if (content && content.type === 'text') {
const toolResults = await parseAndCallTools(content.text, toolResponses, onChunk, idx, mcpTools)
if (toolResults.length > 0) {
userMessages.push({
role: message.role,
content: message.content
@@ -323,12 +301,10 @@ export default class AnthropicProvider extends BaseProvider {
userMessages.push({
role: 'user',
content: toolCallResults
content: toolResults.join('\n')
})
const newBody = body
body.messages = userMessages
newBody.messages = userMessages
await processStream(newBody, idx + 1)
}
}
@@ -499,14 +475,42 @@ export default class AnthropicProvider extends BaseProvider {
* Generate text
* @param prompt - The prompt
* @param content - The content
* @param modelId - Optional model ID to use
* @returns The generated text
*/
public async generateText({ prompt, content }: { prompt: string; content: string }): Promise<string> {
const model = getDefaultModel()
public async generateText({
prompt,
content,
modelId
}: {
prompt: string
content: string
modelId?: string
}): Promise<string> {
// 使用指定的模型或默认模型
const model = modelId
? store
.getState()
.llm.providers.flatMap((provider) => provider.models)
.find((m) => m.id === modelId)
: getDefaultModel()
if (!model) {
console.error(`Model ${modelId} not found, using default model`)
return ''
}
// 应用记忆功能到系统提示词
const { applyMemoriesToPrompt } = await import('@renderer/services/MemoryService')
const enhancedPrompt = await applyMemoriesToPrompt(prompt)
console.log(
'[AnthropicProvider] Applied memories to prompt, length difference:',
enhancedPrompt.length - prompt.length
)
const message = await this.sdk.messages.create({
model: model.id,
system: prompt,
system: enhancedPrompt,
stream: false,
max_tokens: 4096,
messages: [

View File

@@ -37,7 +37,15 @@ export default abstract class BaseProvider {
abstract summaries(messages: Message[], assistant: Assistant): Promise<string>
abstract summaryForSearch(messages: Message[], assistant: Assistant): Promise<string | null>
abstract suggestions(messages: Message[], assistant: Assistant): Promise<Suggestion[]>
abstract generateText({ prompt, content }: { prompt: string; content: string }): Promise<string>
abstract generateText({
prompt,
content,
modelId
}: {
prompt: string
content: string
modelId?: string
}): Promise<string>
abstract check(model: Model): Promise<{ valid: boolean; error: Error | null }>
abstract models(): Promise<OpenAI.Models.Model[]>
abstract generateImage(params: GenerateImageParams): Promise<string[]>
@@ -94,10 +102,22 @@ export default abstract class BaseProvider {
}
public async getMessageContent(message: Message) {
if (isEmpty(message.content)) {
if (isEmpty(message.content) && !message.referencedMessages?.length) {
return message.content
}
// 处理引用消息
if (message.referencedMessages && message.referencedMessages.length > 0) {
const refMsg = message.referencedMessages[0]
const roleText = refMsg.role === 'user' ? '用户' : 'AI'
const referencedContent = `===引用消息开始===\n角色: ${roleText}\n内容: ${refMsg.content}\n===引用消息结束===`
// 如果消息内容为空,则直接返回引用内容
if (isEmpty(message.content.trim())) {
return referencedContent
}
return `${message.content}\n\n${referencedContent}`
}
const webSearchReferences = await this.getWebSearchReferences(message)
if (!isEmpty(webSearchReferences)) {

View File

@@ -8,8 +8,6 @@ import {
import {
Content,
FileDataPart,
FunctionCallPart,
FunctionResponsePart,
GenerateContentStreamResult,
GoogleGenerativeAI,
HarmBlockThreshold,
@@ -18,7 +16,8 @@ import {
Part,
RequestOptions,
SafetySetting,
TextPart
TextPart,
Tool
} from '@google/generative-ai'
import { isGemmaModel, isWebSearchModel } from '@renderer/config/models'
import { getStoreSetting } from '@renderer/hooks/useSettings'
@@ -31,14 +30,12 @@ import {
filterUserRoleStartMessages
} from '@renderer/services/MessagesService'
import WebSearchService from '@renderer/services/WebSearchService'
import store from '@renderer/store'
import { getActiveServers } from '@renderer/store/mcp'
import { Assistant, FileType, FileTypes, MCPToolResponse, Message, Model, Provider, Suggestion } from '@renderer/types'
import { removeSpecialCharactersForTopicName } from '@renderer/utils'
import {
callMCPTool,
geminiFunctionCallToMcpTool,
mcpToolsToGeminiTools,
upsertMCPToolResponse
} from '@renderer/utils/mcp-tools'
import { parseAndCallTools } from '@renderer/utils/mcp-tools'
import { buildSystemPrompt } from '@renderer/utils/prompt'
import { MB } from '@shared/config/constant'
import axios from 'axios'
import { isEmpty, takeRight } from 'lodash'
@@ -230,7 +227,18 @@ export default class GeminiProvider extends BaseProvider {
history.push(await this.getMessageContents(message))
}
const tools = mcpToolsToGeminiTools(mcpTools)
let systemInstruction = assistant.prompt
if (mcpTools && mcpTools.length > 0) {
systemInstruction = await buildSystemPrompt(
assistant.prompt || '',
mcpTools,
getActiveServers(store.getState())
)
}
// const tools = mcpToolsToGeminiTools(mcpTools)
const tools: Tool[] = []
const toolResponses: MCPToolResponse[] = []
if (!WebSearchService.isOverwriteEnabled() && assistant.enableWebSearch && isWebSearchModel(model)) {
@@ -243,7 +251,7 @@ export default class GeminiProvider extends BaseProvider {
const geminiModel = this.sdk.getGenerativeModel(
{
model: model.id,
...(isGemmaModel(model) ? {} : { systemInstruction: assistant.prompt }),
...(isGemmaModel(model) ? {} : { systemInstruction: systemInstruction }),
safetySettings: this.getSafetySettings(model.id),
tools: tools,
generationConfig: {
@@ -268,7 +276,7 @@ export default class GeminiProvider extends BaseProvider {
{
text:
'<start_of_turn>user\n' +
assistant.prompt +
systemInstruction +
'<end_of_turn>\n' +
'<start_of_turn>user\n' +
messageContents.parts[0].text +
@@ -307,7 +315,25 @@ export default class GeminiProvider extends BaseProvider {
const userMessagesStream = await chat.sendMessageStream(messageContents.parts, { signal })
let time_first_token_millsec = 0
const processToolUses = async (content: string, idx: number) => {
const toolResults = await parseAndCallTools(content, toolResponses, onChunk, idx, mcpTools)
if (toolResults && toolResults.length > 0) {
history.push(messageContents)
const newChat = geminiModel.startChat({ history })
const newStream = await newChat.sendMessageStream(
[
{
text: toolResults.join('\n')
}
],
{ signal }
)
await processStream(newStream, idx + 1)
}
}
const processStream = async (stream: GenerateContentStreamResult, idx: number) => {
let content = ''
for await (const chunk of stream.stream) {
if (window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED)) break
@@ -317,56 +343,8 @@ export default class GeminiProvider extends BaseProvider {
const time_completion_millsec = new Date().getTime() - start_time_millsec
const functionCalls = chunk.functionCalls()
if (functionCalls) {
const fcallParts: FunctionCallPart[] = []
const fcRespParts: FunctionResponsePart[] = []
for (const call of functionCalls) {
console.log('Function call:', call)
fcallParts.push({ functionCall: call } as FunctionCallPart)
const mcpTool = geminiFunctionCallToMcpTool(mcpTools, call)
if (mcpTool) {
upsertMCPToolResponse(
toolResponses,
{
tool: mcpTool,
status: 'invoking',
id: `${call.name}-${idx}`
},
onChunk
)
const toolCallResponse = await callMCPTool(mcpTool)
fcRespParts.push({
functionResponse: {
name: mcpTool.id,
response: toolCallResponse
}
})
upsertMCPToolResponse(
toolResponses,
{
tool: mcpTool,
status: 'done',
response: toolCallResponse,
id: `${call.name}-${idx}`
},
onChunk
)
}
}
if (fcRespParts) {
history.push(messageContents)
history.push({
role: 'model',
parts: fcallParts
})
const newChat = geminiModel.startChat({ history })
const newStream = await newChat.sendMessageStream(fcRespParts, { signal })
await processStream(newStream, idx + 1)
}
}
content += chunk.text()
processToolUses(content, idx)
onChunk({
text: chunk.text(),
@@ -492,11 +470,40 @@ export default class GeminiProvider extends BaseProvider {
* Generate text
* @param prompt - The prompt
* @param content - The content
* @param modelId - Optional model ID to use
* @returns The generated text
*/
public async generateText({ prompt, content }: { prompt: string; content: string }): Promise<string> {
const model = getDefaultModel()
const systemMessage = { role: 'system', content: prompt }
public async generateText({
prompt,
content,
modelId
}: {
prompt: string
content: string
modelId?: string
}): Promise<string> {
// 使用指定的模型或默认模型
const model = modelId
? store
.getState()
.llm.providers.flatMap((provider) => provider.models)
.find((m) => m.id === modelId)
: getDefaultModel()
if (!model) {
console.error(`Model ${modelId} not found, using default model`)
return ''
}
// 应用记忆功能到系统提示词
const { applyMemoriesToPrompt } = await import('@renderer/services/MemoryService')
const enhancedPrompt = await applyMemoriesToPrompt(prompt)
console.log(
'[GeminiProvider] Applied memories to prompt, length difference:',
enhancedPrompt.length - prompt.length
)
const systemMessage = { role: 'system', content: enhancedPrompt }
const geminiModel = this.sdk.getGenerativeModel(
{
@@ -508,7 +515,7 @@ export default class GeminiProvider extends BaseProvider {
const chat = await geminiModel.startChat()
const messageContent = isGemmaModel(model)
? `<start_of_turn>user\n${prompt}<end_of_turn>\n<start_of_turn>user\n${content}<end_of_turn>`
? `<start_of_turn>user\n${enhancedPrompt}<end_of_turn>\n<start_of_turn>user\n${content}<end_of_turn>`
: content
const { response } = await chat.sendMessage(messageContent)

View File

@@ -20,6 +20,7 @@ import {
filterUserRoleStartMessages
} from '@renderer/services/MessagesService'
import store from '@renderer/store'
import { getActiveServers } from '@renderer/store/mcp'
import {
Assistant,
FileTypes,
@@ -32,21 +33,14 @@ import {
} from '@renderer/types'
import { removeSpecialCharactersForTopicName } from '@renderer/utils'
import { addImageFileToContents } from '@renderer/utils/formats'
import {
callMCPTool,
mcpToolsToOpenAITools,
openAIToolsToMcpTool,
upsertMCPToolResponse
} from '@renderer/utils/mcp-tools'
import { parseAndCallTools } from '@renderer/utils/mcp-tools'
import { buildSystemPrompt } from '@renderer/utils/prompt'
import { isEmpty, takeRight } from 'lodash'
import OpenAI, { AzureOpenAI } from 'openai'
import {
ChatCompletionAssistantMessageParam,
ChatCompletionContentPart,
ChatCompletionCreateParamsNonStreaming,
ChatCompletionMessageParam,
ChatCompletionMessageToolCall,
ChatCompletionToolMessageParam
ChatCompletionMessageParam
} from 'openai/resources'
import { CompletionsParams } from '.'
@@ -303,55 +297,6 @@ export default class OpenAIProvider extends BaseProvider {
return model.id.startsWith('o1') || model.id.startsWith('o3')
}
/**
* Check if the model is a Glm-4-alltools
* @param model - The model
* @returns True if the model is a Glm-4-alltools, false otherwise
*/
private isZhipuTool(model: Model) {
return model.id.includes('glm-4-alltools')
}
/**
* Clean the tool call arguments
* @param toolCall - The tool call
* @returns The cleaned tool call
*/
private cleanToolCallArgs(toolCall: ChatCompletionMessageToolCall): ChatCompletionMessageToolCall {
if (toolCall.function.arguments) {
let args = toolCall.function.arguments
const codeBlockRegex = /```(?:\w*\n)?([\s\S]*?)```/
const match = args.match(codeBlockRegex)
if (match) {
// Extract content from code block
let extractedArgs = match[1].trim()
// Clean function call format like tool_call(name1=value1,name2=value2)
const functionCallRegex = /^\s*\w+\s*\(([\s\S]*?)\)\s*$/
const functionMatch = extractedArgs.match(functionCallRegex)
if (functionMatch) {
// Try to convert parameters to JSON format
const params = functionMatch[1].split(',').filter(Boolean)
const paramsObj = {}
params.forEach((param) => {
const [name, value] = param.split('=').map((p) => p.trim())
if (name && value !== undefined) {
paramsObj[name] = value
}
})
extractedArgs = JSON.stringify(paramsObj)
}
toolCall.function.arguments = extractedArgs
}
args = toolCall.function.arguments
const firstBraceIndex = args.indexOf('{')
const lastBraceIndex = args.lastIndexOf('}')
if (firstBraceIndex !== -1 && lastBraceIndex !== -1 && firstBraceIndex < lastBraceIndex) {
toolCall.function.arguments = args.substring(firstBraceIndex, lastBraceIndex + 1)
}
}
return toolCall
}
/**
* Generate completions for the assistant
* @param messages - The messages
@@ -366,14 +311,30 @@ export default class OpenAIProvider extends BaseProvider {
const model = assistant.model || defaultModel
const { contextCount, maxTokens, streamOutput } = getAssistantSettings(assistant)
messages = addImageFileToContents(messages)
let systemMessage = assistant.prompt ? { role: 'system', content: assistant.prompt } : undefined
// 应用记忆功能到系统提示词
const { applyMemoriesToPrompt } = await import('@renderer/services/MemoryService')
// 获取当前话题ID
const currentTopicId = messages.length > 0 ? messages[0].topicId : undefined
const enhancedPrompt = await applyMemoriesToPrompt(assistant.prompt || '', currentTopicId)
console.log(
'[OpenAIProvider.completions] Applied memories to prompt, length difference:',
enhancedPrompt.length - (assistant.prompt || '').length
)
let systemMessage = { role: 'system', content: enhancedPrompt }
if (isOpenAIoSeries(model)) {
systemMessage = {
role: 'developer',
content: `Formatting re-enabled${systemMessage ? '\n' + systemMessage.content : ''}`
}
}
if (mcpTools && mcpTools.length > 0) {
systemMessage.content = await buildSystemPrompt(
systemMessage.content || '',
mcpTools,
getActiveServers(store.getState())
)
}
const userMessages: ChatCompletionMessageParam[] = []
const _messages = filterUserRoleStartMessages(
@@ -436,14 +397,51 @@ export default class OpenAIProvider extends BaseProvider {
const { signal } = abortController
await this.checkIsCopilot()
const tools = mcpTools && mcpTools.length > 0 ? mcpToolsToOpenAITools(mcpTools) : undefined
const reqMessages: ChatCompletionMessageParam[] = [systemMessage, ...userMessages].filter(
Boolean
) as ChatCompletionMessageParam[]
const toolResponses: MCPToolResponse[] = []
let firstChunk = true
const processToolUses = async (content: string, idx: number) => {
const toolResults = await parseAndCallTools(content, toolResponses, onChunk, idx, mcpTools)
if (toolResults.length > 0) {
reqMessages.push({
role: 'assistant',
content: content
} as ChatCompletionMessageParam)
reqMessages.push({
role: 'user',
content: toolResults.join('\n')
} as ChatCompletionMessageParam)
const newStream = await this.sdk.chat.completions
// @ts-ignore key is not typed
.create(
{
model: model.id,
messages: reqMessages,
temperature: this.getTemperature(assistant, model),
top_p: this.getTopP(assistant, model),
max_tokens: maxTokens,
keep_alive: this.keepAliveTime,
stream: isSupportStreamOutput(),
// tools: tools,
...getOpenAIWebSearchParams(assistant, model),
...this.getReasoningEffort(assistant, model),
...this.getProviderSpecificParameters(assistant, model),
...this.getCustomParameters(assistant)
},
{
signal
}
)
await processStream(newStream, idx + 1)
}
}
const processStream = async (stream: any, idx: number) => {
if (!isSupportStreamOutput()) {
const time_completion_millsec = new Date().getTime() - start_time_millsec
@@ -457,14 +455,17 @@ export default class OpenAIProvider extends BaseProvider {
}
})
}
const final_tool_calls = {} as Record<number, ChatCompletionMessageToolCall>
let content = ''
for await (const chunk of stream) {
if (window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED)) {
break
}
const delta = chunk.choices[0]?.delta
if (delta?.content) {
content += delta.content
}
if (delta?.reasoning_content || delta?.reasoning) {
hasReasoningContent = true
@@ -486,29 +487,6 @@ export default class OpenAIProvider extends BaseProvider {
const finishReason = chunk.choices[0]?.finish_reason
if (delta?.tool_calls?.length) {
const chunkToolCalls = delta.tool_calls
for (const t of chunkToolCalls) {
const { index, id, function: fn, type } = t
const args = fn && typeof fn.arguments === 'string' ? fn.arguments : ''
if (!(index in final_tool_calls)) {
final_tool_calls[index] = {
id,
function: {
name: fn?.name,
arguments: args
},
type
} as ChatCompletionMessageToolCall
} else {
final_tool_calls[index].function.arguments += args
}
}
if (finishReason !== 'tool_calls') {
continue
}
}
let webSearch: any[] | undefined = undefined
if (assistant.enableWebSearch && isZhipuModel(model) && finishReason === 'stop') {
webSearch = chunk?.web_search
@@ -517,102 +495,6 @@ export default class OpenAIProvider extends BaseProvider {
webSearch = chunk?.search_info?.search_results
firstChunk = true
}
if (finishReason === 'tool_calls' || (finishReason === 'stop' && Object.keys(final_tool_calls).length > 0)) {
const toolCalls = Object.values(final_tool_calls).map(this.cleanToolCallArgs)
console.log('start invoke tools', toolCalls)
if (this.isZhipuTool(model)) {
reqMessages.push({
role: 'assistant',
content: `argments=${JSON.stringify(toolCalls[0].function.arguments)}`
})
} else {
reqMessages.push({
role: 'assistant',
tool_calls: toolCalls
} as ChatCompletionAssistantMessageParam)
}
for (const toolCall of toolCalls) {
const mcpTool = openAIToolsToMcpTool(mcpTools, toolCall)
if (!mcpTool) {
continue
}
upsertMCPToolResponse(toolResponses, { tool: mcpTool, status: 'invoking', id: toolCall.id }, onChunk)
const toolCallResponse = await callMCPTool(mcpTool)
const toolResponsContent: { type: string; text?: string; image_url?: { url: string } }[] = []
for (const content of toolCallResponse.content) {
if (content.type === 'text') {
toolResponsContent.push({
type: 'text',
text: content.text
})
} else if (content.type === 'image') {
toolResponsContent.push({
type: 'image_url',
image_url: { url: `data:${content.mimeType};base64,${content.data}` }
})
} else {
console.warn('Unsupported content type:', content.type)
toolResponsContent.push({
type: 'text',
text: 'unsupported content type: ' + content.type
})
}
}
const provider = lastUserMessage?.model?.provider
const modelName = lastUserMessage?.model?.name
if (
modelName?.toLocaleLowerCase().includes('gpt') ||
(provider === 'dashscope' && modelName?.toLocaleLowerCase().includes('qwen'))
) {
reqMessages.push({
role: 'tool',
content: toolResponsContent,
tool_call_id: toolCall.id
} as ChatCompletionToolMessageParam)
} else {
reqMessages.push({
role: 'tool',
content: JSON.stringify(toolResponsContent),
tool_call_id: toolCall.id
} as ChatCompletionToolMessageParam)
}
upsertMCPToolResponse(
toolResponses,
{ tool: mcpTool, status: 'done', response: toolCallResponse, id: toolCall.id },
onChunk
)
}
const newStream = await this.sdk.chat.completions
// @ts-ignore key is not typed
.create(
{
model: model.id,
messages: reqMessages,
temperature: this.getTemperature(assistant, model),
top_p: this.getTopP(assistant, model),
max_tokens: maxTokens,
keep_alive: this.keepAliveTime,
stream: isSupportStreamOutput(),
tools: tools,
...getOpenAIWebSearchParams(assistant, model),
...this.getReasoningEffort(assistant, model),
...this.getProviderSpecificParameters(assistant, model),
...this.getCustomParameters(assistant)
},
{
signal
}
)
await processStream(newStream, idx + 1)
}
onChunk({
text: delta?.content || '',
reasoning_content: delta?.reasoning_content || delta?.reasoning || '',
@@ -629,7 +511,10 @@ export default class OpenAIProvider extends BaseProvider {
mcpToolResponse: toolResponses
})
}
await processToolUses(content, idx)
}
const stream = await this.sdk.chat.completions
// @ts-ignore key is not typed
.create(
@@ -641,7 +526,7 @@ export default class OpenAIProvider extends BaseProvider {
max_tokens: maxTokens,
keep_alive: this.keepAliveTime,
stream: isSupportStreamOutput(),
tools: tools,
// tools: tools,
...getOpenAIWebSearchParams(assistant, model),
...this.getReasoningEffort(assistant, model),
...this.getProviderSpecificParameters(assistant, model),
@@ -669,12 +554,23 @@ export default class OpenAIProvider extends BaseProvider {
async translate(message: Message, assistant: Assistant, onResponse?: (text: string) => void) {
const defaultModel = getDefaultModel()
const model = assistant.model || defaultModel
// 应用记忆功能到系统提示词
const { applyMemoriesToPrompt } = await import('@renderer/services/MemoryService')
// 获取当前话题ID
const currentTopicId = message.topicId
const enhancedPrompt = await applyMemoriesToPrompt(assistant.prompt || '', currentTopicId)
console.log(
'[OpenAIProvider.translate] Applied memories to prompt, length difference:',
enhancedPrompt.length - (assistant.prompt || '').length
)
const messages = message.content
? [
{ role: 'system', content: assistant.prompt },
{ role: 'system', content: enhancedPrompt },
{ role: 'user', content: message.content }
]
: [{ role: 'user', content: assistant.prompt }]
: [{ role: 'user', content: enhancedPrompt }]
const isOpenAIReasoning = this.isOpenAIReasoning(model)
@@ -755,9 +651,25 @@ export default class OpenAIProvider extends BaseProvider {
return prev + (prev ? '\n' : '') + content
}, '')
// 获取原始提示词
const originalPrompt = getStoreSetting('topicNamingPrompt') || i18n.t('prompts.title')
// 应用记忆功能到系统提示词
const { applyMemoriesToPrompt } = await import('@renderer/services/MemoryService')
// 获取当前话题ID
const currentTopicId = messages.length > 0 ? messages[0].topicId : undefined
// 使用双重类型断言强制转换类型
const enhancedPrompt = (await applyMemoriesToPrompt(originalPrompt as string, currentTopicId)) as unknown as string
// 存储原始提示词长度
const originalPromptLength = (originalPrompt as string).length
console.log(
'[OpenAIProvider.summaries] Applied memories to prompt, length difference:',
enhancedPrompt.length - originalPromptLength
)
const systemMessage = {
role: 'system',
content: getStoreSetting('topicNamingPrompt') || i18n.t('prompts.title')
content: enhancedPrompt
}
const userMessage = {
@@ -826,18 +738,46 @@ export default class OpenAIProvider extends BaseProvider {
* Generate text
* @param prompt - The prompt
* @param content - The content
* @param modelId - Optional model ID to use
* @returns The generated text
*/
public async generateText({ prompt, content }: { prompt: string; content: string }): Promise<string> {
const model = getDefaultModel()
public async generateText({
prompt,
content,
modelId
}: {
prompt: string
content: string
modelId?: string
}): Promise<string> {
// 使用指定的模型或默认模型
const model = modelId
? store
.getState()
.llm.providers.flatMap((provider) => provider.models)
.find((m) => m.id === modelId)
: getDefaultModel()
if (!model) {
console.error(`Model ${modelId} not found, using default model`)
return ''
}
await this.checkIsCopilot()
// 应用记忆功能到系统提示词
const { applyMemoriesToPrompt } = await import('@renderer/services/MemoryService')
// 使用双重类型断言强制转换类型
const enhancedPrompt = (await applyMemoriesToPrompt(prompt as string)) as unknown as string
// 存储原始提示词长度
const promptLength = (prompt as string).length
console.log('[OpenAIProvider] Applied memories to prompt, length difference:', enhancedPrompt.length - promptLength)
const response = await this.sdk.chat.completions.create({
model: model.id,
stream: false,
messages: [
{ role: 'system', content: prompt },
{ role: 'system', content: enhancedPrompt },
{ role: 'user', content }
]
})
@@ -919,7 +859,7 @@ export default class OpenAIProvider extends BaseProvider {
if (this.provider.id === 'github') {
// @ts-ignore key is not typed
return response.body
.map((model) => ({
.map((model: any) => ({
id: model.name,
description: model.summary,
object: 'model',

View File

@@ -88,8 +88,16 @@ export default class AiProvider {
return this.sdk.suggestions(messages, assistant)
}
public async generateText({ prompt, content }: { prompt: string; content: string }): Promise<string> {
return this.sdk.generateText({ prompt, content })
public async generateText({
prompt,
content,
modelId
}: {
prompt: string
content: string
modelId?: string
}): Promise<string> {
return this.sdk.generateText({ prompt, content, modelId })
}
public async check(model: Model): Promise<{ valid: boolean; error: Error | null }> {

View File

@@ -250,7 +250,7 @@ export async function fetchChatCompletion({
}
}
}
console.log('message', message)
// console.log('message', message)
} catch (error: any) {
if (isAbortError(error)) {
message.status = 'paused'
@@ -333,8 +333,28 @@ export async function fetchSearchSummary({ messages, assistant }: { messages: Me
}
}
export async function fetchGenerate({ prompt, content }: { prompt: string; content: string }): Promise<string> {
const model = getDefaultModel()
export async function fetchGenerate({
prompt,
content,
modelId
}: {
prompt: string
content: string
modelId?: string
}): Promise<string> {
// 使用指定的模型或默认模型
const model = modelId
? store
.getState()
.llm.providers.flatMap((provider) => provider.models)
.find((m) => m.id === modelId)
: getDefaultModel()
if (!model) {
console.error(`Model ${modelId} not found, using default model`)
return ''
}
const provider = getProviderByModel(model)
if (!hasApiKey(provider)) {
@@ -344,8 +364,9 @@ export async function fetchGenerate({ prompt, content }: { prompt: string; conte
const AI = new AiProvider(provider)
try {
return await AI.generateText({ prompt, content })
return await AI.generateText({ prompt, content, modelId })
} catch (error: any) {
console.error('Error generating text:', error)
return ''
}
}

View File

@@ -0,0 +1,525 @@
// src/renderer/src/services/ContextualMemoryService.ts
import { TopicManager } from '@renderer/hooks/useTopic'
import { fetchGenerate } from '@renderer/services/ApiService'
import store from '@renderer/store'
import { addMemoryRetrievalLatency } from '@renderer/store/memory'
import { Message } from '@renderer/types'
import { vectorService } from './VectorService'
// 记忆项接口从store/memory.ts导入
interface Memory {
id: string
content: string
createdAt: string
source?: string
category?: string
listId: string
analyzedMessageIds?: string[]
lastMessageId?: string
topicId?: string
vector?: number[]
entities?: string[]
keywords?: string[]
importance?: number
accessCount?: number
lastAccessedAt?: string
decayFactor?: number
freshness?: number
}
interface ShortMemory {
id: string
content: string
createdAt: string
topicId: string
analyzedMessageIds?: string[]
lastMessageId?: string
vector?: number[]
entities?: string[]
keywords?: string[]
importance?: number
accessCount?: number
lastAccessedAt?: string
decayFactor?: number
freshness?: number
}
// 记忆推荐结果接口
export interface MemoryRecommendation {
memory: Memory | ShortMemory
relevanceScore: number
source: 'long-term' | 'short-term'
matchReason?: string
}
/**
* ContextualMemoryService 类负责实现上下文感知的记忆推荐和检索功能
*/
class ContextualMemoryService {
/**
* 基于当前对话上下文推荐相关记忆
* @param messages - 当前对话的消息列表
* @param topicId - 当前对话的话题ID
* @param limit - 返回的最大记忆数量
* @returns 推荐的记忆列表,按相关性排序
*/
async getContextualMemoryRecommendations(
messages: Message[],
topicId: string,
limit: number = 5
): Promise<MemoryRecommendation[]> {
console.log(`[ContextualMemory] Getting contextual memory recommendations for topic ${topicId}`)
const startTime = performance.now()
try {
// 获取当前状态
const state = store.getState()
const memoryState = state.memory
if (!memoryState) {
console.log('[ContextualMemory] Memory state not available')
return []
}
// 检查记忆功能是否激活
if (!memoryState.isActive && !memoryState.shortMemoryActive) {
console.log('[ContextualMemory] Memory features are not active')
return []
}
// 获取最近的消息作为上下文
const recentMessages = messages.slice(-5)
if (recentMessages.length === 0) {
console.log('[ContextualMemory] No recent messages available')
return []
}
// 构建上下文查询文本
const contextQuery = this._buildContextQuery(recentMessages)
console.log(`[ContextualMemory] Context query: ${contextQuery}`)
// 并行获取长期记忆和短期记忆的推荐
const [longTermRecommendations, shortTermRecommendations] = await Promise.all([
this._getLongTermMemoryRecommendations(contextQuery, topicId),
this._getShortTermMemoryRecommendations(contextQuery, topicId)
])
// 合并并排序推荐结果
const allRecommendations = [...longTermRecommendations, ...shortTermRecommendations]
// 按相关性分数排序
allRecommendations.sort((a, b) => b.relevanceScore - a.relevanceScore)
// 限制返回数量
const limitedRecommendations = allRecommendations.slice(0, limit)
// 记录性能指标
const endTime = performance.now()
const latency = endTime - startTime
store.dispatch(addMemoryRetrievalLatency(latency))
console.log(
`[ContextualMemory] Found ${limitedRecommendations.length} recommendations in ${latency.toFixed(2)}ms`
)
return limitedRecommendations
} catch (error) {
console.error('[ContextualMemory] Error getting contextual memory recommendations:', error)
return []
}
}
/**
* 基于当前对话主题自动提取相关记忆
* @param topicId - 当前对话的话题ID
* @param limit - 返回的最大记忆数量
* @returns 与当前主题相关的记忆列表
*/
async getTopicRelatedMemories(topicId: string, limit: number = 10): Promise<MemoryRecommendation[]> {
console.log(`[ContextualMemory] Getting topic-related memories for topic ${topicId}`)
try {
// 获取当前状态
const state = store.getState()
const memoryState = state.memory
const messagesState = state.messages
if (!memoryState || !messagesState) {
console.log('[ContextualMemory] Required state not available')
return []
}
// 获取话题信息
// 使用TopicManager获取话题
let topicQuery = ''
try {
const topic = await TopicManager.getTopic(topicId)
if (!topic) {
console.log(`[ContextualMemory] Topic ${topicId} not found`)
return []
}
// 使用话题ID作为查询
// 注意TopicManager.getTopic返回的类型只有id和messages属性
topicQuery = `Topic ${topicId}`
if (!topicQuery.trim()) {
console.log('[ContextualMemory] No topic information available for query')
return []
}
} catch (error) {
console.error(`[ContextualMemory] Error getting topic ${topicId}:`, error)
return []
}
console.log(`[ContextualMemory] Topic query: ${topicQuery}`)
// 并行获取长期记忆和短期记忆的推荐
const [longTermRecommendations, shortTermRecommendations] = await Promise.all([
this._getLongTermMemoryRecommendations(topicQuery, topicId),
this._getShortTermMemoryRecommendations(topicQuery, topicId)
])
// 合并并排序推荐结果
const allRecommendations = [...longTermRecommendations, ...shortTermRecommendations]
// 按相关性分数排序
allRecommendations.sort((a, b) => b.relevanceScore - a.relevanceScore)
// 限制返回数量
const limitedRecommendations = allRecommendations.slice(0, limit)
console.log(`[ContextualMemory] Found ${limitedRecommendations.length} topic-related memories`)
return limitedRecommendations
} catch (error) {
console.error('[ContextualMemory] Error getting topic-related memories:', error)
return []
}
}
/**
* 使用语义搜索查找与查询相关的记忆
* @param query - 搜索查询
* @param limit - 返回的最大记忆数量
* @returns 与查询相关的记忆列表
*/
async searchMemoriesBySemantics(query: string, limit: number = 10): Promise<MemoryRecommendation[]> {
console.log(`[ContextualMemory] Semantic search for: ${query}`)
try {
// 获取当前状态
const state = store.getState()
const memoryState = state.memory
if (!memoryState) {
console.log('[ContextualMemory] Memory state not available')
return []
}
// 并行获取长期记忆和短期记忆的推荐
const [longTermRecommendations, shortTermRecommendations] = await Promise.all([
this._getLongTermMemoryRecommendations(query),
this._getShortTermMemoryRecommendations(query)
])
// 合并并排序推荐结果
const allRecommendations = [...longTermRecommendations, ...shortTermRecommendations]
// 按相关性分数排序
allRecommendations.sort((a, b) => b.relevanceScore - a.relevanceScore)
// 限制返回数量
const limitedRecommendations = allRecommendations.slice(0, limit)
console.log(`[ContextualMemory] Found ${limitedRecommendations.length} memories matching query`)
return limitedRecommendations
} catch (error) {
console.error('[ContextualMemory] Error searching memories by semantics:', error)
return []
}
}
/**
* 使用AI分析当前对话上下文提取关键信息并推荐相关记忆
* @param messages - 当前对话的消息列表
* @param limit - 返回的最大记忆数量
* @returns 基于AI分析的相关记忆推荐
*/
async getAIEnhancedMemoryRecommendations(messages: Message[], limit: number = 5): Promise<MemoryRecommendation[]> {
console.log('[ContextualMemory] Getting AI-enhanced memory recommendations')
try {
// 获取当前状态
const state = store.getState()
const memoryState = state.memory
if (!memoryState) {
console.log('[ContextualMemory] Memory state not available')
return []
}
// 获取分析模型
const analyzeModel = memoryState.analyzeModel
if (!analyzeModel) {
console.log('[ContextualMemory] No analyze model set')
return []
}
// 获取最近的消息作为上下文
const recentMessages = messages.slice(-10)
if (recentMessages.length === 0) {
console.log('[ContextualMemory] No recent messages available')
return []
}
// 构建对话内容
const conversation = recentMessages.map((msg) => `${msg.role || 'user'}: ${msg.content || ''}`).join('\n')
// 构建提示词
const prompt = `
请分析以下对话内容,提取出关键信息和主题,以便我可以找到相关的记忆。
请提供:
1. 对话的主要主题
2. 用户可能关心的关键信息点
3. 可能与此对话相关的背景知识或上下文
请以简洁的关键词和短语形式回答,每行一个要点,不要使用编号或项目符号。
对话内容:
${conversation}
`
// 调用AI生成文本
console.log('[ContextualMemory] Calling AI for context analysis...')
const result = await fetchGenerate({
prompt: prompt,
content: conversation,
modelId: analyzeModel
})
if (!result || typeof result !== 'string' || result.trim() === '') {
console.log('[ContextualMemory] No valid result from AI analysis')
return []
}
// 提取关键信息
const keyPoints = result
.split('\n')
.map((line) => line.trim())
.filter((line) => line && !line.startsWith('#') && !line.startsWith('-'))
console.log('[ContextualMemory] Extracted key points:', keyPoints)
// 使用提取的关键信息作为查询
const enhancedQuery = keyPoints.join(' ')
// 获取相关记忆
return await this.searchMemoriesBySemantics(enhancedQuery, limit)
} catch (error) {
console.error('[ContextualMemory] Error getting AI-enhanced memory recommendations:', error)
return []
}
}
/**
* 构建上下文查询文本
* @param messages - 消息列表
* @returns 构建的查询文本
* @private
*/
private _buildContextQuery(messages: Message[]): string {
// 提取最近消息的内容
const messageContents = messages.map((msg) => msg.content || '').filter((content) => content.trim() !== '')
// 如果没有有效内容,返回空字符串
if (messageContents.length === 0) {
return ''
}
// 合并消息内容最多使用最近的3条消息
return messageContents.slice(-3).join(' ')
}
/**
* 获取与查询相关的长期记忆推荐
* @param query - 查询文本
* @param topicId - 可选的话题ID用于过滤记忆
* @returns 长期记忆推荐列表
* @private
*/
private async _getLongTermMemoryRecommendations(query: string, topicId?: string): Promise<MemoryRecommendation[]> {
// 获取当前状态
const state = store.getState()
const memoryState = state.memory
// 检查长期记忆功能是否激活
if (!memoryState || !memoryState.isActive) {
return []
}
// 获取所有激活的记忆列表
const activeListIds = memoryState.memoryLists.filter((list) => list.isActive).map((list) => list.id)
if (activeListIds.length === 0) {
return []
}
// 获取激活列表中的记忆
const memories = memoryState.memories.filter((memory) => activeListIds.includes(memory.listId))
if (memories.length === 0) {
return []
}
// 使用向量服务查找相似记忆
const results = await vectorService.findSimilarMemoriesToQuery(
query,
memories,
20, // 获取更多结果,后续会进一步优化排序
0.5 // 降低阈值以获取更多潜在相关记忆
)
// 转换为推荐格式
const recommendations: MemoryRecommendation[] = results.map((result) => ({
memory: result.memory as Memory,
relevanceScore: result.similarity,
source: 'long-term',
matchReason: '语义相似'
}))
// 应用高级排序优化
return this._optimizeRelevanceRanking(recommendations, query, topicId)
}
/**
* 获取与查询相关的短期记忆推荐
* @param query - 查询文本
* @param topicId - 可选的话题ID用于过滤记忆
* @returns 短期记忆推荐列表
* @private
*/
private async _getShortTermMemoryRecommendations(query: string, topicId?: string): Promise<MemoryRecommendation[]> {
// 获取当前状态
const state = store.getState()
const memoryState = state.memory
// 检查短期记忆功能是否激活
if (!memoryState || !memoryState.shortMemoryActive) {
return []
}
// 获取短期记忆
let shortMemories = memoryState.shortMemories
// 如果指定了话题ID只获取该话题的短期记忆
if (topicId) {
shortMemories = shortMemories.filter((memory) => memory.topicId === topicId)
}
if (shortMemories.length === 0) {
return []
}
// 使用向量服务查找相似记忆
const results = await vectorService.findSimilarMemoriesToQuery(
query,
shortMemories,
20, // 获取更多结果,后续会进一步优化排序
0.5 // 降低阈值以获取更多潜在相关记忆
)
// 转换为推荐格式
const recommendations: MemoryRecommendation[] = results.map((result) => ({
memory: result.memory as ShortMemory,
relevanceScore: result.similarity,
source: 'short-term',
matchReason: '与当前对话相关'
}))
// 应用高级排序优化
return this._optimizeRelevanceRanking(recommendations, query, topicId)
}
/**
* 优化记忆推荐的相关性排序
* @param recommendations - 初始推荐列表
* @param query - 查询文本
* @param topicId - 可选的话题ID
* @returns 优化排序后的推荐列表
* @private
*/
private _optimizeRelevanceRanking(
recommendations: MemoryRecommendation[],
query: string,
topicId?: string
): MemoryRecommendation[] {
if (recommendations.length === 0) {
return []
}
// 获取当前状态
const state = store.getState()
const memoryState = state.memory
// 应用多因素排序优化
return recommendations
.map((rec) => {
const memory = rec.memory
let adjustedScore = rec.relevanceScore
// 1. 考虑记忆的重要性
if (memory.importance && memoryState.priorityManagementEnabled) {
adjustedScore *= 1 + memory.importance * 0.5 // 重要性最多提升50%的分数
}
// 2. 考虑记忆的鲜度
if (memory.freshness && memoryState.freshnessEnabled) {
adjustedScore *= 1 + memory.freshness * 0.3 // 鲜度最多提升30%的分数
}
// 3. 考虑记忆的衰减因子
if (memory.decayFactor && memoryState.decayEnabled) {
adjustedScore *= memory.decayFactor // 直接应用衰减因子
}
// 4. 如果记忆与当前话题相关,提高分数
if (topicId && memory.topicId === topicId) {
adjustedScore *= 1.2 // 提高20%的分数
}
// 5. 考虑访问频率,常用的记忆可能更相关
if (memory.accessCount && memory.accessCount > 0) {
// 访问次数越多,提升越大,但有上限
const accessBoost = Math.min(memory.accessCount / 10, 0.2) // 最多提升20%
adjustedScore *= 1 + accessBoost
}
// 6. 考虑关键词匹配
if (memory.keywords && memory.keywords.length > 0) {
const queryLower = query.toLowerCase()
const keywordMatches = memory.keywords.filter((keyword) => queryLower.includes(keyword.toLowerCase())).length
if (keywordMatches > 0) {
// 关键词匹配越多,提升越大
const keywordBoost = Math.min(keywordMatches * 0.1, 0.3) // 最多提升30%
adjustedScore *= 1 + keywordBoost
}
}
// 返回调整后的推荐
return {
...rec,
relevanceScore: adjustedScore
}
})
.sort((a, b) => b.relevanceScore - a.relevanceScore) // 按调整后的分数重新排序
}
}
// 导出 ContextualMemoryService 的单例
export const contextualMemoryService = new ContextualMemoryService()

View File

@@ -0,0 +1,239 @@
// src/renderer/src/services/HistoricalContextService.ts
import { TopicManager } from '@renderer/hooks/useTopic'
import { fetchGenerate } from '@renderer/services/ApiService'
import store from '@renderer/store'
import { ShortMemory } from '@renderer/store/memory'
import { Message } from '@renderer/types'
/**
* 分析当前对话并决定是否需要调用历史对话
* @param topicId 当前话题ID
* @param recentMessageCount 要分析的最近消息数量
* @param returnIdOnly 是否只返回话题ID而不获取完整内容用于调试
* @returns 如果需要历史上下文返回历史对话内容否则返回null
*/
export const analyzeAndSelectHistoricalContext = async (
topicId: string,
recentMessageCount: number = 8,
returnIdOnly: boolean = false
): Promise<{ content: string; sourceTopicId: string } | null> => {
try {
// 1. 获取设置,检查功能是否启用
const state = store.getState()
const isEnabled = state.settings?.enableHistoricalContext ?? false
if (!isEnabled) {
console.log('[HistoricalContext] Feature is disabled')
return null
}
// 2. 获取最近的消息
const recentMessages = await getRecentMessages(topicId, recentMessageCount)
if (!recentMessages || recentMessages.length === 0) {
console.log('[HistoricalContext] No recent messages found')
return null
}
// 3. 获取所有短期记忆(已分析的对话)
const shortMemories = state.memory?.shortMemories || []
if (shortMemories.length === 0) {
console.log('[HistoricalContext] No short memories available')
return null
}
// 4. 使用快速模型分析是否需要历史上下文
const analysisResult = await analyzeNeedForHistoricalContext(recentMessages, shortMemories)
if (!analysisResult.needsHistoricalContext) {
console.log('[HistoricalContext] Analysis indicates no need for historical context')
return null
}
// 5. 如果需要历史上下文,获取原始对话内容
if (analysisResult.selectedTopicId) {
// 如果只需要返回ID则不获取完整内容用于调试
if (returnIdOnly) {
return {
content: `话题ID: ${analysisResult.selectedTopicId}\n原因: ${analysisResult.reason || '相关历史对话'}`,
sourceTopicId: analysisResult.selectedTopicId
}
}
// 正常情况下,获取完整对话内容
const dialogContent = await getOriginalDialogContent(analysisResult.selectedTopicId)
if (dialogContent) {
return {
content: dialogContent,
sourceTopicId: analysisResult.selectedTopicId
}
}
}
return null
} catch (error) {
console.error('[HistoricalContext] Error analyzing and selecting historical context:', error)
return null
}
}
/**
* 获取指定话题的最近消息
*/
const getRecentMessages = async (topicId: string, count: number): Promise<Message[]> => {
try {
// 先尝试从Redux store获取
const state = store.getState()
let messages: Message[] = []
if (state.messages?.messagesByTopic && state.messages.messagesByTopic[topicId]) {
messages = state.messages.messagesByTopic[topicId]
} else {
// 如果Redux store中没有从数据库获取
const topicMessages = await TopicManager.getTopicMessages(topicId)
if (topicMessages && topicMessages.length > 0) {
messages = topicMessages
}
}
// 返回最近的count条消息
return messages.slice(-count)
} catch (error) {
console.error('[HistoricalContext] Error getting recent messages:', error)
return []
}
}
/**
* 分析是否需要历史上下文
*/
const analyzeNeedForHistoricalContext = async (
recentMessages: Message[],
shortMemories: ShortMemory[]
): Promise<{ needsHistoricalContext: boolean; selectedTopicId?: string; reason?: string }> => {
try {
// 准备分析提示词
const messagesContent = recentMessages
.map((msg) => `${msg.role === 'user' ? '用户' : 'AI'}: ${msg.content}`)
.join('\n')
const memoriesContent = shortMemories
.map((memory) => `话题ID: ${memory.topicId}\n内容: ${memory.content}`)
.join('\n\n')
const prompt = `
你是一个专门分析对话上下文的助手,你的任务是判断当前对话是否需要引用历史对话来提供更完整、更连贯的回答。
最近的对话内容:
${messagesContent}
可用的历史对话摘要:
${memoriesContent}
请仔细分析用户的问题和可用的历史对话摘要。考虑以下因素:
1. 用户当前问题是否与历史对话中的任何主题相关
2. 历史对话中是否包含可能对当前问题有帮助的信息
3. 引用历史对话是否能使回答更全面、更个性化
4. 即使用户没有直接提及历史内容,但如果历史对话中有相关信息,也应考虑引用
请积极地寻找可能的联系,即使联系不是非常明显的。如果有任何可能相关的历史对话,请倾向于引用它。
请回答以下问题:
1. 是否需要引用历史对话来更好地回答用户的问题?(是/否)
2. 如果需要请指出最相关的历史对话的话题ID。
3. 详细解释为什么需要引用这个历史对话,以及它如何与当前问题相关。
请按以下JSON格式回答不要添加任何其他文本:
{
"needsHistoricalContext": true/false,
"selectedTopicId": "话题ID或null",
"reason": "详细解释为什么需要或不需要引用历史对话"
}
`
// 获取分析模型
const state = store.getState()
// 优先使用历史对话上下文分析模型,如果没有设置,则使用短期记忆分析模型或长期记忆分析模型
const analyzeModel =
state.memory?.historicalContextAnalyzeModel || state.memory?.shortMemoryAnalyzeModel || state.memory?.analyzeModel
if (!analyzeModel) {
console.log('[HistoricalContext] No analyze model set')
return { needsHistoricalContext: false }
}
// 调用模型进行分析
console.log('[HistoricalContext] Calling AI model for analysis...')
const result = await fetchGenerate({
prompt,
content: '',
modelId: analyzeModel
})
if (!result) {
console.log('[HistoricalContext] No result from AI analysis')
return { needsHistoricalContext: false }
}
// 解析结果
try {
// 尝试直接解析JSON
const parsedResult = JSON.parse(result)
return {
needsHistoricalContext: parsedResult.needsHistoricalContext === true,
selectedTopicId: parsedResult.selectedTopicId || undefined,
reason: parsedResult.reason
}
} catch (parseError) {
// 如果直接解析失败尝试从文本中提取JSON
const jsonMatch = result.match(/\{[\s\S]*\}/)
if (jsonMatch) {
try {
const extractedJson = JSON.parse(jsonMatch[0])
return {
needsHistoricalContext: extractedJson.needsHistoricalContext === true,
selectedTopicId: extractedJson.selectedTopicId || undefined,
reason: extractedJson.reason
}
} catch (extractError) {
console.error('[HistoricalContext] Failed to extract JSON from result:', extractError)
}
}
// 如果都失败了,尝试简单的文本分析
const needsContext = result.toLowerCase().includes('true') && !result.toLowerCase().includes('false')
const topicIdMatch = result.match(/selectedTopicId["\s:]+([^"\s,}]+)/)
const reasonMatch = result.match(/reason["\s:]+"([^"]+)"/) || result.match(/reason["\s:]+([^,}\s]+)/)
return {
needsHistoricalContext: needsContext,
selectedTopicId: topicIdMatch ? topicIdMatch[1] : undefined,
reason: reasonMatch ? reasonMatch[1] : undefined
}
}
} catch (error) {
console.error('[HistoricalContext] Error analyzing need for historical context:', error)
return { needsHistoricalContext: false }
}
}
/**
* 获取原始对话内容
*/
const getOriginalDialogContent = async (topicId: string): Promise<string | null> => {
try {
// 获取话题的原始消息
const messages = await TopicManager.getTopicMessages(topicId)
if (!messages || messages.length === 0) {
console.log(`[HistoricalContext] No messages found for topic ${topicId}`)
return null
}
// 格式化对话内容
const dialogContent = messages.map((msg) => `${msg.role === 'user' ? '用户' : 'AI'}: ${msg.content}`).join('\n\n')
return dialogContent
} catch (error) {
console.error('[HistoricalContext] Error getting original dialog content:', error)
return null
}
}

View File

@@ -0,0 +1,341 @@
// 记忆去重与合并服务
import { fetchGenerate } from '@renderer/services/ApiService'
import store from '@renderer/store'
import {
addMemory,
addShortMemory,
deleteMemory,
deleteShortMemory,
saveLongTermMemoryData,
saveMemoryData
} from '@renderer/store/memory'
// 记忆去重与合并的结果接口
export interface DeduplicationResult {
similarGroups: {
groupId: string
memoryIds: string[]
mergedContent: string
category?: string
importance?: number // 新增重要性评分
keywords?: string[] // 新增关键词
}[]
independentMemories: string[]
rawResponse: string
}
/**
* 分析记忆库中的相似记忆,提供智能合并建议
* @param listId 可选的列表ID如果不提供则处理所有列表
* @param isShortMemory 是否处理短期记忆
* @param topicId 当处理短期记忆时可选的话题ID
* @returns 去重分析结果
*/
export const deduplicateAndMergeMemories = async (
listId?: string,
isShortMemory: boolean = false,
topicId?: string
): Promise<DeduplicationResult | null> => {
// 获取需要处理的记忆
const state = store.getState()
let targetMemories: any[] = []
if (isShortMemory) {
// 处理短期记忆
const shortMemories = state.memory?.shortMemories || []
targetMemories = topicId ? shortMemories.filter((memory) => memory.topicId === topicId) : shortMemories
} else {
// 处理长期记忆
const memories = state.memory?.memories || []
targetMemories = listId ? memories.filter((memory) => memory.listId === listId) : memories
}
if (targetMemories.length < 2) {
console.log('[Memory Deduplication] Not enough memories to deduplicate')
return null
}
const memoryType = isShortMemory ? 'short memories' : 'memories'
console.log(`[Memory Deduplication] Starting deduplication for ${targetMemories.length} ${memoryType}`)
// 构建去重提示词
const memoriesToCheck = targetMemories
.map((memory, index) => {
if (isShortMemory) {
return `${index + 1}. 短期记忆: ${memory.content}`
} else {
return `${index + 1}. ${memory.category || '其他'}: ${memory.content}`
}
})
.join('\n')
const prompt = `
请仔细分析以下记忆项,识别语义相似或包含重复信息的条目,并提供智能合并建议。
相似度判断标准:
1. 语义相似:即使表述不同,但表达相同或非常相似的意思
2. 内容重叠:一个记忆项包含另一个记忆项的大部分信息
3. 主题相同:描述同一个主题或事件的不同方面
记忆项列表:
${memoriesToCheck}
例如,以下记忆应被视为相似:
- "用户喜欢简洁的界面设计"和"用户偏好简单直观的UI"
- "用户正在开发一个网站项目"和"用户在进行网站开发工作"
- "用户正在准备完成一个项目"和"用户正在进行一个项目的工作"
请按以下格式返回结果:
1. 识别出的相似组:
- 组1: [记忆项编号,如"1,5,8"] - 合并建议: "合并后的内容" - 分类: "最合适的分类"
- 组2: [记忆项编号] - 合并建议: "合并后的内容" - 分类: "最合适的分类"
...
2. 独立记忆项: [不需要合并的记忆项编号]
合并建议要求:
- 保留所有非重复的有价值信息
- 使用简洁清晰的语言
- 确保合并后的内容比原始记忆更加全面和准确
- 如果记忆项之间有细微差异,请在合并内容中保留这些差异
如果没有发现相似记忆,请返回"未发现相似记忆"。
`
try {
// 使用AI模型进行去重分析
const analyzeModel = state.memory?.analyzeModel
if (!analyzeModel) {
console.log('[Memory Deduplication] No analyze model set')
return null
}
console.log('[Memory Deduplication] Calling AI model for analysis...')
const result = await fetchGenerate({
prompt: prompt,
content: memoriesToCheck,
modelId: analyzeModel
})
if (!result) {
console.log('[Memory Deduplication] No result from AI analysis')
return null
}
console.log('[Memory Deduplication] Analysis result:', result)
// 解析结果
const similarGroups: DeduplicationResult['similarGroups'] = []
const independentMemories: string[] = []
// 检查是否没有发现相似记忆
if (result.includes('未发现相似记忆')) {
console.log('[Memory Deduplication] No similar memories found')
return {
similarGroups: [],
independentMemories: targetMemories.map((_, index) => String(index + 1)),
rawResponse: result
}
}
// 解析相似组
const similarGroupsMatch = result.match(/1\.\s*识别出的相似组:([\s\S]*?)(?=2\.\s*独立记忆项:|$)/i)
if (similarGroupsMatch && similarGroupsMatch[1]) {
const groupsText = similarGroupsMatch[1].trim()
// 更新正则表达式以匹配新的格式,包括重要性和关键词
const groupRegex =
/-\s*组(\d+)?:\s*\[([\d,\s]+)\]\s*-\s*合并建议:\s*"([^"]+)"\s*-\s*分类:\s*"([^"]+)"\s*(?:-\s*重要性:\s*"([^"]+)")?\s*(?:-\s*关键词:\s*"([^"]+)")?/g
let match: RegExpExecArray | null
while ((match = groupRegex.exec(groupsText)) !== null) {
const groupId = match[1] || String(similarGroups.length + 1)
const memoryIndices = match[2].split(',').map((s: string) => s.trim())
const mergedContent = match[3].trim()
const category = match[4]?.trim()
const importance = match[5] ? parseFloat(match[5].trim()) : undefined
const keywords = match[6]
? match[6]
.trim()
.split(',')
.map((k: string) => k.trim())
: undefined
similarGroups.push({
groupId,
memoryIds: memoryIndices,
mergedContent,
category,
importance,
keywords
})
}
}
// 解析独立记忆项
const independentMatch = result.match(/2\.\s*独立记忆项:\s*\[([\d,\s]+)\]/i)
if (independentMatch && independentMatch[1]) {
independentMemories.push(...independentMatch[1].split(',').map((s: string) => s.trim()))
}
console.log('[Memory Deduplication] Parsed result:', { similarGroups, independentMemories })
return {
similarGroups,
independentMemories,
rawResponse: result
}
} catch (error) {
console.error('[Memory Deduplication] Error during deduplication:', error)
return null
}
}
// 已在顶部导入saveMemoryData和saveLongTermMemoryData
/**
* 应用去重结果,合并相似记忆
* @param result 去重分析结果
* @param autoApply 是否自动应用合并结果
* @param isShortMemory 是否处理短期记忆
*/
export const applyDeduplicationResult = async (
result: DeduplicationResult,
autoApply: boolean = false,
isShortMemory: boolean = false
) => {
if (!result || !result.similarGroups || result.similarGroups.length === 0) {
console.log('[Memory Deduplication] No similar groups to apply')
return
}
const state = store.getState()
const memories = isShortMemory ? state.memory?.shortMemories || [] : state.memory?.memories || []
// 处理每个相似组
for (const group of result.similarGroups) {
// 获取组中的记忆
const memoryIndices = group.memoryIds.map((id) => parseInt(id) - 1)
const groupMemories = memoryIndices.map((index) => memories[index]).filter(Boolean)
if (groupMemories.length < 2) continue
// 获取第一个记忆的列表ID和其他属性
const firstMemory = groupMemories[0]
// 收集所有已分析过的消息ID
const allAnalyzedMessageIds = new Set<string>()
groupMemories.forEach((memory) => {
if (memory.analyzedMessageIds) {
memory.analyzedMessageIds.forEach((id) => allAnalyzedMessageIds.add(id))
}
})
// 找出最新的lastMessageId
let lastMessageId: string | undefined
groupMemories.forEach((memory) => {
if (memory.lastMessageId) {
if (!lastMessageId || new Date(memory.createdAt) > new Date(lastMessageId)) {
lastMessageId = memory.lastMessageId
}
}
})
// 找出所有关联的话题ID
const topicIds = new Set<string>()
groupMemories.forEach((memory) => {
if (memory.topicId) {
topicIds.add(memory.topicId)
}
})
// 如果自动应用,则添加合并后的记忆并删除原记忆
if (autoApply) {
if (isShortMemory) {
// 处理短期记忆
// 添加合并后的短期记忆
const topicId = topicIds.size === 1 ? Array.from(topicIds)[0] : undefined
if (topicId) {
store.dispatch(
addShortMemory({
content: group.mergedContent,
topicId: topicId,
analyzedMessageIds: Array.from(allAnalyzedMessageIds),
lastMessageId: lastMessageId,
importance: group.importance, // 添加重要性评分
keywords: group.keywords // 添加关键词
})
)
// 删除原短期记忆
for (const memory of groupMemories) {
store.dispatch(deleteShortMemory(memory.id))
}
}
} else {
// 处理长期记忆
// 安全地获取 listId 和 category因为它们只存在于 Memory 类型
const listId = 'listId' in firstMemory ? firstMemory.listId : undefined
const memoryCategory = 'category' in firstMemory ? firstMemory.category : undefined
// 添加合并后的记忆
store.dispatch(
addMemory({
content: group.mergedContent,
source: '自动合并',
category: group.category || memoryCategory || '其他', // 使用安全获取的 category
listId: listId, // 使用安全获取的 listId
analyzedMessageIds: Array.from(allAnalyzedMessageIds),
lastMessageId: lastMessageId,
topicId: topicIds.size === 1 ? Array.from(topicIds)[0] : undefined,
importance: group.importance, // 添加重要性评分
keywords: group.keywords // 添加关键词
})
)
// 删除原记忆
for (const memory of groupMemories) {
store.dispatch(deleteMemory(memory.id))
}
}
console.log(`[Memory Deduplication] Applied group ${group.groupId}: merged ${groupMemories.length} memories`)
}
}
// 合并完成后,将更改保存到文件
if (autoApply) {
try {
// 获取最新的状态
const currentState = store.getState().memory
// 保存到文件
if (isShortMemory) {
// 短期记忆使用saveMemoryData
await store
.dispatch(
saveMemoryData({
shortMemories: currentState.shortMemories
})
)
.unwrap()
console.log('[Memory Deduplication] Short memories saved to file after merging')
} else {
// 长期记忆使用saveLongTermMemoryData
await store
.dispatch(
saveLongTermMemoryData({
memories: currentState.memories,
memoryLists: currentState.memoryLists,
currentListId: currentState.currentListId,
analyzeModel: currentState.analyzeModel
})
)
.unwrap()
console.log('[Memory Deduplication] Long-term memories saved to file after merging')
}
} catch (error) {
console.error('[Memory Deduplication] Failed to save memory data after merging:', error)
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
import SearchPopup from '@renderer/components/Popups/SearchPopup'
import { DEFAULT_CONTEXTCOUNT } from '@renderer/config/constant'
import db from '@renderer/databases'
import { getTopicById } from '@renderer/hooks/useTopic'
import i18n from '@renderer/i18n'
import { fetchMessagesSummary } from '@renderer/services/ApiService'
@@ -200,6 +201,40 @@ export function getMessageModelId(message: Message) {
return message?.model?.id || message.modelId
}
/**
* 根据消息ID查找消息
* @param messageId 消息ID
* @returns 找到的消息如果未找到则返回null
*/
export async function findMessageById(messageId: string): Promise<Message | null> {
console.log(`[findMessageById] 正在查找消息ID: ${messageId}`)
try {
// 获取所有话题
const topics = await db.topics.toArray()
console.log(`[findMessageById] 找到 ${topics.length} 个话题`)
// 遍历所有话题,查找消息
for (const topic of topics) {
if (!topic.messages || topic.messages.length === 0) {
continue
}
const message = topic.messages.find((msg) => msg.id === messageId)
if (message) {
console.log(`[findMessageById] 在话题 ${topic.id} 中找到消息`)
return message
}
}
console.log(`[findMessageById] 未找到消息ID: ${messageId}`)
return null
} catch (error) {
console.error(`[findMessageById] 查找消息时出错:`, error)
return null
}
}
export function resetAssistantMessage(message: Message, model?: Model): Message {
return {
...message,

View File

@@ -0,0 +1,260 @@
// src/renderer/src/services/VectorService.ts
// 导入Memory和ShortMemory接口
interface Memory {
id: string
content: string
createdAt: string
source?: string
category?: string
listId: string
analyzedMessageIds?: string[]
lastMessageId?: string
topicId?: string
vectorRepresentation?: number[]
entities?: string[]
keywords?: string[]
importance?: number
accessCount?: number
lastAccessedAt?: string
}
interface ShortMemory {
id: string
content: string
createdAt: string
topicId: string
analyzedMessageIds?: string[]
lastMessageId?: string
vectorRepresentation?: number[]
entities?: string[]
keywords?: string[]
importance?: number
}
// TODO: Import necessary API clients or libraries for vector embedding (e.g., OpenAI)
/**
* 计算两个向量之间的余弦相似度
* @param vecA - 第一个向量
* @param vecB - 第二个向量
* @returns 余弦相似度值 (-1 到 1)
*/
function cosineSimilarity(vecA: number[], vecB: number[]): number {
if (!vecA || !vecB || vecA.length !== vecB.length || vecA.length === 0) {
// console.error('Invalid vectors for cosine similarity calculation.', vecA, vecB)
return 0 // 或者抛出错误,取决于错误处理策略
}
let dotProduct = 0.0
let normA = 0.0
let normB = 0.0
for (let i = 0; i < vecA.length; i++) {
dotProduct += vecA[i] * vecB[i]
normA += vecA[i] * vecA[i]
normB += vecB[i] * vecB[i]
}
if (normA === 0 || normB === 0) {
// console.warn('Zero vector encountered in cosine similarity calculation.')
return 0 // 避免除以零
}
return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB))
}
// 简单的内存缓存来存储向量表示
const vectorCache = new Map<string, number[]>()
/**
* VectorService 类负责处理记忆内容的向量化和相似度计算
*/
class VectorService {
/**
* 获取给定文本的向量表示。
* 优先从缓存获取否则调用API生成。
* @param text - 需要向量化的文本
* @param modelId - 使用的向量化模型ID (TODO: 需要从设置或状态中获取)
* @returns 文本的向量表示 (number[]) 或 null (如果失败)
*/
async getVector(text: string, modelId: string = 'text-embedding-ada-002'): Promise<number[] | null> {
if (!text || text.trim() === '') {
return null
}
const cacheKey = `${modelId}:${text}`
if (vectorCache.has(cacheKey)) {
return vectorCache.get(cacheKey)!
}
try {
// TODO: 实现调用向量化API的逻辑
console.log(`[VectorService] Requesting vector for text (length: ${text.length})...`)
// 示例: const response = await openai.embeddings.create({ model: modelId, input: text });
// const vector = response?.data?.[0]?.embedding;
// --- 占位符逻辑 ---
// 实际应调用 API 获取向量
// 这里生成一个随机向量作为占位符,维度需与模型一致
const placeholderVector = Array.from({ length: 1536 }, () => Math.random() * 2 - 1) // 假设 ada-002 是 1536 维
const vector = placeholderVector
// --- 占位符结束 ---
if (vector) {
vectorCache.set(cacheKey, vector)
console.log(`[VectorService] Vector obtained and cached for text (length: ${text.length}).`)
return vector
} else {
console.error('[VectorService] Failed to get vector embedding.')
return null
}
} catch (error) {
console.error('[VectorService] Error getting vector embedding:', error)
return null
}
}
/**
* 确保一个记忆项具有向量表示。
* 如果没有,则尝试生成并更新。
* @param memory - 记忆项 (Memory 或 ShortMemory)
* @returns 更新后的记忆项 (如果成功生成向量) 或原记忆项
*/
async ensureVectorRepresentation(memory: Memory | ShortMemory): Promise<Memory | ShortMemory> {
if (memory.vectorRepresentation && memory.vectorRepresentation.length > 0) {
return memory // 已经有向量了
}
// 从状态或设置中获取 vectorizeModel
const vectorizeModel = 'text-embedding-ada-002' // 暂时硬编码
const vector = await this.getVector(memory.content, vectorizeModel)
if (vector) {
return { ...memory, vectorRepresentation: vector }
}
return memory // 无法生成向量,返回原样
}
/**
* 计算两个记忆项之间的语义相似度。
* @param memoryA - 第一个记忆项
* @param memoryB - 第二个记忆项
* @returns 相似度分数 (0 到 1) 或 0 (如果无法计算)
*/
async calculateSimilarity(memoryA: Memory | ShortMemory, memoryB: Memory | ShortMemory): Promise<number> {
try {
const memoryAWithVector = await this.ensureVectorRepresentation(memoryA)
const memoryBWithVector = await this.ensureVectorRepresentation(memoryB)
if (
memoryAWithVector.vectorRepresentation &&
memoryBWithVector.vectorRepresentation &&
memoryAWithVector.vectorRepresentation.length > 0 &&
memoryBWithVector.vectorRepresentation.length > 0
) {
const similarity = cosineSimilarity(
memoryAWithVector.vectorRepresentation,
memoryBWithVector.vectorRepresentation
)
// 将余弦相似度 (-1 到 1) 映射到 0 到 1 范围 (可选,但通常更直观)
return (similarity + 1) / 2
} else {
// console.warn('[VectorService] Could not calculate similarity due to missing vectors.')
return 0
}
} catch (error) {
console.error('[VectorService] Error calculating similarity:', error)
return 0
}
}
/**
* 查找与给定记忆最相似的记忆项列表。
* @param targetMemory - 目标记忆项
* @param candidates - 候选记忆项列表
* @param topN - 返回最相似的 N 个结果
* @param threshold - 相似度阈值 (0 到 1)
* @returns 最相似的记忆项列表及其相似度分数
*/
async findSimilarMemories(
targetMemory: Memory | ShortMemory,
candidates: (Memory | ShortMemory)[],
topN: number = 5,
threshold: number = 0.7 // 默认阈值
): Promise<{ memory: Memory | ShortMemory; similarity: number }[]> {
const targetMemoryWithVector = await this.ensureVectorRepresentation(targetMemory)
if (!targetMemoryWithVector.vectorRepresentation || targetMemoryWithVector.vectorRepresentation.length === 0) {
console.warn('[VectorService] Target memory has no vector representation. Cannot find similar memories.')
return []
}
const results: { memory: Memory | ShortMemory; similarity: number }[] = []
for (const candidate of candidates) {
// 排除目标记忆自身
if (candidate.id === targetMemory.id) {
continue
}
const similarity = await this.calculateSimilarity(targetMemoryWithVector, candidate)
if (similarity >= threshold) {
results.push({ memory: candidate, similarity })
}
}
// 按相似度降序排序
results.sort((a, b) => b.similarity - a.similarity)
// 返回前 N 个结果
return results.slice(0, topN)
}
/**
* 计算查询文本与一组记忆项的相似度。
* @param queryText - 查询文本
* @param candidates - 候选记忆项列表
* @param topN - 返回最相似的 N 个结果
* @param threshold - 相似度阈值 (0 到 1)
* @returns 最相似的记忆项列表及其相似度分数
*/
async findSimilarMemoriesToQuery(
queryText: string,
candidates: (Memory | ShortMemory)[],
topN: number = 10,
threshold: number = 0.7
): Promise<{ memory: Memory | ShortMemory; similarity: number }[]> {
const queryVector = await this.getVector(queryText)
if (!queryVector) {
console.warn('[VectorService] Could not get vector for query text. Cannot find similar memories.')
return []
}
const results: { memory: Memory | ShortMemory; similarity: number }[] = []
for (const candidate of candidates) {
const candidateWithVector = await this.ensureVectorRepresentation(candidate)
if (candidateWithVector.vectorRepresentation && candidateWithVector.vectorRepresentation.length > 0) {
const similarity = cosineSimilarity(queryVector, candidateWithVector.vectorRepresentation)
const normalizedSimilarity = (similarity + 1) / 2 // 归一化到 0-1
if (normalizedSimilarity >= threshold) {
results.push({ memory: candidate, similarity: normalizedSimilarity })
}
}
}
results.sort((a, b) => b.similarity - a.similarity)
return results.slice(0, topN)
}
/**
* 清空向量缓存
*/
clearCache(): void {
vectorCache.clear()
console.log('[VectorService] Vector cache cleared.')
}
}
// 导出 VectorService 的单例
export const vectorService = new VectorService()

View File

@@ -10,6 +10,7 @@ import copilot from './copilot'
import knowledge from './knowledge'
import llm from './llm'
import mcp from './mcp'
import memory from './memory' // Removed import of memoryPersistenceMiddleware
import messagesReducer from './messages'
import migrate from './migrate'
import minapps from './minapps'
@@ -35,6 +36,7 @@ const rootReducer = combineReducers({
websearch,
mcp,
copilot,
memory,
messages: messagesReducer
})
@@ -42,8 +44,8 @@ const persistedReducer = persistReducer(
{
key: 'cherry-studio',
storage,
version: 95,
blacklist: ['runtime', 'messages'],
version: 96,
blacklist: ['runtime', 'messages', 'memory'],
migrate
},
rootReducer
@@ -57,7 +59,7 @@ const store = configureStore({
serializableCheck: {
ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER]
}
})
}) // Removed concat of memoryPersistenceMiddleware
},
devTools: true
})

View File

@@ -541,7 +541,7 @@ export const moveProvider = (providers: Provider[], id: string, position: number
return newProviders
}
const settingsSlice = createSlice({
const llmSlice = createSlice({
name: 'llm',
initialState: isLocalAi ? getIntegratedInitialState() : initialState,
reducers: {
@@ -632,6 +632,6 @@ export const {
setLMStudioKeepAliveTime,
setGPUStackKeepAliveTime,
updateModel
} = settingsSlice.actions
} = llmSlice.actions
export default settingsSlice.reducer
export default llmSlice.reducer

View File

@@ -97,6 +97,14 @@ export const builtinMCPServers: MCPServer[] = [
type: 'inMemory',
description: '实现文件系统操作的模型上下文协议MCP的 Node.js 服务器',
isActive: false
},
{
id: nanoid(),
name: '@cherry/simpleremember',
type: 'inMemory',
description:
'自动记忆工具,功能跟上面的记忆工具差不多。这个记忆会自动应用到对话中,无需显式调用。适合记住用户偏好、项目背景等长期有用信息.可以跨对话。',
isActive: true
}
]

File diff suppressed because it is too large Load Diff

View File

@@ -391,14 +391,34 @@ export const sendMessage =
})
} catch (error: any) {
console.error('Error in chat completion:', error)
// 添加检查,防止意外的错误消息被保存
const errorMessage =
typeof error?.message === 'string'
? error.message
: 'An unexpected error occurred during chat completion.'
// 检查是否是我们不希望保存的特定字符串,如果是,替换为通用错误
let finalErrorMessage = errorMessage
// 检查多种可能的 rememberInstructions 错误形式
if (
errorMessage === 'rememberInstructions is not defined' ||
(typeof errorMessage === 'string' && errorMessage.includes('rememberInstructions'))
) {
console.warn('Detected and sanitized rememberInstructions error')
finalErrorMessage = 'An unexpected error occurred.'
}
dispatch(
updateMessageThunk(topic.id, assistantMessage.id, {
status: 'error',
error: { message: error.message }
// 使用处理过的错误消息
error: { message: finalErrorMessage }
})
)
dispatch(clearStreamMessage({ topicId: topic.id, messageId: assistantMessage.id }))
dispatch(setError(error.message))
// setError 也使用处理过的消息
dispatch(setError(finalErrorMessage))
}
})
}

View File

@@ -48,6 +48,7 @@ export interface SettingsState {
clickAssistantToShowTopic: boolean
autoCheckUpdate: boolean
renderInputMessageAsMarkdown: boolean
enableHistoricalContext: boolean // 是否启用历史对话上下文功能
codeShowLineNumbers: boolean
codeCollapsible: boolean
codeWrappable: boolean
@@ -153,6 +154,7 @@ export const initialState: SettingsState = {
clickAssistantToShowTopic: true,
autoCheckUpdate: true,
renderInputMessageAsMarkdown: false,
enableHistoricalContext: false, // 默认禁用历史对话上下文功能
codeShowLineNumbers: false,
codeCollapsible: false,
codeWrappable: false,
@@ -306,6 +308,10 @@ const settingsSlice = createSlice({
setRenderInputMessageAsMarkdown: (state, action: PayloadAction<boolean>) => {
state.renderInputMessageAsMarkdown = action.payload
},
setEnableHistoricalContext: (state, action: PayloadAction<boolean>) => {
state.enableHistoricalContext = action.payload
},
setClickAssistantToShowTopic: (state, action: PayloadAction<boolean>) => {
state.clickAssistantToShowTopic = action.payload
},
@@ -512,6 +518,7 @@ export const {
setPasteLongTextAsFile,
setAutoCheckUpdate,
setRenderInputMessageAsMarkdown,
setEnableHistoricalContext,
setClickAssistantToShowTopic,
setWebdavHost,
setWebdavUser,

View File

@@ -71,6 +71,13 @@ export type Message = {
useful?: boolean
error?: Record<string, any>
enabledMCPs?: MCPServer[]
// 引用消息
referencedMessages?: {
id: string
content: string
role: 'user' | 'assistant'
createdAt: string
}[]
metadata?: {
// Gemini
groundingMetadata?: GroundingMetadata
@@ -402,6 +409,34 @@ export interface MCPTool {
inputSchema: MCPToolInputSchema
}
export interface MCPPromptArguments {
name: string
description?: string
required?: boolean
}
export interface MCPPrompt {
id: string
name: string
description?: string
arguments?: MCPPromptArguments[]
serverId: string
serverName: string
}
export interface GetMCPPromptResponse {
description?: string
messages: {
role: string
content: {
type: 'text' | 'image' | 'audio' | 'resource'
text?: string
data?: string
mimeType?: string
}
}[]
}
export interface MCPConfig {
servers: MCPServer[]
}

View File

@@ -28,17 +28,59 @@ export function getErrorDetails(err: any, seen = new WeakSet()): any {
export function formatErrorMessage(error: any): string {
console.error('Original error:', error)
// 检查已知的问题错误对象
if (typeof error === 'object' && error !== null) {
// 特别检查 rememberInstructions 错误
if (error.message === 'rememberInstructions is not defined') {
console.warn('Formatting known corrupted error message from storage.')
// 返回安全的通用错误消息
return '```\nError: A previously recorded error message could not be displayed.\n```'
}
// 检查错误对象中是否包含 rememberInstructions 字符串
if (JSON.stringify(error).includes('rememberInstructions')) {
console.warn('Detected potential rememberInstructions issue in error object')
return '```\nError: An error occurred while processing the message.\n```'
}
// 处理网络错误
if (error.message === 'network error') {
console.warn('Network error detected')
return '```\nError: 网络连接错误,请检查您的网络连接并重试\n```'
}
// 处理其他网络相关错误
if (
typeof error.message === 'string' &&
(error.message.includes('network') ||
error.message.includes('timeout') ||
error.message.includes('connection') ||
error.message.includes('ECONNREFUSED'))
) {
console.warn('Network-related error detected:', error.message)
return '```\nError: 网络连接问题\n```'
}
}
try {
const detailedError = getErrorDetails(error)
delete detailedError?.headers
delete detailedError?.stack
delete detailedError?.request_id
return '```json\n' + JSON.stringify(detailedError, null, 2) + '\n```'
} catch (e) {
// Ensure stringification is safe
try {
return '```json\n' + JSON.stringify(detailedError, null, 2) + '\n```'
} catch (stringifyError) {
console.error('Error stringifying detailed error:', stringifyError)
return '```\nError: Unable to stringify detailed error message.\n```'
}
} catch (getDetailsError) {
console.error('Error getting error details:', getDetailsError)
// Fallback to simple string conversion if getErrorDetails fails
try {
return '```\n' + String(error) + '\n```'
} catch {
return 'Error: Unable to format error message'
return '```\nError: Unable to format error message.\n```'
}
}
}

View File

@@ -21,7 +21,7 @@ import { addMCPServer } from '@renderer/store/mcp'
import { MCPServer, MCPTool, MCPToolResponse } from '@renderer/types'
import { ChatCompletionMessageToolCall, ChatCompletionTool } from 'openai/resources'
import { ChunkCallbackData } from '../providers/AiProvider'
import { ChunkCallbackData, CompletionsParams } from '../providers/AiProvider'
const ensureValidSchema = (obj: Record<string, any>): FunctionDeclarationSchemaProperty => {
// Filter out unsupported keys for Gemini
@@ -375,3 +375,87 @@ export function getMcpServerByTool(tool: MCPTool) {
const servers = store.getState().mcp.servers
return servers.find((s) => s.id === tool.serverId)
}
export function parseToolUse(content: string, mcpTools: MCPTool[]): MCPToolResponse[] {
if (!content || !mcpTools || mcpTools.length === 0) {
return []
}
const toolUsePattern =
/<tool_use>([\s\S]*?)<name>([\s\S]*?)<\/name>([\s\S]*?)<arguments>([\s\S]*?)<\/arguments>([\s\S]*?)<\/tool_use>/g
const tools: MCPToolResponse[] = []
let match
let idx = 0
// Find all tool use blocks
while ((match = toolUsePattern.exec(content)) !== null) {
// const fullMatch = match[0]
const toolName = match[2].trim()
const toolArgs = match[4].trim()
// Try to parse the arguments as JSON
let parsedArgs
try {
parsedArgs = JSON.parse(toolArgs)
} catch (error) {
// If parsing fails, use the string as is
parsedArgs = toolArgs
}
// console.log(`Parsed arguments for tool "${toolName}":`, parsedArgs)
const mcpTool = mcpTools.find((tool) => tool.id === toolName)
if (!mcpTool) {
console.error(`Tool "${toolName}" not found in MCP tools`)
continue
}
// Add to tools array
tools.push({
id: `${toolName}-${idx++}`, // Unique ID for each tool use
tool: {
...mcpTool,
inputSchema: parsedArgs
},
status: 'pending'
})
// Remove the tool use block from the content
// content = content.replace(fullMatch, '')
}
return tools
}
export async function parseAndCallTools(
content: string,
toolResponses: MCPToolResponse[],
onChunk: CompletionsParams['onChunk'],
idx: number,
mcpTools?: MCPTool[]
): Promise<string[]> {
const toolResults: string[] = []
// process tool use
const tools = parseToolUse(content, mcpTools || [])
if (!tools || tools.length === 0) {
return toolResults
}
for (let i = 0; i < tools.length; i++) {
const tool = tools[i]
upsertMCPToolResponse(toolResponses, { id: `${tool.id}-${idx}-${i}`, tool: tool.tool, status: 'invoking' }, onChunk)
}
const toolPromises = tools.map(async (tool, i) => {
const toolCallResponse = await callMCPTool(tool.tool)
const result = `
<tool_use_result>
<name>${tool.id}</name>
<result>${JSON.stringify(toolCallResponse)}</result>
</tool_use_result>
`.trim()
upsertMCPToolResponse(
toolResponses,
{ id: `${tool.id}-${idx}-${i}`, tool: tool.tool, status: 'done', response: toolCallResponse },
onChunk
)
return result
})
toolResults.push(...(await Promise.all(toolPromises)))
return toolResults
}

View File

@@ -0,0 +1,201 @@
import { MCPTool } from '@renderer/types'
export const SYSTEM_PROMPT = `In this environment you have access to a set of tools you can use to answer the user's question. \
You can use one tool per message, and will receive the result of that tool use in the user's response. You use tools step-by-step to accomplish a given task, with each tool use informed by the result of the previous tool use.
## Tool Use Formatting
Tool use is formatted using XML-style tags. The tool name is enclosed in opening and closing tags, and each parameter is similarly enclosed within its own set of tags. Here's the structure:
<tool_use>
<name>{tool_name}</name>
<arguments>{json_arguments}</arguments>
</tool_use>
The tool name should be the exact name of the tool you are using, and the arguments should be a JSON object containing the parameters required by that tool. For example:
<tool_use>
<name>python_interpreter</name>
<arguments>{"code": "5 + 3 + 1294.678"}</arguments>
</tool_use>
The user will respond with the result of the tool use, which should be formatted as follows:
<tool_use_result>
<name>{tool_name}</name>
<result>{result}</result>
</tool_use_result>
The result should be a string, which can represent a file or any other output type. You can use this result as input for the next action.
For example, if the result of the tool use is an image file, you can use it in the next action like this:
<tool_use>
<name>image_transformer</name>
<arguments>{"image": "image_1.jpg"}</arguments>
</tool_use>
Always adhere to this format for the tool use to ensure proper parsing and execution.
## Tool Use Examples
{{ TOOL_USE_EXAMPLES }}
## Tool Use Available Tools
Above example were using notional tools that might not exist for you. You only have access to these tools:
{{ AVAILABLE_TOOLS }}
## Tool Use Rules
Here are the rules you should always follow to solve your task:
1. Always use the right arguments for the tools. Never use variable names as the action arguments, use the value instead.
2. Call a tool only when needed: do not call the search agent if you do not need information, try to solve the task yourself.
3. If no tool call is needed, just answer the question directly.
4. Never re-do a tool call that you previously did with the exact same parameters.
5. For tool use, MARK SURE use XML tag format as shown in the examples above. Do not use any other format.
# User Instructions
{{ USER_SYSTEM_PROMPT }}
Now Begin! If you solve the task correctly, you will receive a reward of $1,000,000.
`
export const ToolUseExamples = `
Here are a few examples using notional tools:
---
User: Generate an image of the oldest person in this document.
Assistant: I can use the document_qa tool to find out who the oldest person is in the document.
<tool_use>
<name>document_qa</name>
<arguments>{"document": "document.pdf", "question": "Who is the oldest person mentioned?"}</arguments>
</tool_use>
User: <tool_use_result>
<name>document_qa</name>
<result>John Doe, a 55 year old lumberjack living in Newfoundland.</result>
</tool_use_result>
Assistant: I can use the image_generator tool to create a portrait of John Doe.
<tool_use>
<name>image_generator</name>
<arguments>{"prompt": "A portrait of John Doe, a 55-year-old man living in Canada."}</arguments>
</tool_use>
User: <tool_use_result>
<name>image_generator</name>
<result>image.png</result>
</tool_use_result>
Assistant: the image is generated as image.png
---
User: "What is the result of the following operation: 5 + 3 + 1294.678?"
Assistant: I can use the python_interpreter tool to calculate the result of the operation.
<tool_use>
<name>python_interpreter</name>
<arguments>{"code": "5 + 3 + 1294.678"}</arguments>
</tool_use>
User: <tool_use_result>
<name>python_interpreter</name>
<result>1302.678</result>
</tool_use_result>
Assistant: The result of the operation is 1302.678.
---
User: "Which city has the highest population , Guangzhou or Shanghai?"
Assistant: I can use the search tool to find the population of Guangzhou.
<tool_use>
<name>search</name>
<arguments>{"query": "Population Guangzhou"}</arguments>
</tool_use>
User: <tool_use_result>
<name>search</name>
<result>Guangzhou has a population of 15 million inhabitants as of 2021.</result>
</tool_use_result>
Assistant: I can use the search tool to find the population of Shanghai.
<tool_use>
<name>search</name>
<arguments>{"query": "Population Shanghai"}</arguments>
</tool_use>
User: <tool_use_result>
<name>search</name>
<result>26 million (2019)</result>
</tool_use_result>
Assistant: The population of Shanghai is 26 million, while Guangzhou has a population of 15 million. Therefore, Shanghai has the highest population.
`
export const AvailableTools = (tools: MCPTool[]) => {
const availableTools = tools
.map((tool) => {
return `
<tool>
<name>${tool.id}</name>
<description>${tool.description}</description>
<arguments>
${tool.inputSchema ? JSON.stringify(tool.inputSchema) : ''}
</arguments>
</tool>
`
})
.join('\n')
return `<tools>
${availableTools}
</tools>`
}
import { applyMemoriesToPrompt } from '@renderer/services/MemoryService'
import { MCPServer } from '@renderer/types'
import { getRememberedMemories } from './remember-utils'
export const buildSystemPrompt = async (
userSystemPrompt: string,
tools: MCPTool[],
mcpServers: MCPServer[] = []
): Promise<string> => {
// 获取MCP记忆
let mcpMemoriesPrompt = ''
try {
mcpMemoriesPrompt = await getRememberedMemories(mcpServers)
} catch (error) {
console.error('Error getting MCP memories:', error)
}
// 获取内置记忆
let appMemoriesPrompt = ''
try {
// 应用内置记忆功能
console.log('[Prompt] Applying app memories to prompt')
// 直接将用户系统提示词传递给 applyMemoriesToPrompt让它添加记忆
appMemoriesPrompt = await applyMemoriesToPrompt(userSystemPrompt)
console.log('[Prompt] App memories prompt length:', appMemoriesPrompt.length - userSystemPrompt.length)
} catch (error) {
console.error('Error applying app memories:', error)
// 如果应用 Redux 记忆失败,至少保留原始用户提示
appMemoriesPrompt = userSystemPrompt
}
// 添加记忆工具的使用说明
// 合并所有提示词
// 注意appMemoriesPrompt 已经包含 userSystemPrompt所以不需要再次添加
// 合并 app 记忆(已包含 user prompt和 mcp 记忆
const enhancedPrompt = appMemoriesPrompt + (mcpMemoriesPrompt ? `\n\n${mcpMemoriesPrompt}` : '')
let finalPrompt: string
if (tools && tools.length > 0) {
console.log('[Prompt] Final prompt with tools:', { promptLength: enhancedPrompt.length })
// Break down the chained replace calls to potentially help the parser
const availableToolsString = AvailableTools(tools)
let tempPrompt = SYSTEM_PROMPT.replace('{{ USER_SYSTEM_PROMPT }}', enhancedPrompt)
tempPrompt = tempPrompt.replace('{{ TOOL_USE_EXAMPLES }}', ToolUseExamples)
finalPrompt = tempPrompt.replace('{{ AVAILABLE_TOOLS }}', availableToolsString)
} else {
console.log('[Prompt] Final prompt without tools:', { promptLength: enhancedPrompt.length })
finalPrompt = enhancedPrompt // Assign enhancedPrompt when no tools are present
}
// Single return point for the function
return finalPrompt
} // Closing brace for the buildSystemPrompt function moved here

View File

@@ -0,0 +1,68 @@
// src/renderer/src/utils/remember-utils.ts
import { MCPServer } from '@renderer/types'
export async function getRememberedMemories(mcpServers: MCPServer[]): Promise<string> {
try {
// 查找simpleremember服务器
const rememberServer = mcpServers.find((server) => server.name === '@cherry/simpleremember' && server.isActive)
if (!rememberServer) {
console.log('[SimpleRemember] Server not found or not active')
return ''
}
console.log('[SimpleRemember] Found server:', rememberServer.name, 'isActive:', rememberServer.isActive)
// 调用get_memories工具
try {
console.log('[SimpleRemember] Calling get_memories tool...')
const response = await window.api.mcp.callTool({
server: rememberServer,
name: 'get_memories',
args: {}
})
console.log('[SimpleRemember] get_memories response:', response)
if (response.isError) {
console.error('[SimpleRemember] Error getting memories:', response)
return ''
}
// 解析记忆
// 根据MCP规范工具返回的是content数组而不是data
let memories = []
if (response.content && response.content.length > 0 && response.content[0].text) {
try {
memories = JSON.parse(response.content[0].text)
} catch (parseError) {
console.error('[SimpleRemember] Failed to parse memories JSON:', parseError)
return ''
}
} else if (response.data) {
// 兼容旧版本的返回格式
memories = response.data
}
console.log('[SimpleRemember] Parsed memories:', memories)
if (!Array.isArray(memories) || memories.length === 0) {
console.log('[SimpleRemember] No memories found or invalid format')
return ''
}
// 构建记忆提示词
// Add explicit type for memory item in map function
const memoryPrompt = memories.map((memory: { content: string }) => `- ${memory.content}`).join('\n')
console.log('[SimpleRemember] Generated memory prompt:', memoryPrompt)
return `\n\n用户的记忆:\n${memoryPrompt}`
} catch (toolError) {
console.error('[SimpleRemember] Error calling get_memories tool:', toolError)
return ''
}
} catch (error) {
console.error('[SimpleRemember] Error in getRememberedMemories:', error)
return ''
}
}

537
yarn.lock
View File

@@ -3304,6 +3304,38 @@ __metadata:
languageName: node
linkType: hard
"@types/d3-array@npm:*":
version: 3.2.1
resolution: "@types/d3-array@npm:3.2.1"
checksum: 10c0/38bf2c778451f4b79ec81a2288cb4312fe3d6449ecdf562970cc339b60f280f31c93a024c7ff512607795e79d3beb0cbda123bb07010167bce32927f71364bca
languageName: node
linkType: hard
"@types/d3-axis@npm:*":
version: 3.0.6
resolution: "@types/d3-axis@npm:3.0.6"
dependencies:
"@types/d3-selection": "npm:*"
checksum: 10c0/d756d42360261f44d8eefd0950c5bb0a4f67a46dd92069da3f723ac36a1e8cb2b9ce6347d836ef19d5b8aef725dbcf8fdbbd6cfbff676ca4b0642df2f78b599a
languageName: node
linkType: hard
"@types/d3-brush@npm:*":
version: 3.0.6
resolution: "@types/d3-brush@npm:3.0.6"
dependencies:
"@types/d3-selection": "npm:*"
checksum: 10c0/fd6e2ac7657a354f269f6b9c58451ffae9d01b89ccb1eb6367fd36d635d2f1990967215ab498e0c0679ff269429c57fad6a2958b68f4d45bc9f81d81672edc01
languageName: node
linkType: hard
"@types/d3-chord@npm:*":
version: 3.0.6
resolution: "@types/d3-chord@npm:3.0.6"
checksum: 10c0/c5a25eb5389db01e63faec0c5c2ec7cc41c494e9b3201630b494c4e862a60f1aa83fabbc33a829e7e1403941e3c30d206c741559b14406ac2a4239cfdf4b4c17
languageName: node
linkType: hard
"@types/d3-color@npm:*":
version: 3.1.3
resolution: "@types/d3-color@npm:3.1.3"
@@ -3311,7 +3343,31 @@ __metadata:
languageName: node
linkType: hard
"@types/d3-drag@npm:^3.0.7":
"@types/d3-contour@npm:*":
version: 3.0.6
resolution: "@types/d3-contour@npm:3.0.6"
dependencies:
"@types/d3-array": "npm:*"
"@types/geojson": "npm:*"
checksum: 10c0/e7d83e94719af4576ceb5ac7f277c5806f83ba6c3631744ae391cffc3641f09dfa279470b83053cd0b2acd6784e8749c71141d05bdffa63ca58ffb5b31a0f27c
languageName: node
linkType: hard
"@types/d3-delaunay@npm:*":
version: 6.0.4
resolution: "@types/d3-delaunay@npm:6.0.4"
checksum: 10c0/d154a8864f08c4ea23ecb9bdabcef1c406a25baa8895f0cb08a0ed2799de0d360e597552532ce7086ff0cdffa8f3563f9109d18f0191459d32bb620a36939123
languageName: node
linkType: hard
"@types/d3-dispatch@npm:*":
version: 3.0.6
resolution: "@types/d3-dispatch@npm:3.0.6"
checksum: 10c0/405eb7d0ec139fbf72fa6a43b0f3ca8a1f913bb2cb38f607827e63fca8d4393f021f32f3b96b33c93ddbd37789453a0b3624f14f504add5308fd9aec8a46dda0
languageName: node
linkType: hard
"@types/d3-drag@npm:*, @types/d3-drag@npm:^3.0.7":
version: 3.0.7
resolution: "@types/d3-drag@npm:3.0.7"
dependencies:
@@ -3320,6 +3376,59 @@ __metadata:
languageName: node
linkType: hard
"@types/d3-dsv@npm:*":
version: 3.0.7
resolution: "@types/d3-dsv@npm:3.0.7"
checksum: 10c0/c0f01da862465594c8a28278b51c850af3b4239cc22b14fd1a19d7a98f93d94efa477bf59d8071beb285dca45bf614630811451e18e7c52add3a0abfee0a1871
languageName: node
linkType: hard
"@types/d3-ease@npm:*":
version: 3.0.2
resolution: "@types/d3-ease@npm:3.0.2"
checksum: 10c0/aff5a1e572a937ee9bff6465225d7ba27d5e0c976bd9eacdac2e6f10700a7cb0c9ea2597aff6b43a6ed850a3210030870238894a77ec73e309b4a9d0333f099c
languageName: node
linkType: hard
"@types/d3-fetch@npm:*":
version: 3.0.7
resolution: "@types/d3-fetch@npm:3.0.7"
dependencies:
"@types/d3-dsv": "npm:*"
checksum: 10c0/3d147efa52a26da1a5d40d4d73e6cebaaa964463c378068062999b93ea3731b27cc429104c21ecbba98c6090e58ef13429db6399238c5e3500162fb3015697a0
languageName: node
linkType: hard
"@types/d3-force@npm:*":
version: 3.0.10
resolution: "@types/d3-force@npm:3.0.10"
checksum: 10c0/c82b459079a106b50e346c9b79b141f599f2fc4f598985a5211e72c7a2e20d35bd5dc6e91f306b323c8bfa325c02c629b1645f5243f1c6a55bd51bc85cccfa92
languageName: node
linkType: hard
"@types/d3-format@npm:*":
version: 3.0.4
resolution: "@types/d3-format@npm:3.0.4"
checksum: 10c0/3ac1600bf9061a59a228998f7cd3f29e85cbf522997671ba18d4d84d10a2a1aff4f95aceb143fa9960501c3ec351e113fc75884e6a504ace44dc1744083035ee
languageName: node
linkType: hard
"@types/d3-geo@npm:*":
version: 3.1.0
resolution: "@types/d3-geo@npm:3.1.0"
dependencies:
"@types/geojson": "npm:*"
checksum: 10c0/3745a93439038bb5b0b38facf435f7079812921d46406f5d38deaee59e90084ff742443c7ea0a8446df81a0d81eaf622fe7068cf4117a544bd4aa3b2dc182f88
languageName: node
linkType: hard
"@types/d3-hierarchy@npm:*":
version: 3.1.7
resolution: "@types/d3-hierarchy@npm:3.1.7"
checksum: 10c0/873711737d6b8e7b6f1dda0bcd21294a48f75024909ae510c5d2c21fad2e72032e0958def4d9f68319d3aaac298ad09c49807f8bfc87a145a82693b5208613c7
languageName: node
linkType: hard
"@types/d3-interpolate@npm:*":
version: 3.0.4
resolution: "@types/d3-interpolate@npm:3.0.4"
@@ -3329,6 +3438,50 @@ __metadata:
languageName: node
linkType: hard
"@types/d3-path@npm:*":
version: 3.1.1
resolution: "@types/d3-path@npm:3.1.1"
checksum: 10c0/2c36eb31ebaf2ce4712e793fd88087117976f7c4ed69cc2431825f999c8c77cca5cea286f3326432b770739ac6ccd5d04d851eb65e7a4dbcc10c982b49ad2c02
languageName: node
linkType: hard
"@types/d3-polygon@npm:*":
version: 3.0.2
resolution: "@types/d3-polygon@npm:3.0.2"
checksum: 10c0/f46307bb32b6c2aef8c7624500e0f9b518de8f227ccc10170b869dc43e4c542560f6c8d62e9f087fac45e198d6e4b623e579c0422e34c85baf56717456d3f439
languageName: node
linkType: hard
"@types/d3-quadtree@npm:*":
version: 3.0.6
resolution: "@types/d3-quadtree@npm:3.0.6"
checksum: 10c0/7eaa0a4d404adc856971c9285e1c4ab17e9135ea669d847d6db7e0066126a28ac751864e7ce99c65d526e130f56754a2e437a1617877098b3bdcc3ef23a23616
languageName: node
linkType: hard
"@types/d3-random@npm:*":
version: 3.0.3
resolution: "@types/d3-random@npm:3.0.3"
checksum: 10c0/5f4fea40080cd6d4adfee05183d00374e73a10c530276a6455348983dda341003a251def28565a27c25d9cf5296a33e870e397c9d91ff83fb7495a21c96b6882
languageName: node
linkType: hard
"@types/d3-scale-chromatic@npm:*":
version: 3.1.0
resolution: "@types/d3-scale-chromatic@npm:3.1.0"
checksum: 10c0/93c564e02d2e97a048e18fe8054e4a935335da6ab75a56c3df197beaa87e69122eef0dfbeb7794d4a444a00e52e3123514ee27cec084bd21f6425b7037828cc2
languageName: node
linkType: hard
"@types/d3-scale@npm:*":
version: 4.0.9
resolution: "@types/d3-scale@npm:4.0.9"
dependencies:
"@types/d3-time": "npm:*"
checksum: 10c0/4ac44233c05cd50b65b33ecb35d99fdf07566bcdbc55bc1306b2f27d1c5134d8c560d356f2c8e76b096e9125ffb8d26d95f78d56e210d1c542cb255bdf31d6c8
languageName: node
linkType: hard
"@types/d3-selection@npm:*, @types/d3-selection@npm:^3.0.10":
version: 3.0.11
resolution: "@types/d3-selection@npm:3.0.11"
@@ -3336,7 +3489,37 @@ __metadata:
languageName: node
linkType: hard
"@types/d3-transition@npm:^3.0.8":
"@types/d3-shape@npm:*":
version: 3.1.7
resolution: "@types/d3-shape@npm:3.1.7"
dependencies:
"@types/d3-path": "npm:*"
checksum: 10c0/38e59771c1c4c83b67aa1f941ce350410522a149d2175832fdc06396b2bb3b2c1a2dd549e0f8230f9f24296ee5641a515eaf10f55ee1ef6c4f83749e2dd7dcfd
languageName: node
linkType: hard
"@types/d3-time-format@npm:*":
version: 4.0.3
resolution: "@types/d3-time-format@npm:4.0.3"
checksum: 10c0/9ef5e8e2b96b94799b821eed5d61a3d432c7903247966d8ad951b8ce5797fe46554b425cb7888fa5bf604b4663c369d7628c0328ffe80892156671c58d1a7f90
languageName: node
linkType: hard
"@types/d3-time@npm:*":
version: 3.0.4
resolution: "@types/d3-time@npm:3.0.4"
checksum: 10c0/6d9e2255d63f7a313a543113920c612e957d70da4fb0890931da6c2459010291b8b1f95e149a538500c1c99e7e6c89ffcce5554dd29a31ff134a38ea94b6d174
languageName: node
linkType: hard
"@types/d3-timer@npm:*":
version: 3.0.2
resolution: "@types/d3-timer@npm:3.0.2"
checksum: 10c0/c644dd9571fcc62b1aa12c03bcad40571553020feeb5811f1d8a937ac1e65b8a04b759b4873aef610e28b8714ac71c9885a4d6c127a048d95118f7e5b506d9e1
languageName: node
linkType: hard
"@types/d3-transition@npm:*, @types/d3-transition@npm:^3.0.8":
version: 3.0.9
resolution: "@types/d3-transition@npm:3.0.9"
dependencies:
@@ -3345,7 +3528,7 @@ __metadata:
languageName: node
linkType: hard
"@types/d3-zoom@npm:^3.0.8":
"@types/d3-zoom@npm:*, @types/d3-zoom@npm:^3.0.8":
version: 3.0.8
resolution: "@types/d3-zoom@npm:3.0.8"
dependencies:
@@ -3355,6 +3538,44 @@ __metadata:
languageName: node
linkType: hard
"@types/d3@npm:^7":
version: 7.4.3
resolution: "@types/d3@npm:7.4.3"
dependencies:
"@types/d3-array": "npm:*"
"@types/d3-axis": "npm:*"
"@types/d3-brush": "npm:*"
"@types/d3-chord": "npm:*"
"@types/d3-color": "npm:*"
"@types/d3-contour": "npm:*"
"@types/d3-delaunay": "npm:*"
"@types/d3-dispatch": "npm:*"
"@types/d3-drag": "npm:*"
"@types/d3-dsv": "npm:*"
"@types/d3-ease": "npm:*"
"@types/d3-fetch": "npm:*"
"@types/d3-force": "npm:*"
"@types/d3-format": "npm:*"
"@types/d3-geo": "npm:*"
"@types/d3-hierarchy": "npm:*"
"@types/d3-interpolate": "npm:*"
"@types/d3-path": "npm:*"
"@types/d3-polygon": "npm:*"
"@types/d3-quadtree": "npm:*"
"@types/d3-random": "npm:*"
"@types/d3-scale": "npm:*"
"@types/d3-scale-chromatic": "npm:*"
"@types/d3-selection": "npm:*"
"@types/d3-shape": "npm:*"
"@types/d3-time": "npm:*"
"@types/d3-time-format": "npm:*"
"@types/d3-timer": "npm:*"
"@types/d3-transition": "npm:*"
"@types/d3-zoom": "npm:*"
checksum: 10c0/a9c6d65b13ef3b42c87f2a89ea63a6d5640221869f97d0657b0cb2f1dac96a0f164bf5605643c0794e0de3aa2bf05df198519aaf15d24ca135eb0e8bd8a9d879
languageName: node
linkType: hard
"@types/debug@npm:^4.0.0, @types/debug@npm:^4.1.6":
version: 4.1.12
resolution: "@types/debug@npm:4.1.12"
@@ -3406,6 +3627,13 @@ __metadata:
languageName: node
linkType: hard
"@types/geojson@npm:*":
version: 7946.0.16
resolution: "@types/geojson@npm:7946.0.16"
checksum: 10c0/1ff24a288bd5860b766b073ead337d31d73bdc715e5b50a2cee5cb0af57a1ed02cc04ef295f5fa68dc40fe3e4f104dd31282b2b818a5ba3231bc1001ba084e3c
languageName: node
linkType: hard
"@types/hast@npm:^3.0.0, @types/hast@npm:^3.0.4":
version: 3.0.4
resolution: "@types/hast@npm:3.0.4"
@@ -3925,6 +4153,7 @@ __metadata:
"@tavily/core": "patch:@tavily/core@npm%3A0.3.1#~/.yarn/patches/@tavily-core-npm-0.3.1-fe69bf2bea.patch"
"@tryfabric/martian": "npm:^1.2.4"
"@types/adm-zip": "npm:^0"
"@types/d3": "npm:^7"
"@types/diff": "npm:^7"
"@types/fs-extra": "npm:^11"
"@types/lodash": "npm:^4.17.5"
@@ -3942,10 +4171,12 @@ __metadata:
analytics: "npm:^0.8.16"
antd: "npm:^5.22.5"
applescript: "npm:^1.0.0"
async-mutex: "npm:^0.5.0"
axios: "npm:^1.7.3"
babel-plugin-styled-components: "npm:^2.1.4"
browser-image-compression: "npm:^2.0.2"
color: "npm:^5.0.0"
d3: "npm:^7.9.0"
dayjs: "npm:^1.11.11"
dexie: "npm:^4.0.8"
dexie-react-hooks: "npm:^1.1.7"
@@ -4477,6 +4708,15 @@ __metadata:
languageName: node
linkType: hard
"async-mutex@npm:^0.5.0":
version: 0.5.0
resolution: "async-mutex@npm:0.5.0"
dependencies:
tslib: "npm:^2.4.0"
checksum: 10c0/9096e6ad6b674c894d8ddd5aa4c512b09bb05931b8746ebd634952b05685608b2b0820ed5c406e6569919ff5fe237ab3c491e6f2887d6da6b6ba906db3ee9c32
languageName: node
linkType: hard
"async@npm:^3.2.3":
version: 3.2.6
resolution: "async@npm:3.2.6"
@@ -5395,6 +5635,13 @@ __metadata:
languageName: node
linkType: hard
"commander@npm:7":
version: 7.2.0
resolution: "commander@npm:7.2.0"
checksum: 10c0/8d690ff13b0356df7e0ebbe6c59b4712f754f4b724d4f473d3cc5b3fdcf978e3a5dc3078717858a2ceb50b0f84d0660a7f22a96cdc50fb877d0c9bb31593d23a
languageName: node
linkType: hard
"commander@npm:9.2.0":
version: 9.2.0
resolution: "commander@npm:9.2.0"
@@ -5678,21 +5925,77 @@ __metadata:
languageName: node
linkType: hard
"d3-color@npm:1 - 3":
"d3-array@npm:2 - 3, d3-array@npm:2.10.0 - 3, d3-array@npm:2.5.0 - 3, d3-array@npm:3, d3-array@npm:^3.2.0":
version: 3.2.4
resolution: "d3-array@npm:3.2.4"
dependencies:
internmap: "npm:1 - 2"
checksum: 10c0/08b95e91130f98c1375db0e0af718f4371ccacef7d5d257727fe74f79a24383e79aba280b9ffae655483ffbbad4fd1dec4ade0119d88c4749f388641c8bf8c50
languageName: node
linkType: hard
"d3-axis@npm:3":
version: 3.0.0
resolution: "d3-axis@npm:3.0.0"
checksum: 10c0/a271e70ba1966daa5aaf6a7f959ceca3e12997b43297e757c7b945db2e1ead3c6ee226f2abcfa22abbd4e2e28bd2b71a0911794c4e5b911bbba271328a582c78
languageName: node
linkType: hard
"d3-brush@npm:3":
version: 3.0.0
resolution: "d3-brush@npm:3.0.0"
dependencies:
d3-dispatch: "npm:1 - 3"
d3-drag: "npm:2 - 3"
d3-interpolate: "npm:1 - 3"
d3-selection: "npm:3"
d3-transition: "npm:3"
checksum: 10c0/07baf00334c576da2f68a91fc0da5732c3a5fa19bd3d7aed7fd24d1d674a773f71a93e9687c154176f7246946194d77c48c2d8fed757f5dcb1a4740067ec50a8
languageName: node
linkType: hard
"d3-chord@npm:3":
version: 3.0.1
resolution: "d3-chord@npm:3.0.1"
dependencies:
d3-path: "npm:1 - 3"
checksum: 10c0/baa6013914af3f4fe1521f0d16de31a38eb8a71d08ff1dec4741f6f45a828661e5cd3935e39bd14e3032bdc78206c283ca37411da21d46ec3cfc520be6e7a7ce
languageName: node
linkType: hard
"d3-color@npm:1 - 3, d3-color@npm:3":
version: 3.1.0
resolution: "d3-color@npm:3.1.0"
checksum: 10c0/a4e20e1115fa696fce041fbe13fbc80dc4c19150fa72027a7c128ade980bc0eeeba4bcf28c9e21f0bce0e0dbfe7ca5869ef67746541dcfda053e4802ad19783c
languageName: node
linkType: hard
"d3-dispatch@npm:1 - 3":
"d3-contour@npm:4":
version: 4.0.2
resolution: "d3-contour@npm:4.0.2"
dependencies:
d3-array: "npm:^3.2.0"
checksum: 10c0/98bc5fbed6009e08707434a952076f39f1cd6ed8b9288253cc3e6a3286e4e80c63c62d84954b20e64bf6e4ededcc69add54d3db25e990784a59c04edd3449032
languageName: node
linkType: hard
"d3-delaunay@npm:6":
version: 6.0.4
resolution: "d3-delaunay@npm:6.0.4"
dependencies:
delaunator: "npm:5"
checksum: 10c0/57c3aecd2525664b07c4c292aa11cf49b2752c0cf3f5257f752999399fe3c592de2d418644d79df1f255471eec8057a9cc0c3062ed7128cb3348c45f69597754
languageName: node
linkType: hard
"d3-dispatch@npm:1 - 3, d3-dispatch@npm:3":
version: 3.0.1
resolution: "d3-dispatch@npm:3.0.1"
checksum: 10c0/6eca77008ce2dc33380e45d4410c67d150941df7ab45b91d116dbe6d0a3092c0f6ac184dd4602c796dc9e790222bad3ff7142025f5fd22694efe088d1d941753
languageName: node
linkType: hard
"d3-drag@npm:2 - 3, d3-drag@npm:^3.0.0":
"d3-drag@npm:2 - 3, d3-drag@npm:3, d3-drag@npm:^3.0.0":
version: 3.0.0
resolution: "d3-drag@npm:3.0.0"
dependencies:
@@ -5702,14 +6005,78 @@ __metadata:
languageName: node
linkType: hard
"d3-ease@npm:1 - 3":
"d3-dsv@npm:1 - 3, d3-dsv@npm:3":
version: 3.0.1
resolution: "d3-dsv@npm:3.0.1"
dependencies:
commander: "npm:7"
iconv-lite: "npm:0.6"
rw: "npm:1"
bin:
csv2json: bin/dsv2json.js
csv2tsv: bin/dsv2dsv.js
dsv2dsv: bin/dsv2dsv.js
dsv2json: bin/dsv2json.js
json2csv: bin/json2dsv.js
json2dsv: bin/json2dsv.js
json2tsv: bin/json2dsv.js
tsv2csv: bin/dsv2dsv.js
tsv2json: bin/dsv2json.js
checksum: 10c0/10e6af9e331950ed258f34ab49ac1b7060128ef81dcf32afc790bd1f7e8c3cc2aac7f5f875250a83f21f39bb5925fbd0872bb209f8aca32b3b77d32bab8a65ab
languageName: node
linkType: hard
"d3-ease@npm:1 - 3, d3-ease@npm:3":
version: 3.0.1
resolution: "d3-ease@npm:3.0.1"
checksum: 10c0/fec8ef826c0cc35cda3092c6841e07672868b1839fcaf556e19266a3a37e6bc7977d8298c0fcb9885e7799bfdcef7db1baaba9cd4dcf4bc5e952cf78574a88b0
languageName: node
linkType: hard
"d3-interpolate@npm:1 - 3":
"d3-fetch@npm:3":
version: 3.0.1
resolution: "d3-fetch@npm:3.0.1"
dependencies:
d3-dsv: "npm:1 - 3"
checksum: 10c0/4f467a79bf290395ac0cbb5f7562483f6a18668adc4c8eb84c9d3eff048b6f6d3b6f55079ba1ebf1908dabe000c941d46be447f8d78453b2dad5fb59fb6aa93b
languageName: node
linkType: hard
"d3-force@npm:3":
version: 3.0.0
resolution: "d3-force@npm:3.0.0"
dependencies:
d3-dispatch: "npm:1 - 3"
d3-quadtree: "npm:1 - 3"
d3-timer: "npm:1 - 3"
checksum: 10c0/220a16a1a1ac62ba56df61028896e4b52be89c81040d20229c876efc8852191482c233f8a52bb5a4e0875c321b8e5cb6413ef3dfa4d8fe79eeb7d52c587f52cf
languageName: node
linkType: hard
"d3-format@npm:1 - 3, d3-format@npm:3":
version: 3.1.0
resolution: "d3-format@npm:3.1.0"
checksum: 10c0/049f5c0871ebce9859fc5e2f07f336b3c5bfff52a2540e0bac7e703fce567cd9346f4ad1079dd18d6f1e0eaa0599941c1810898926f10ac21a31fd0a34b4aa75
languageName: node
linkType: hard
"d3-geo@npm:3":
version: 3.1.1
resolution: "d3-geo@npm:3.1.1"
dependencies:
d3-array: "npm:2.5.0 - 3"
checksum: 10c0/d32270dd2dc8ac3ea63e8805d63239c4c8ec6c0d339d73b5e5a30a87f8f54db22a78fb434369799465eae169503b25f9a107c642c8a16c32a3285bc0e6d8e8c1
languageName: node
linkType: hard
"d3-hierarchy@npm:3":
version: 3.1.2
resolution: "d3-hierarchy@npm:3.1.2"
checksum: 10c0/6dcdb480539644aa7fc0d72dfc7b03f99dfbcdf02714044e8c708577e0d5981deb9d3e99bbbb2d26422b55bcc342ac89a0fa2ea6c9d7302e2fc0951dd96f89cf
languageName: node
linkType: hard
"d3-interpolate@npm:1 - 3, d3-interpolate@npm:1.2.0 - 3, d3-interpolate@npm:3":
version: 3.0.1
resolution: "d3-interpolate@npm:3.0.1"
dependencies:
@@ -5718,6 +6085,57 @@ __metadata:
languageName: node
linkType: hard
"d3-path@npm:1 - 3, d3-path@npm:3, d3-path@npm:^3.1.0":
version: 3.1.0
resolution: "d3-path@npm:3.1.0"
checksum: 10c0/dc1d58ec87fa8319bd240cf7689995111a124b141428354e9637aa83059eb12e681f77187e0ada5dedfce346f7e3d1f903467ceb41b379bfd01cd8e31721f5da
languageName: node
linkType: hard
"d3-polygon@npm:3":
version: 3.0.1
resolution: "d3-polygon@npm:3.0.1"
checksum: 10c0/e236aa7f33efa9a4072907af7dc119f85b150a0716759d4fe5f12f62573018264a6cbde8617fbfa6944a7ae48c1c0c8d3f39ae72e11f66dd471e9b5e668385df
languageName: node
linkType: hard
"d3-quadtree@npm:1 - 3, d3-quadtree@npm:3":
version: 3.0.1
resolution: "d3-quadtree@npm:3.0.1"
checksum: 10c0/18302d2548bfecaef788152397edec95a76400fd97d9d7f42a089ceb68d910f685c96579d74e3712d57477ed042b056881b47cd836a521de683c66f47ce89090
languageName: node
linkType: hard
"d3-random@npm:3":
version: 3.0.1
resolution: "d3-random@npm:3.0.1"
checksum: 10c0/987a1a1bcbf26e6cf01fd89d5a265b463b2cea93560fc17d9b1c45e8ed6ff2db5924601bcceb808de24c94133f000039eb7fa1c469a7a844ccbf1170cbb25b41
languageName: node
linkType: hard
"d3-scale-chromatic@npm:3":
version: 3.1.0
resolution: "d3-scale-chromatic@npm:3.1.0"
dependencies:
d3-color: "npm:1 - 3"
d3-interpolate: "npm:1 - 3"
checksum: 10c0/9a3f4671ab0b971f4a411b42180d7cf92bfe8e8584e637ce7e698d705e18d6d38efbd20ec64f60cc0dfe966c20d40fc172565bc28aaa2990c0a006360eed91af
languageName: node
linkType: hard
"d3-scale@npm:4":
version: 4.0.2
resolution: "d3-scale@npm:4.0.2"
dependencies:
d3-array: "npm:2.10.0 - 3"
d3-format: "npm:1 - 3"
d3-interpolate: "npm:1.2.0 - 3"
d3-time: "npm:2.1.1 - 3"
d3-time-format: "npm:2 - 4"
checksum: 10c0/65d9ad8c2641aec30ed5673a7410feb187a224d6ca8d1a520d68a7d6eac9d04caedbff4713d1e8545be33eb7fec5739983a7ab1d22d4e5ad35368c6729d362f1
languageName: node
linkType: hard
"d3-selection@npm:2 - 3, d3-selection@npm:3, d3-selection@npm:^3.0.0":
version: 3.0.0
resolution: "d3-selection@npm:3.0.0"
@@ -5725,14 +6143,41 @@ __metadata:
languageName: node
linkType: hard
"d3-timer@npm:1 - 3":
"d3-shape@npm:3":
version: 3.2.0
resolution: "d3-shape@npm:3.2.0"
dependencies:
d3-path: "npm:^3.1.0"
checksum: 10c0/f1c9d1f09926daaf6f6193ae3b4c4b5521e81da7d8902d24b38694517c7f527ce3c9a77a9d3a5722ad1e3ff355860b014557b450023d66a944eabf8cfde37132
languageName: node
linkType: hard
"d3-time-format@npm:2 - 4, d3-time-format@npm:4":
version: 4.1.0
resolution: "d3-time-format@npm:4.1.0"
dependencies:
d3-time: "npm:1 - 3"
checksum: 10c0/735e00fb25a7fd5d418fac350018713ae394eefddb0d745fab12bbff0517f9cdb5f807c7bbe87bb6eeb06249662f8ea84fec075f7d0cd68609735b2ceb29d206
languageName: node
linkType: hard
"d3-time@npm:1 - 3, d3-time@npm:2.1.1 - 3, d3-time@npm:3":
version: 3.1.0
resolution: "d3-time@npm:3.1.0"
dependencies:
d3-array: "npm:2 - 3"
checksum: 10c0/a984f77e1aaeaa182679b46fbf57eceb6ebdb5f67d7578d6f68ef933f8eeb63737c0949991618a8d29472dbf43736c7d7f17c452b2770f8c1271191cba724ca1
languageName: node
linkType: hard
"d3-timer@npm:1 - 3, d3-timer@npm:3":
version: 3.0.1
resolution: "d3-timer@npm:3.0.1"
checksum: 10c0/d4c63cb4bb5461d7038aac561b097cd1c5673969b27cbdd0e87fa48d9300a538b9e6f39b4a7f0e3592ef4f963d858c8a9f0e92754db73116770856f2fc04561a
languageName: node
linkType: hard
"d3-transition@npm:2 - 3":
"d3-transition@npm:2 - 3, d3-transition@npm:3":
version: 3.0.1
resolution: "d3-transition@npm:3.0.1"
dependencies:
@@ -5747,7 +6192,7 @@ __metadata:
languageName: node
linkType: hard
"d3-zoom@npm:^3.0.0":
"d3-zoom@npm:3, d3-zoom@npm:^3.0.0":
version: 3.0.0
resolution: "d3-zoom@npm:3.0.0"
dependencies:
@@ -5760,6 +6205,44 @@ __metadata:
languageName: node
linkType: hard
"d3@npm:^7.9.0":
version: 7.9.0
resolution: "d3@npm:7.9.0"
dependencies:
d3-array: "npm:3"
d3-axis: "npm:3"
d3-brush: "npm:3"
d3-chord: "npm:3"
d3-color: "npm:3"
d3-contour: "npm:4"
d3-delaunay: "npm:6"
d3-dispatch: "npm:3"
d3-drag: "npm:3"
d3-dsv: "npm:3"
d3-ease: "npm:3"
d3-fetch: "npm:3"
d3-force: "npm:3"
d3-format: "npm:3"
d3-geo: "npm:3"
d3-hierarchy: "npm:3"
d3-interpolate: "npm:3"
d3-path: "npm:3"
d3-polygon: "npm:3"
d3-quadtree: "npm:3"
d3-random: "npm:3"
d3-scale: "npm:4"
d3-scale-chromatic: "npm:3"
d3-selection: "npm:3"
d3-shape: "npm:3"
d3-time: "npm:3"
d3-time-format: "npm:4"
d3-timer: "npm:3"
d3-transition: "npm:3"
d3-zoom: "npm:3"
checksum: 10c0/3dd9c08c73cfaa69c70c49e603c85e049c3904664d9c79a1a52a0f52795828a1ff23592dc9a7b2257e711d68a615472a13103c212032f38e016d609796e087e8
languageName: node
linkType: hard
"dashdash@npm:^1.12.0":
version: 1.14.1
resolution: "dashdash@npm:1.14.1"
@@ -6042,6 +6525,15 @@ __metadata:
languageName: node
linkType: hard
"delaunator@npm:5":
version: 5.0.1
resolution: "delaunator@npm:5.0.1"
dependencies:
robust-predicates: "npm:^3.0.2"
checksum: 10c0/3d7ea4d964731c5849af33fec0a271bc6753487b331fd7d43ccb17d77834706e1c383e6ab8fda0032da955e7576d1083b9603cdaf9cbdfd6b3ebd1fb8bb675a5
languageName: node
linkType: hard
"delay@npm:^6.0.0":
version: 6.0.0
resolution: "delay@npm:6.0.0"
@@ -8951,7 +9443,7 @@ __metadata:
languageName: node
linkType: hard
"iconv-lite@npm:0.6.3, iconv-lite@npm:^0.6.2":
"iconv-lite@npm:0.6, iconv-lite@npm:0.6.3, iconv-lite@npm:^0.6.2":
version: 0.6.3
resolution: "iconv-lite@npm:0.6.3"
dependencies:
@@ -9093,6 +9585,13 @@ __metadata:
languageName: node
linkType: hard
"internmap@npm:1 - 2":
version: 2.0.3
resolution: "internmap@npm:2.0.3"
checksum: 10c0/8cedd57f07bbc22501516fbfc70447f0c6812871d471096fad9ea603516eacc2137b633633daf432c029712df0baefd793686388ddf5737e3ea15074b877f7ed
languageName: node
linkType: hard
"invert-kv@npm:^1.0.0":
version: 1.0.0
resolution: "invert-kv@npm:1.0.0"
@@ -14416,6 +14915,13 @@ __metadata:
languageName: node
linkType: hard
"robust-predicates@npm:^3.0.2":
version: 3.0.2
resolution: "robust-predicates@npm:3.0.2"
checksum: 10c0/4ecd53649f1c2d49529c85518f2fa69ffb2f7a4453f7fd19c042421c7b4d76c3efb48bc1c740c8f7049346d7cb58cf08ee0c9adaae595cc23564d360adb1fde4
languageName: node
linkType: hard
"rollup-plugin-visualizer@npm:^5.12.0":
version: 5.14.0
resolution: "rollup-plugin-visualizer@npm:5.14.0"
@@ -14537,6 +15043,13 @@ __metadata:
languageName: node
linkType: hard
"rw@npm:1":
version: 1.3.3
resolution: "rw@npm:1.3.3"
checksum: 10c0/b1e1ef37d1e79d9dc7050787866e30b6ddcb2625149276045c262c6b4d53075ddc35f387a856a8e76f0d0df59f4cd58fe24707e40797ebee66e542b840ed6a53
languageName: node
linkType: hard
"safe-buffer@npm:5.2.1, safe-buffer@npm:^5.0.1, safe-buffer@npm:^5.1.1, safe-buffer@npm:^5.1.2, safe-buffer@npm:^5.2.1, safe-buffer@npm:~5.2.0":
version: 5.2.1
resolution: "safe-buffer@npm:5.2.1"

View File

@@ -0,0 +1,280 @@
# 敏感信息过滤功能实现方案(修改版)
## 需求分析
用户希望增加一个按钮,控制记忆功能是否过滤密钥等安全敏感信息。当开启过滤功能时,分析模型会过滤掉密钥等敏感信息;关闭则不过滤。此功能对于保护用户隐私和敏感数据至关重要。
## 实现思路
1. 在Redux状态中添加一个新的状态属性`filterSensitiveInfo`
2. 在设置界面中添加一个开关按钮,默认为开启状态
3. 修改分析函数,根据`filterSensitiveInfo`状态添加过滤指令
4. 添加日志记录,跟踪过滤状态的变化
## 修改文件
### 1. 修改 src/renderer/src/store/memory.ts
```typescript
// 在 MemoryState 接口中添加
export interface MemoryState {
// 其他属性...
filterSensitiveInfo: boolean // 是否过滤敏感信息
}
// 在 initialState 中添加
const initialState: MemoryState = {
// 其他属性...
filterSensitiveInfo: true, // 默认启用敏感信息过滤
}
// 添加新的 action creator
setFilterSensitiveInfo: (state, action: PayloadAction<boolean>) => {
state.filterSensitiveInfo = action.payload
},
// 导出 action
export const {
// 其他 actions...
setFilterSensitiveInfo,
} = memorySlice.actions
// 修改 saveMemoryData 函数,确保 filterSensitiveInfo 设置也被保存
const completeData = {
// 基本设置
isActive: memoryData.isActive !== undefined ? memoryData.isActive : state.isActive,
shortMemoryActive: memoryData.shortMemoryActive !== undefined ? memoryData.shortMemoryActive : state.shortMemoryActive,
autoAnalyze: memoryData.autoAnalyze !== undefined ? memoryData.autoAnalyze : state.autoAnalyze,
filterSensitiveInfo: memoryData.filterSensitiveInfo !== undefined ? memoryData.filterSensitiveInfo : state.filterSensitiveInfo,
// 其他属性...
}
// 同样修改 saveLongTermMemoryData 函数
const completeData = {
// 基本设置
isActive: memoryData.isActive !== undefined ? memoryData.isActive : state.isActive,
autoAnalyze: memoryData.autoAnalyze !== undefined ? memoryData.autoAnalyze : state.autoAnalyze,
filterSensitiveInfo: memoryData.filterSensitiveInfo !== undefined ? memoryData.filterSensitiveInfo : state.filterSensitiveInfo,
// 其他属性...
}
```
### 2. 修改 src/renderer/src/pages/settings/MemorySettings/index.tsx
```typescript
// 导入 InfoCircleOutlined 图标
import {
AppstoreOutlined,
DeleteOutlined,
EditOutlined,
InfoCircleOutlined,
PlusOutlined,
SearchOutlined,
UnorderedListOutlined
} from '@ant-design/icons'
// 导入 setFilterSensitiveInfo action
import {
addMemory,
clearMemories,
deleteMemory,
editMemory,
setAnalyzeModel,
setAnalyzing,
setAutoAnalyze,
setFilterSensitiveInfo,
setMemoryActive,
setShortMemoryAnalyzeModel,
saveMemoryData,
saveLongTermMemoryData,
saveAllMemorySettings
} from '@renderer/store/memory'
// 从 Redux 获取 filterSensitiveInfo 状态
const filterSensitiveInfo = useAppSelector((state) => state.memory?.filterSensitiveInfo ?? true) // 默认启用敏感信息过滤
// 添加处理切换敏感信息过滤的函数
const handleToggleFilterSensitiveInfo = async (checked: boolean) => {
dispatch(setFilterSensitiveInfo(checked))
console.log('[Memory Settings] Filter sensitive info set:', checked)
// 使用Redux Thunk保存到JSON文件
try {
await dispatch(saveMemoryData({ filterSensitiveInfo: checked })).unwrap()
console.log('[Memory Settings] Filter sensitive info saved to file successfully:', checked)
} catch (error) {
console.error('[Memory Settings] Failed to save filter sensitive info to file:', error)
}
}
// 在短期记忆设置中添加开关按钮
<SettingRow>
<SettingRowTitle>
{t('settings.memory.filterSensitiveInfo') || '过滤敏感信息'}
<Tooltip title={t('settings.memory.filterSensitiveInfoTip') || '启用后记忆功能将不会提取API密钥、密码等敏感信息'}>
<InfoCircleOutlined style={{ marginLeft: 8 }} />
</Tooltip>
</SettingRowTitle>
<Switch checked={filterSensitiveInfo} onChange={handleToggleFilterSensitiveInfo} disabled={!isActive} />
</SettingRow>
// 在长期记忆设置中也添加相同的开关按钮
<SettingRow>
<SettingRowTitle>
{t('settings.memory.filterSensitiveInfo') || '过滤敏感信息'}
<Tooltip title={t('settings.memory.filterSensitiveInfoTip') || '启用后记忆功能将不会提取API密钥、密码等敏感信息'}>
<InfoCircleOutlined style={{ marginLeft: 8 }} />
</Tooltip>
</SettingRowTitle>
<Switch checked={filterSensitiveInfo} onChange={handleToggleFilterSensitiveInfo} disabled={!isActive} />
</SettingRow>
```
### 3. 修改 src/renderer/src/services/MemoryService.ts
```typescript
// 修改 analyzeConversation 函数
const analyzeConversation = async (
conversation: string,
modelId: string,
customPrompt?: string
): Promise<Array<{ content: string; category: string }>> => {
try {
// 获取当前的过滤敏感信息设置
const filterSensitiveInfo = store.getState().memory?.filterSensitiveInfo ?? true
// 使用自定义提示词或默认提示词
let basePrompt =
customPrompt ||
`
请分析对话内容,提取出重要的用户偏好、习惯、需求和背景信息,这些信息在未来的对话中可能有用。
将每条信息分类并按以下格式返回:
类别: 信息内容
类别应该是以下几种之一:
- 用户偏好:用户喜好、喜欢的事物、风格等
- 技术需求:用户的技术相关需求、开发偏好等
- 个人信息:用户的背景、经历等个人信息
- 交互偏好:用户喜欢的交流方式、沟通风格等
- 其他:不属于以上类别的重要信息
请确保每条信息都是简洁、准确的。如果没有找到重要信息,请返回空字符串。
`
// 如果启用了敏感信息过滤,添加相关指令
if (filterSensitiveInfo) {
basePrompt += `
## 安全提示:
请注意不要提取任何敏感信息,包括但不限于:
- API密钥、访问令牌或其他凭证
- 密码或密码提示
- 私人联系方式(如电话号码、邮箱地址)
- 个人身份信息(如身份证号、社保号)
- 银行账户或支付信息
- 私密的个人或商业信息
如果发现此类信息,请完全忽略,不要以任何形式记录或提取。
`
}
// 其余代码保持不变...
}
}
// 修改 analyzeAndAddShortMemories 函数
export const analyzeAndAddShortMemories = async (topicId: string) => {
// 其他代码...
try {
// 获取当前的过滤敏感信息设置
const filterSensitiveInfo = store.getState().memory?.filterSensitiveInfo ?? true
// 构建短期记忆分析提示词
let prompt = `
请对以下对话内容进行非常详细的分析和总结提取对当前对话至关重要的上下文信息。请注意这个分析将用于生成短期记忆帮助AI理解当前对话的完整上下文。
分析要求:
1. 非常详细地总结用户的每一句话中表达的关键信息、需求和意图
2. 全面分析AI回复中的重要内容和对用户问题的解决方案
3. 详细记录对话中的重要事实、数据、代码示例和具体细节
4. 清晰捕捉对话的逻辑发展、转折点和关键决策
5. 提取对理解当前对话上下文必不可少的信息
6. 记录用户提出的具体问题和关注点
7. 捕捉用户在对话中表达的偏好、困惑和反馈
8. 记录对话中提到的文件、路径、变量名等具体技术细节
`
// 如果启用了敏感信息过滤,添加相关指令
if (filterSensitiveInfo) {
prompt += `
9. 请注意不要提取任何敏感信息,包括但不限于:
- API密钥、访问令牌或其他凭证
- 密码或密码提示
- 私人联系方式(如电话号码、邮箱地址)
- 个人身份信息(如身份证号、社保号)
- 银行账户或支付信息
- 私密的个人或商业信息
如果发现此类信息,请完全忽略,不要以任何形式记录或提取。
`
}
// 其余代码保持不变...
}
}
```
### 4. 修改 src/renderer/src/i18n/locales/zh-cn.json 和 en-us.json
```json
{
"settings": {
"memory": {
"filterSensitiveInfo": "过滤敏感信息",
"filterSensitiveInfoTip": "启用后记忆功能将不会提取API密钥、密码等敏感信息"
}
}
}
```
```json
{
"settings": {
"memory": {
"filterSensitiveInfo": "Filter Sensitive Information",
"filterSensitiveInfoTip": "When enabled, memory function will not extract API keys, passwords, or other sensitive information"
}
}
}
```
## 实现效果
这些修改后,用户将能够通过开关按钮控制记忆功能是否过滤敏感信息:
1. 当开启过滤功能时默认状态分析模型会被明确指示不要提取API密钥、密码等敏感信息
2. 当关闭过滤功能时,分析模型会正常提取所有信息,包括可能的敏感信息
开关按钮会出现在短期记忆和长期记忆设置中,用户可以根据需要随时切换。设置会被保存到配置文件中,确保应用重启后设置仍然生效。
## 思考过程
1. **状态管理**首先考虑如何在Redux中添加新的状态属性并确保它能够被正确保存和加载。
2. **UI设计**:在设置界面中添加开关按钮,并提供提示信息,帮助用户理解这个功能的作用。
3. **提示词修改**:根据开关状态修改分析提示词,添加不要提取敏感信息的指令。这是实现过滤功能的核心部分。
4. **国际化支持**:添加相关的翻译键值对,确保功能在不同语言环境下都能正常使用。
5. **持久化**:确保设置能够被正确保存到配置文件中,并在应用重启后加载。
## 注意事项
1. 这个功能只能在一定程度上防止敏感信息被提取但不能完全保证。如果用户在对话中明确提到了敏感信息AI模型可能仍然会提取部分内容。
2. 建议用户在讨论敏感信息时,最好暂时关闭记忆功能,或者在对话中避免提及敏感信息。
3. 这个功能只影响新分析的对话内容,已经存储的记忆不会受到影响。如果用户想要清除可能包含敏感信息的记忆,需要手动删除这些记忆。