Compare commits
56 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2822a5e65d | ||
|
|
26b798f345 | ||
|
|
7aec8b4a35 | ||
|
|
994ab7362f | ||
|
|
bbdcd85014 | ||
|
|
249ab3d59f | ||
|
|
5df40ffc14 | ||
|
|
2bbe2f7ae5 | ||
|
|
f0876eaef0 | ||
|
|
aa8c7fd66f | ||
|
|
b8dffce149 | ||
|
|
8b95a131ec | ||
|
|
72e18fbcc1 | ||
|
|
b62c59eb52 | ||
|
|
ffe7702c1c | ||
|
|
1ed6320caf | ||
|
|
315271ac35 | ||
|
|
0bd24f652d | ||
|
|
0e7c4e4bdd | ||
|
|
d4bf8da225 | ||
|
|
8eb6632620 | ||
|
|
10225512f4 | ||
|
|
76058bd749 | ||
|
|
a692ae7e9d | ||
|
|
a70ca190ba | ||
|
|
7c39116351 | ||
|
|
04333535dd | ||
|
|
a1dba93d27 | ||
|
|
0842b7e84d | ||
|
|
24d6d146c0 | ||
|
|
978c3ea3cf | ||
|
|
a9eb235c43 | ||
|
|
e0a47de8f7 | ||
|
|
78a4696327 | ||
|
|
57fa0aad38 | ||
|
|
2e0251aed7 | ||
|
|
afd1381d7f | ||
|
|
c3b5cbee8f | ||
|
|
e1f255048e | ||
|
|
8a579be4c1 | ||
|
|
efcffbaa30 | ||
|
|
f9c6bddae5 | ||
|
|
5e086a1686 | ||
|
|
0db4c8b475 | ||
|
|
d5fcef39d3 | ||
|
|
5c44f71684 | ||
|
|
3462be2a2a | ||
|
|
a0be911dc9 | ||
|
|
f7f7d2bde8 | ||
|
|
10efa444bf | ||
|
|
24e28b86cf | ||
|
|
fa66d048d7 | ||
|
|
fe7a392116 | ||
|
|
c883fd85d8 | ||
|
|
aa73025568 | ||
|
|
9689f00214 |
8
LICENSE
8
LICENSE
@@ -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.
|
||||
|
||||
@@ -85,7 +85,9 @@ afterPack: scripts/after-pack.js
|
||||
afterSign: scripts/notarize.js
|
||||
releaseInfo:
|
||||
releaseNotes: |
|
||||
知识库和服务商界面更新
|
||||
增加 Dangbei 小程序
|
||||
可以强制使用搜索引擎覆盖模型自带搜索能力
|
||||
修复部分公式无法正常渲染问题
|
||||
增加对 grok-3 和 Grok-3-mini 的支持
|
||||
助手支持使用拼音排序
|
||||
网络搜索增加 Baidu, Google, Bing 支持(免费使用)
|
||||
网络搜索增加 uBlacklist 订阅
|
||||
快速面板 (QuickPanel) 进行性能优化
|
||||
解决 mcp 依赖工具下载速度问题
|
||||
|
||||
10
package.json
10
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "CherryStudio",
|
||||
"version": "1.2.1",
|
||||
"version": "1.2.2-batemo",
|
||||
"private": true,
|
||||
"description": "A powerful AI assistant for producer.",
|
||||
"main": "./out/main/index.js",
|
||||
@@ -66,12 +66,16 @@
|
||||
"@electron/notarize": "^2.5.0",
|
||||
"@google/generative-ai": "^0.24.0",
|
||||
"@langchain/community": "^0.3.36",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"@notionhq/client": "^2.2.15",
|
||||
"@strongtz/win32-arm64-msvc": "^0.4.7",
|
||||
"@tryfabric/martian": "^1.2.4",
|
||||
"@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",
|
||||
@@ -88,6 +92,7 @@
|
||||
"officeparser": "^4.1.1",
|
||||
"proxy-agent": "^6.5.0",
|
||||
"tar": "^7.4.3",
|
||||
"tiny-pinyin": "^1.3.2",
|
||||
"turndown": "^7.2.0",
|
||||
"turndown-plugin-gfm": "^1.0.2",
|
||||
"undici": "^7.4.0",
|
||||
@@ -111,12 +116,13 @@
|
||||
"@google/genai": "^0.4.0",
|
||||
"@hello-pangea/dnd": "^16.6.0",
|
||||
"@kangfenmao/keyv-storage": "^0.1.0",
|
||||
"@modelcontextprotocol/sdk": "^1.8.0",
|
||||
"@modelcontextprotocol/sdk": "^1.9.0",
|
||||
"@notionhq/client": "^2.2.15",
|
||||
"@reduxjs/toolkit": "^2.2.5",
|
||||
"@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",
|
||||
|
||||
@@ -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',
|
||||
@@ -146,5 +148,19 @@ export enum IpcChannel {
|
||||
MiniWindowReload = 'miniwindow-reload',
|
||||
|
||||
ReduxStateChange = 'redux-state-change',
|
||||
ReduxStoreReady = 'redux-store-ready'
|
||||
ReduxStoreReady = 'redux-store-ready',
|
||||
|
||||
// Search Window
|
||||
SearchWindow_Open = 'search-window:open',
|
||||
SearchWindow_Close = 'search-window:close',
|
||||
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'
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -6,8 +6,8 @@ const AdmZip = require('adm-zip')
|
||||
const { downloadWithRedirects } = require('./download')
|
||||
|
||||
// Base URL for downloading bun binaries
|
||||
const BUN_RELEASE_BASE_URL = 'https://github.com/oven-sh/bun/releases/download'
|
||||
const DEFAULT_BUN_VERSION = '1.2.5' // Default fallback version
|
||||
const BUN_RELEASE_BASE_URL = 'https://gitcode.com/CherryHQ/bun/releases/download'
|
||||
const DEFAULT_BUN_VERSION = '1.2.9' // Default fallback version
|
||||
|
||||
// Mapping of platform+arch to binary package name
|
||||
const BUN_PACKAGES = {
|
||||
|
||||
@@ -7,8 +7,8 @@ const AdmZip = require('adm-zip')
|
||||
const { downloadWithRedirects } = require('./download')
|
||||
|
||||
// Base URL for downloading uv binaries
|
||||
const UV_RELEASE_BASE_URL = 'https://github.com/astral-sh/uv/releases/download'
|
||||
const DEFAULT_UV_VERSION = '0.6.6'
|
||||
const UV_RELEASE_BASE_URL = 'https://gitcode.com/CherryHQ/uv/releases/download'
|
||||
const DEFAULT_UV_VERSION = '0.6.14'
|
||||
|
||||
// Mapping of platform+arch to binary package name
|
||||
const UV_PACKAGES = {
|
||||
|
||||
@@ -18,35 +18,48 @@ exports.default = async function (context) {
|
||||
'node_modules'
|
||||
)
|
||||
|
||||
removeDifferentArchNodeFiles(node_modules_path, '@libsql', arch === Arch.arm64 ? ['darwin-arm64'] : ['darwin-x64'])
|
||||
keepPackageNodeFiles(node_modules_path, '@libsql', arch === Arch.arm64 ? ['darwin-arm64'] : ['darwin-x64'])
|
||||
}
|
||||
|
||||
if (platform === 'linux') {
|
||||
const node_modules_path = path.join(context.appOutDir, 'resources', 'app.asar.unpacked', 'node_modules')
|
||||
const _arch = arch === Arch.arm64 ? ['linux-arm64-gnu', 'linux-arm64-musl'] : ['linux-x64-gnu', 'linux-x64-musl']
|
||||
removeDifferentArchNodeFiles(node_modules_path, '@libsql', _arch)
|
||||
keepPackageNodeFiles(node_modules_path, '@libsql', _arch)
|
||||
}
|
||||
|
||||
if (platform === 'windows') {
|
||||
const node_modules_path = path.join(context.appOutDir, 'resources', 'app.asar.unpacked', 'node_modules')
|
||||
if (arch === Arch.arm64) {
|
||||
removeDifferentArchNodeFiles(node_modules_path, '@strongtz', ['win32-arm64-msvc'])
|
||||
removeDifferentArchNodeFiles(node_modules_path, '@libsql', ['win32-arm64-msvc'])
|
||||
keepPackageNodeFiles(node_modules_path, '@strongtz', ['win32-arm64-msvc'])
|
||||
keepPackageNodeFiles(node_modules_path, '@libsql', ['win32-arm64-msvc'])
|
||||
}
|
||||
if (arch === Arch.x64) {
|
||||
removeDifferentArchNodeFiles(node_modules_path, '@strongtz', ['win32-x64-msvc'])
|
||||
removeDifferentArchNodeFiles(node_modules_path, '@libsql', ['win32-x64-msvc'])
|
||||
keepPackageNodeFiles(node_modules_path, '@strongtz', ['win32-x64-msvc'])
|
||||
keepPackageNodeFiles(node_modules_path, '@libsql', ['win32-x64-msvc'])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function removeDifferentArchNodeFiles(nodeModulesPath, packageName, arch) {
|
||||
/**
|
||||
* 使用指定架构的 node_modules 文件
|
||||
* @param {*} nodeModulesPath
|
||||
* @param {*} packageName
|
||||
* @param {*} arch
|
||||
* @returns
|
||||
*/
|
||||
function keepPackageNodeFiles(nodeModulesPath, packageName, arch) {
|
||||
const modulePath = path.join(nodeModulesPath, packageName)
|
||||
|
||||
if (!fs.existsSync(modulePath)) {
|
||||
console.log(`[After Pack] Directory does not exist: ${modulePath}`)
|
||||
return
|
||||
}
|
||||
|
||||
const dirs = fs.readdirSync(modulePath)
|
||||
dirs
|
||||
.filter((dir) => !arch.includes(dir))
|
||||
.forEach((dir) => {
|
||||
fs.rmSync(path.join(modulePath, dir), { recursive: true, force: true })
|
||||
console.log(`Removed dir: ${dir}`, arch)
|
||||
console.log(`[After Pack] Removed dir: ${dir}`, arch)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import './services/MemoryFileService'
|
||||
|
||||
import { electronApp, optimizer } from '@electron-toolkit/utils'
|
||||
import { replaceDevtoolsFont } from '@main/utils/windowUtil'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { app, ipcMain } from 'electron'
|
||||
import installExtension, { REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS } from 'electron-devtools-installer'
|
||||
import Logger from 'electron-log'
|
||||
|
||||
import { registerIpc } from './ipc'
|
||||
import { configManager } from './services/ConfigManager'
|
||||
import mcpService from './services/MCPService'
|
||||
import { CHERRY_STUDIO_PROTOCOL, handleProtocolUrl, registerProtocolClient } from './services/ProtocolClient'
|
||||
import { registerShortcuts } from './services/ShortcutService'
|
||||
import { TrayService } from './services/TrayService'
|
||||
@@ -92,6 +96,15 @@ if (!app.requestSingleInstanceLock()) {
|
||||
app.isQuitting = true
|
||||
})
|
||||
|
||||
app.on('will-quit', async () => {
|
||||
// event.preventDefault()
|
||||
try {
|
||||
await mcpService.cleanup()
|
||||
} catch (error) {
|
||||
Logger.error('Error cleaning up MCP service:', error)
|
||||
}
|
||||
})
|
||||
|
||||
// In this file you can include the rest of your app"s specific main process
|
||||
// code. You can also put them in separate files and require them here.
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import './services/MemoryFileService'
|
||||
|
||||
import fs from 'node:fs'
|
||||
|
||||
import { isMac, isWin } from '@main/constant'
|
||||
@@ -18,9 +20,11 @@ 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'
|
||||
import { searchService } from './services/SearchService'
|
||||
import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService'
|
||||
import { TrayService } from './services/TrayService'
|
||||
import { windowService } from './services/WindowService'
|
||||
@@ -261,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))
|
||||
@@ -291,4 +297,32 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
ipcMain.handle(IpcChannel.Nutstore_GetDirectoryContents, (_, token: string, path: string) =>
|
||||
NutstoreService.getDirectoryContents(token, path)
|
||||
)
|
||||
|
||||
// search window
|
||||
ipcMain.handle(IpcChannel.SearchWindow_Open, async (_, uid: string) => {
|
||||
await searchService.openSearchWindow(uid)
|
||||
})
|
||||
ipcMain.handle(IpcChannel.SearchWindow_Close, async (_, uid: string) => {
|
||||
await searchService.closeSearchWindow(uid)
|
||||
})
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
|
||||
@@ -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)}`
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
321
src/main/mcpServers/simpleremember.ts
Normal file
321
src/main/mcpServers/simpleremember.ts
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -10,11 +10,46 @@ 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()
|
||||
@@ -34,10 +69,13 @@ 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)
|
||||
this.stopServer = this.stopServer.bind(this)
|
||||
this.cleanup = this.cleanup.bind(this)
|
||||
}
|
||||
|
||||
async initClient(server: MCPServer): Promise<Client> {
|
||||
@@ -67,7 +105,7 @@ class McpService {
|
||||
|
||||
const args = [...(server.args || [])]
|
||||
|
||||
let transport: StdioClientTransport | SSEClientTransport | InMemoryTransport
|
||||
let transport: StdioClientTransport | SSEClientTransport | InMemoryTransport | StreamableHTTPClientTransport
|
||||
|
||||
try {
|
||||
// Create appropriate transport based on configuration
|
||||
@@ -86,7 +124,16 @@ class McpService {
|
||||
// set the client transport to the client
|
||||
transport = clientTransport
|
||||
} else if (server.baseUrl) {
|
||||
transport = new SSEClientTransport(new URL(server.baseUrl))
|
||||
if (server.type === 'streamableHttp') {
|
||||
transport = new StreamableHTTPClientTransport(
|
||||
new URL(server.baseUrl!),
|
||||
{} as StreamableHTTPClientTransportOptions
|
||||
)
|
||||
} else if (server.type === 'sse') {
|
||||
transport = new SSEClientTransport(new URL(server.baseUrl!))
|
||||
} else {
|
||||
throw new Error('Invalid server type')
|
||||
}
|
||||
} else if (server.command) {
|
||||
let cmd = server.command
|
||||
|
||||
@@ -137,8 +184,12 @@ class McpService {
|
||||
...getDefaultEnvironment(),
|
||||
PATH: this.getEnhancedPath(process.env.PATH || ''),
|
||||
...server.env
|
||||
}
|
||||
},
|
||||
stderr: 'pipe'
|
||||
})
|
||||
transport.stderr?.on('data', (data) =>
|
||||
Logger.info(`[MCP] Stdio stderr for server: ${server.name} `, data.toString())
|
||||
)
|
||||
} else {
|
||||
throw new Error('Either baseUrl or command must be provided')
|
||||
}
|
||||
@@ -191,31 +242,50 @@ class McpService {
|
||||
await this.initClient(server)
|
||||
}
|
||||
|
||||
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
|
||||
async cleanup() {
|
||||
for (const [key] of this.clients) {
|
||||
try {
|
||||
await this.closeClient(key)
|
||||
} catch (error) {
|
||||
Logger.error(`[MCP] Failed to close client: ${error}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -245,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
|
||||
*/
|
||||
@@ -310,4 +450,5 @@ class McpService {
|
||||
}
|
||||
}
|
||||
|
||||
export default new McpService()
|
||||
const mcpService = new McpService()
|
||||
export default mcpService
|
||||
|
||||
394
src/main/services/MCPStreamableHttpClient.ts
Normal file
394
src/main/services/MCPStreamableHttpClient.ts
Normal file
@@ -0,0 +1,394 @@
|
||||
import { auth, AuthResult, OAuthClientProvider, UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js'
|
||||
import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
|
||||
import { JSONRPCMessage, JSONRPCMessageSchema } from '@modelcontextprotocol/sdk/types.js'
|
||||
|
||||
export class StreamableHTTPError extends Error {
|
||||
constructor(
|
||||
public readonly code: number | undefined,
|
||||
message: string | undefined,
|
||||
public readonly event: ErrorEvent
|
||||
) {
|
||||
super(`Streamable HTTP error: ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration options for the `StreamableHTTPClientTransport`.
|
||||
*/
|
||||
export type StreamableHTTPClientTransportOptions = {
|
||||
/**
|
||||
* An OAuth client provider to use for authentication.
|
||||
*
|
||||
* When an `authProvider` is specified and the connection is started:
|
||||
* 1. The connection is attempted with any existing access token from the `authProvider`.
|
||||
* 2. If the access token has expired, the `authProvider` is used to refresh the token.
|
||||
* 3. If token refresh fails or no access token exists, and auth is required, `OAuthClientProvider.redirectToAuthorization` is called, and an `UnauthorizedError` will be thrown from `connect`/`start`.
|
||||
*
|
||||
* After the user has finished authorizing via their user agent, and is redirected back to the MCP client application, call `StreamableHTTPClientTransport.finishAuth` with the authorization code before retrying the connection.
|
||||
*
|
||||
* If an `authProvider` is not provided, and auth is required, an `UnauthorizedError` will be thrown.
|
||||
*
|
||||
* `UnauthorizedError` might also be thrown when sending any message over the transport, indicating that the session has expired, and needs to be re-authed and reconnected.
|
||||
*/
|
||||
authProvider?: OAuthClientProvider
|
||||
|
||||
/**
|
||||
* Customizes HTTP requests to the server.
|
||||
*/
|
||||
requestInit?: RequestInit
|
||||
}
|
||||
|
||||
/**
|
||||
* Client transport for Streamable HTTP: this implements the MCP Streamable HTTP transport specification.
|
||||
* It will connect to a server using HTTP POST for sending messages and HTTP GET with Server-Sent Events
|
||||
* for receiving messages.
|
||||
*/
|
||||
export class StreamableHTTPClientTransport implements Transport {
|
||||
private _activeStreams: Map<string, ReadableStreamDefaultReader<Uint8Array>> = new Map()
|
||||
private _abortController?: AbortController
|
||||
private _url: URL
|
||||
private _requestInit?: RequestInit
|
||||
private _authProvider?: OAuthClientProvider
|
||||
private _sessionId?: string
|
||||
private _lastEventId?: string
|
||||
|
||||
onclose?: () => void
|
||||
onerror?: (error: Error) => void
|
||||
onmessage?: (message: JSONRPCMessage) => void
|
||||
|
||||
constructor(url: URL, opts?: StreamableHTTPClientTransportOptions) {
|
||||
this._url = url
|
||||
this._requestInit = opts?.requestInit
|
||||
this._authProvider = opts?.authProvider
|
||||
}
|
||||
|
||||
private async _authThenStart(): Promise<void> {
|
||||
if (!this._authProvider) {
|
||||
throw new UnauthorizedError('No auth provider')
|
||||
}
|
||||
|
||||
let result: AuthResult
|
||||
try {
|
||||
result = await auth(this._authProvider, { serverUrl: this._url })
|
||||
} catch (error) {
|
||||
this.onerror?.(error as Error)
|
||||
throw error
|
||||
}
|
||||
|
||||
if (result !== 'AUTHORIZED') {
|
||||
throw new UnauthorizedError()
|
||||
}
|
||||
|
||||
return await this._startOrAuth()
|
||||
}
|
||||
|
||||
private async _commonHeaders(): Promise<HeadersInit> {
|
||||
const headers: HeadersInit = {}
|
||||
if (this._authProvider) {
|
||||
const tokens = await this._authProvider.tokens()
|
||||
if (tokens) {
|
||||
headers['Authorization'] = `Bearer ${tokens.access_token}`
|
||||
}
|
||||
}
|
||||
|
||||
if (this._sessionId) {
|
||||
headers['mcp-session-id'] = this._sessionId
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
private async _startOrAuth(): Promise<void> {
|
||||
try {
|
||||
// Try to open an initial SSE stream with GET to listen for server messages
|
||||
// This is optional according to the spec - server may not support it
|
||||
const commonHeaders = await this._commonHeaders()
|
||||
const headers = new Headers(commonHeaders)
|
||||
headers.set('Accept', 'text/event-stream')
|
||||
|
||||
// Include Last-Event-ID header for resumable streams
|
||||
if (this._lastEventId) {
|
||||
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,
|
||||
signal: this._abortController?.signal
|
||||
})
|
||||
|
||||
if (response.status === 405) {
|
||||
// Server doesn't support GET for SSE, which is allowed by the spec
|
||||
// We'll rely on SSE responses to POST requests for communication
|
||||
return
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 && this._authProvider) {
|
||||
// Need to authenticate
|
||||
return await this._authThenStart()
|
||||
}
|
||||
|
||||
const error = new Error(`Failed to open SSE stream: ${response.status} ${response.statusText}`)
|
||||
this.onerror?.(error)
|
||||
throw error
|
||||
}
|
||||
|
||||
// Successful connection, handle the SSE stream as a standalone listener
|
||||
const streamId = `initial-${Date.now()}`
|
||||
this._handleSseStream(response.body, streamId)
|
||||
} catch (error) {
|
||||
this.onerror?.(error as Error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async start() {
|
||||
if (this._activeStreams.size > 0) {
|
||||
throw new Error(
|
||||
'StreamableHTTPClientTransport already started! If using Client class, note that connect() calls start() automatically.'
|
||||
)
|
||||
}
|
||||
|
||||
this._abortController = new AbortController()
|
||||
return await this._startOrAuth()
|
||||
}
|
||||
|
||||
/**
|
||||
* Call this method after the user has finished authorizing via their user agent and is redirected back to the MCP client application. This will exchange the authorization code for an access token, enabling the next connection attempt to successfully auth.
|
||||
*/
|
||||
async finishAuth(authorizationCode: string): Promise<void> {
|
||||
if (!this._authProvider) {
|
||||
throw new UnauthorizedError('No auth provider')
|
||||
}
|
||||
|
||||
const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode })
|
||||
if (result !== 'AUTHORIZED') {
|
||||
throw new UnauthorizedError('Failed to authorize')
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
// Close all active streams
|
||||
for (const reader of this._activeStreams.values()) {
|
||||
try {
|
||||
reader.cancel()
|
||||
} catch (error) {
|
||||
this.onerror?.(error as Error)
|
||||
}
|
||||
}
|
||||
this._activeStreams.clear()
|
||||
|
||||
// Abort any pending requests
|
||||
this._abortController?.abort()
|
||||
|
||||
// If we have a session ID, send a DELETE request to explicitly terminate the session
|
||||
if (this._sessionId) {
|
||||
try {
|
||||
const commonHeaders = await this._commonHeaders()
|
||||
const response = await fetch(this._url, {
|
||||
method: 'DELETE',
|
||||
headers: commonHeaders,
|
||||
signal: this._abortController?.signal
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
// Server might respond with 405 if it doesn't support explicit session termination
|
||||
// We don't throw an error in that case
|
||||
if (response.status !== 405) {
|
||||
const text = await response.text().catch(() => null)
|
||||
throw new Error(`Error terminating session (HTTP ${response.status}): ${text}`)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// We still want to invoke onclose even if the session termination fails
|
||||
this.onerror?.(error as Error)
|
||||
}
|
||||
}
|
||||
|
||||
this.onclose?.()
|
||||
}
|
||||
|
||||
async send(message: JSONRPCMessage | JSONRPCMessage[]): Promise<void> {
|
||||
try {
|
||||
const commonHeaders = await this._commonHeaders()
|
||||
const headers = new Headers({ ...commonHeaders, ...this._requestInit?.headers })
|
||||
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',
|
||||
headers,
|
||||
body: JSON.stringify(message),
|
||||
signal: this._abortController?.signal
|
||||
}
|
||||
|
||||
const response = await fetch(this._url, init)
|
||||
|
||||
// Handle session ID received during initialization
|
||||
const sessionId = response.headers.get('mcp-session-id')
|
||||
if (sessionId) {
|
||||
this._sessionId = sessionId
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 && this._authProvider) {
|
||||
const result = await auth(this._authProvider, { serverUrl: this._url })
|
||||
if (result !== 'AUTHORIZED') {
|
||||
throw new UnauthorizedError()
|
||||
}
|
||||
|
||||
// Purposely _not_ awaited, so we don't call onerror twice
|
||||
return this.send(message)
|
||||
}
|
||||
|
||||
const text = await response.text().catch(() => null)
|
||||
throw new Error(`Error POSTing to endpoint (HTTP ${response.status}): ${text}`)
|
||||
}
|
||||
|
||||
// If the response is 202 Accepted, there's no body to process
|
||||
if (response.status === 202) {
|
||||
return
|
||||
}
|
||||
|
||||
// Get original message(s) for detecting request IDs
|
||||
const messages = Array.isArray(message) ? message : [message]
|
||||
|
||||
// Extract IDs from request messages for tracking responses
|
||||
const requestIds = messages
|
||||
.filter((msg) => 'method' in msg && 'id' in msg)
|
||||
.map((msg) => ('id' in msg ? msg.id : undefined))
|
||||
.filter((id) => id !== undefined)
|
||||
|
||||
// If we have request IDs and an SSE response, create a unique stream ID
|
||||
const hasRequests = requestIds.length > 0
|
||||
|
||||
// Check the response type
|
||||
const contentType = response.headers.get('content-type')
|
||||
|
||||
if (hasRequests) {
|
||||
if (contentType?.includes('text/event-stream')) {
|
||||
// For streaming responses, create a unique stream ID based on request IDs
|
||||
const streamId = `req-${requestIds.join('-')}-${Date.now()}`
|
||||
this._handleSseStream(response.body, streamId)
|
||||
} else if (contentType?.includes('application/json')) {
|
||||
// For non-streaming servers, we might get direct JSON responses
|
||||
const data = await response.json()
|
||||
const responseMessages = Array.isArray(data)
|
||||
? data.map((msg) => JSONRPCMessageSchema.parse(msg))
|
||||
: [JSONRPCMessageSchema.parse(data)]
|
||||
|
||||
for (const msg of responseMessages) {
|
||||
this.onmessage?.(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.onerror?.(error as Error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private _handleSseStream(stream: ReadableStream<Uint8Array> | null, streamId: string): void {
|
||||
if (!stream) {
|
||||
return
|
||||
}
|
||||
|
||||
// Set up stream handling for server-sent events
|
||||
const reader = stream.getReader()
|
||||
this._activeStreams.set(streamId, reader)
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
const processStream = async () => {
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) {
|
||||
// Stream closed by server
|
||||
this._activeStreams.delete(streamId)
|
||||
break
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
|
||||
// Process SSE messages in the buffer
|
||||
const events = buffer.split('\n\n')
|
||||
buffer = events.pop() || ''
|
||||
|
||||
for (const event of events) {
|
||||
const lines = event.split('\n')
|
||||
let id: string | undefined
|
||||
let eventType: string | undefined
|
||||
let data: string | undefined
|
||||
|
||||
// Parse SSE message according to the format
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('id:')) {
|
||||
id = line.slice(3).trim()
|
||||
} else if (line.startsWith('event:')) {
|
||||
eventType = line.slice(6).trim()
|
||||
} else if (line.startsWith('data:')) {
|
||||
data = line.slice(5).trim()
|
||||
}
|
||||
}
|
||||
|
||||
// Update last event ID if provided by server
|
||||
// As per spec: the ID MUST be globally unique across all streams within that session
|
||||
if (id) {
|
||||
this._lastEventId = id
|
||||
}
|
||||
|
||||
// Handle message event
|
||||
if (data) {
|
||||
// Default event type is 'message' per SSE spec if not specified
|
||||
if (!eventType || eventType === 'message') {
|
||||
try {
|
||||
const message = JSONRPCMessageSchema.parse(JSON.parse(data))
|
||||
this.onmessage?.(message)
|
||||
} catch (error) {
|
||||
this.onerror?.(error as Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this._activeStreams.delete(streamId)
|
||||
this.onerror?.(error as Error)
|
||||
}
|
||||
}
|
||||
|
||||
processStream()
|
||||
}
|
||||
}
|
||||
310
src/main/services/MemoryFileService.ts
Normal file
310
src/main/services/MemoryFileService.ts
Normal 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()
|
||||
@@ -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),
|
||||
|
||||
82
src/main/services/SearchService.ts
Normal file
82
src/main/services/SearchService.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { is } from '@electron-toolkit/utils'
|
||||
import { BrowserWindow } from 'electron'
|
||||
|
||||
export class SearchService {
|
||||
private static instance: SearchService | null = null
|
||||
private searchWindows: Record<string, BrowserWindow> = {}
|
||||
public static getInstance(): SearchService {
|
||||
if (!SearchService.instance) {
|
||||
SearchService.instance = new SearchService()
|
||||
}
|
||||
return SearchService.instance
|
||||
}
|
||||
|
||||
constructor() {
|
||||
// Initialize the service
|
||||
}
|
||||
|
||||
private async createNewSearchWindow(uid: string): Promise<BrowserWindow> {
|
||||
const newWindow = new BrowserWindow({
|
||||
width: 800,
|
||||
height: 600,
|
||||
show: false,
|
||||
webPreferences: {
|
||||
nodeIntegration: true,
|
||||
contextIsolation: false,
|
||||
devTools: is.dev
|
||||
}
|
||||
})
|
||||
newWindow.webContents.session.webRequest.onBeforeSendHeaders({ urls: ['*://*/*'] }, (details, callback) => {
|
||||
const headers = {
|
||||
...details.requestHeaders,
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
|
||||
}
|
||||
callback({ requestHeaders: headers })
|
||||
})
|
||||
this.searchWindows[uid] = newWindow
|
||||
newWindow.on('closed', () => {
|
||||
delete this.searchWindows[uid]
|
||||
})
|
||||
return newWindow
|
||||
}
|
||||
|
||||
public async openSearchWindow(uid: string): Promise<void> {
|
||||
await this.createNewSearchWindow(uid)
|
||||
}
|
||||
|
||||
public async closeSearchWindow(uid: string): Promise<void> {
|
||||
const window = this.searchWindows[uid]
|
||||
if (window) {
|
||||
window.close()
|
||||
delete this.searchWindows[uid]
|
||||
}
|
||||
}
|
||||
|
||||
public async openUrlInSearchWindow(uid: string, url: string): Promise<any> {
|
||||
let window = this.searchWindows[uid]
|
||||
if (window) {
|
||||
await window.loadURL(url)
|
||||
} else {
|
||||
window = await this.createNewSearchWindow(uid)
|
||||
await window.loadURL(url)
|
||||
}
|
||||
|
||||
// Get the page content after loading the URL
|
||||
// Wait for the page to fully load before getting the content
|
||||
await new Promise<void>((resolve) => {
|
||||
const loadTimeout = setTimeout(() => resolve(), 10000) // 10 second timeout
|
||||
window.webContents.once('did-finish-load', () => {
|
||||
clearTimeout(loadTimeout)
|
||||
// Small delay to ensure JavaScript has executed
|
||||
setTimeout(resolve, 500)
|
||||
})
|
||||
})
|
||||
|
||||
// Get the page content after ensuring it's fully loaded
|
||||
const content = await window.webContents.executeJavaScript('document.documentElement.outerHTML')
|
||||
return content
|
||||
}
|
||||
}
|
||||
|
||||
export const searchService = SearchService.getInstance()
|
||||
@@ -17,7 +17,6 @@ export class WindowService {
|
||||
private mainWindow: BrowserWindow | null = null
|
||||
private miniWindow: BrowserWindow | null = null
|
||||
private isPinnedMiniWindow: boolean = false
|
||||
private wasFullScreen: boolean = false
|
||||
//hacky-fix: store the focused status of mainWindow before miniWindow shows
|
||||
//to restore the focus status when miniWindow hides
|
||||
private wasMainWindowFocused: boolean = false
|
||||
@@ -41,7 +40,8 @@ export class WindowService {
|
||||
|
||||
const mainWindowState = windowStateKeeper({
|
||||
defaultWidth: 1080,
|
||||
defaultHeight: 670
|
||||
defaultHeight: 670,
|
||||
fullScreen: false
|
||||
})
|
||||
|
||||
const theme = configManager.getTheme()
|
||||
@@ -53,7 +53,7 @@ export class WindowService {
|
||||
height: mainWindowState.height,
|
||||
minWidth: 1080,
|
||||
minHeight: 600,
|
||||
show: false, // 初始不显示
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
transparent: isMac,
|
||||
vibrancy: 'sidebar',
|
||||
@@ -138,12 +138,10 @@ export class WindowService {
|
||||
|
||||
// 处理全屏相关事件
|
||||
mainWindow.on('enter-full-screen', () => {
|
||||
this.wasFullScreen = true
|
||||
mainWindow.webContents.send(IpcChannel.FullscreenStatusChanged, true)
|
||||
})
|
||||
|
||||
mainWindow.on('leave-full-screen', () => {
|
||||
this.wasFullScreen = false
|
||||
mainWindow.webContents.send(IpcChannel.FullscreenStatusChanged, false)
|
||||
})
|
||||
|
||||
@@ -275,16 +273,6 @@ export class WindowService {
|
||||
}
|
||||
|
||||
//上述逻辑以下,是“开启托盘+设置关闭时最小化到托盘”的情况
|
||||
// 如果是Windows或Linux,且处于全屏状态,则退出应用
|
||||
if (this.wasFullScreen) {
|
||||
if (isWin || isLinux) {
|
||||
return app.quit()
|
||||
} else {
|
||||
event.preventDefault()
|
||||
mainWindow.setFullScreen(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
mainWindow.hide()
|
||||
@@ -316,13 +304,34 @@ export class WindowService {
|
||||
this.mainWindow.restore()
|
||||
return
|
||||
}
|
||||
//[macOS] Known Issue
|
||||
// setVisibleOnAllWorkspaces true/false will NOT bring window to current desktop in Mac (works fine with Windows)
|
||||
// AppleScript may be a solution, but it's not worth
|
||||
this.mainWindow.setVisibleOnAllWorkspaces(true)
|
||||
|
||||
/**
|
||||
* About setVisibleOnAllWorkspaces
|
||||
*
|
||||
* [macOS] Known Issue
|
||||
* setVisibleOnAllWorkspaces true/false will NOT bring window to current desktop in Mac (works fine with Windows)
|
||||
* AppleScript may be a solution, but it's not worth
|
||||
*
|
||||
* [Linux] Known Issue
|
||||
* setVisibleOnAllWorkspaces 在 Linux 环境下(特别是 KDE Wayland)会导致窗口进入"假弹出"状态
|
||||
* 因此在 Linux 环境下不执行这两行代码
|
||||
*/
|
||||
if (!isLinux) {
|
||||
this.mainWindow.setVisibleOnAllWorkspaces(true)
|
||||
}
|
||||
|
||||
//[macOS] After being closed in fullscreen, the fullscreen behavior will become strange when window shows again
|
||||
// So we need to set it to FALSE explicitly.
|
||||
// althougle other platforms don't have the issue, but it's a good practice to do so
|
||||
if (this.mainWindow.isFullScreen()) {
|
||||
this.mainWindow.setFullScreen(false)
|
||||
}
|
||||
|
||||
this.mainWindow.show()
|
||||
this.mainWindow.focus()
|
||||
this.mainWindow.setVisibleOnAllWorkspaces(false)
|
||||
if (!isLinux) {
|
||||
this.mainWindow.setVisibleOnAllWorkspaces(false)
|
||||
}
|
||||
} else {
|
||||
this.mainWindow = this.createMainWindow()
|
||||
}
|
||||
@@ -330,7 +339,9 @@ export class WindowService {
|
||||
|
||||
public toggleMainWindow() {
|
||||
// should not toggle main window when in full screen
|
||||
if (this.wasFullScreen) {
|
||||
// but if the main window is close to tray when it's in full screen, we can show it again
|
||||
// (it's a bug in macos, because we can close the window when it's in full screen, and the state will be remained)
|
||||
if (this.mainWindow?.isFullScreen() && this.mainWindow?.isVisible()) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -384,7 +395,8 @@ export class WindowService {
|
||||
//miniWindow should show in current desktop
|
||||
this.miniWindow?.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true })
|
||||
//make miniWindow always on top of fullscreen apps with level set
|
||||
this.miniWindow.setAlwaysOnTop(true, 'screen-saver', 1)
|
||||
//[mac] level higher than 'floating' will cover the pinyin input method
|
||||
this.miniWindow.setAlwaysOnTop(true, 'floating')
|
||||
|
||||
this.miniWindow.on('ready-to-show', () => {
|
||||
if (isPreload) {
|
||||
|
||||
22
src/preload/index.d.ts
vendored
22
src/preload/index.d.ts
vendored
@@ -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: {
|
||||
@@ -175,6 +185,18 @@ declare global {
|
||||
decryptToken: (token: string) => Promise<{ username: string; access_token: string }>
|
||||
getDirectoryContents: (token: string, path: string) => Promise<any>
|
||||
}
|
||||
searchService: {
|
||||
openSearchWindow: (uid: string) => Promise<string>
|
||||
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>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
@@ -169,6 +172,19 @@ const api = {
|
||||
decryptToken: (token: string) => ipcRenderer.invoke(IpcChannel.Nutstore_DecryptToken, token),
|
||||
getDirectoryContents: (token: string, path: string) =>
|
||||
ipcRenderer.invoke(IpcChannel.Nutstore_GetDirectoryContents, token, path)
|
||||
},
|
||||
searchService: {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
1
src/renderer/src/assets/images/mcp/npm.svg
Normal file
1
src/renderer/src/assets/images/mcp/npm.svg
Normal 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 |
@@ -1,6 +1,7 @@
|
||||
.markdown {
|
||||
color: var(--color-text);
|
||||
line-height: 1.6;
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
word-break: break-word;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CloseOutlined } from '@ant-design/icons'
|
||||
import { Tooltip } from 'antd'
|
||||
import { FC } from 'react'
|
||||
import styled from 'styled-components'
|
||||
@@ -8,13 +9,16 @@ interface CustomTagProps {
|
||||
color: string
|
||||
size?: number
|
||||
tooltip?: string
|
||||
closable?: boolean
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
const CustomTag: FC<CustomTagProps> = ({ children, icon, color, size = 12, tooltip }) => {
|
||||
const CustomTag: FC<CustomTagProps> = ({ children, icon, color, size = 12, tooltip, closable = false, onClose }) => {
|
||||
return (
|
||||
<Tooltip title={tooltip} placement="top">
|
||||
<Tag $color={color} $size={size}>
|
||||
<Tag $color={color} $size={size} $closable={closable}>
|
||||
{icon && icon} {children}
|
||||
{closable && <CloseIcon $size={size} $color={color} onClick={onClose} />}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
)
|
||||
@@ -22,19 +26,42 @@ const CustomTag: FC<CustomTagProps> = ({ children, icon, color, size = 12, toolt
|
||||
|
||||
export default CustomTag
|
||||
|
||||
const Tag = styled.div<{ $color: string; $size: number }>`
|
||||
const Tag = styled.div<{ $color: string; $size: number; $closable: boolean }>`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: ${({ $size }) => $size / 3}px ${({ $size }) => $size * 0.8}px;
|
||||
padding-right: ${({ $closable, $size }) => ($closable ? $size * 1.8 : $size * 0.8)}px;
|
||||
border-radius: 99px;
|
||||
color: ${({ $color }) => $color};
|
||||
background-color: ${({ $color }) => $color + '20'};
|
||||
font-size: ${({ $size }) => $size}px;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
.iconfont {
|
||||
font-size: ${({ $size }) => $size}px;
|
||||
color: ${({ $color }) => $color};
|
||||
}
|
||||
`
|
||||
|
||||
const CloseIcon = styled(CloseOutlined)<{ $size: number; $color: string }>`
|
||||
cursor: pointer;
|
||||
font-size: ${({ $size }) => $size * 0.8}px;
|
||||
color: ${({ $color }) => $color};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
right: ${({ $size }) => $size * 0.2}px;
|
||||
top: ${({ $size }) => $size * 0.2}px;
|
||||
bottom: ${({ $size }) => $size * 0.2}px;
|
||||
border-radius: 99px;
|
||||
transition: all 0.2s ease;
|
||||
aspect-ratio: 1;
|
||||
line-height: 1;
|
||||
&:hover {
|
||||
background-color: #da8a8a;
|
||||
color: #ffffff;
|
||||
}
|
||||
`
|
||||
|
||||
230
src/renderer/src/components/MemoryProvider.tsx
Normal file
230
src/renderer/src/components/MemoryProvider.tsx
Normal 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
|
||||
@@ -23,6 +23,7 @@ interface ModelTagsProps {
|
||||
showToolsCalling?: boolean
|
||||
size?: number
|
||||
showLabel?: boolean
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
const ModelTagsWithLabel: FC<ModelTagsProps> = ({
|
||||
@@ -31,7 +32,8 @@ const ModelTagsWithLabel: FC<ModelTagsProps> = ({
|
||||
showReasoning = true,
|
||||
showToolsCalling = true,
|
||||
size = 12,
|
||||
showLabel = true
|
||||
showLabel = true,
|
||||
style
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [_showLabel, _setShowLabel] = useState(showLabel)
|
||||
@@ -64,7 +66,7 @@ const ModelTagsWithLabel: FC<ModelTagsProps> = ({
|
||||
}, [showLabel])
|
||||
|
||||
return (
|
||||
<Container ref={containerRef}>
|
||||
<Container ref={containerRef} style={style}>
|
||||
{isVisionModel(model) && (
|
||||
<CustomTag
|
||||
size={size}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { HStack } from '../Layout'
|
||||
import ModelTags from '../ModelTags'
|
||||
import ModelTagsWithLabel from '../ModelTagsWithLabel'
|
||||
import Scrollbar from '../Scrollbar'
|
||||
|
||||
type MenuItem = Required<MenuProps>['items'][number]
|
||||
@@ -130,7 +130,7 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
|
||||
label: (
|
||||
<ModelItem>
|
||||
<ModelNameRow>
|
||||
<span>{m?.name}</span> <ModelTags model={m} />
|
||||
<span>{m?.name}</span> <ModelTagsWithLabel model={m} size={11} showLabel={false} />
|
||||
</ModelNameRow>
|
||||
<PinIcon
|
||||
onClick={(e) => {
|
||||
@@ -184,7 +184,7 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
|
||||
<span>
|
||||
{m.model?.name} | {m.provider.isSystem ? t(`provider.${m.provider.id}`) : m.provider.name}
|
||||
</span>{' '}
|
||||
<ModelTags model={m.model} />
|
||||
<ModelTagsWithLabel model={m.model} size={11} showLabel={false} />
|
||||
</ModelNameRow>
|
||||
<PinIcon
|
||||
onClick={(e) => {
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -481,6 +480,10 @@ const StyledMenu = styled(Menu)`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.anticon {
|
||||
min-width: auto;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
|
||||
273
src/renderer/src/components/Popups/ShortMemoryPopup.tsx
Normal file
273
src/renderer/src/components/Popups/ShortMemoryPopup.tsx
Normal 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
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { setAvatar } from '@renderer/store/runtime'
|
||||
import { setUserName } from '@renderer/store/settings'
|
||||
import { compressImage, isEmoji } from '@renderer/utils'
|
||||
import { Avatar, Dropdown, Input, Modal, Popover, Upload } from 'antd'
|
||||
import { useState } from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
|
||||
export type QuickPanelCloseAction = 'enter' | 'click' | 'esc' | 'outsideclick' | string | undefined
|
||||
export type QuickPanelCloseAction = 'enter' | 'click' | 'esc' | 'outsideclick' | 'enter_empty' | string | undefined
|
||||
export type QuickPanelCallBackOptions = {
|
||||
symbol: string
|
||||
action: QuickPanelCloseAction
|
||||
|
||||
@@ -2,9 +2,12 @@ import { CheckOutlined, RightOutlined } from '@ant-design/icons'
|
||||
import { isMac } from '@renderer/config/constant'
|
||||
import { classNames } from '@renderer/utils'
|
||||
import { Flex } from 'antd'
|
||||
import { theme } from 'antd'
|
||||
import Color from 'color'
|
||||
import { t } from 'i18next'
|
||||
import React, { use, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
import * as tinyPinyin from 'tiny-pinyin'
|
||||
|
||||
import { QuickPanelContext } from './provider'
|
||||
import { QuickPanelCallBackOptions, QuickPanelCloseAction, QuickPanelListItem, QuickPanelOpenOptions } from './types'
|
||||
@@ -27,13 +30,19 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
throw new Error('QuickPanel must be used within a QuickPanelProvider')
|
||||
}
|
||||
|
||||
const { token } = theme.useToken()
|
||||
const colorPrimary = Color(token.colorPrimary || '#008000')
|
||||
const selectedColor = colorPrimary.alpha(0.15).toString()
|
||||
const selectedColorHover = colorPrimary.alpha(0.2).toString()
|
||||
|
||||
const ASSISTIVE_KEY = isMac ? '⌘' : 'Ctrl'
|
||||
const [isAssistiveKeyPressed, setIsAssistiveKeyPressed] = useState(false)
|
||||
|
||||
// 避免上下翻页时,鼠标干扰
|
||||
const [isMouseOver, setIsMouseOver] = useState(false)
|
||||
|
||||
const [index, setIndex] = useState(ctx.defaultIndex)
|
||||
const [_index, setIndex] = useState(ctx.defaultIndex)
|
||||
const index = useDeferredValue(_index)
|
||||
const [historyPanel, setHistoryPanel] = useState<QuickPanelOpenOptions[]>([])
|
||||
|
||||
const bodyRef = useRef<HTMLDivElement>(null)
|
||||
@@ -65,7 +74,21 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
filterText += item.description
|
||||
}
|
||||
|
||||
return filterText.toLowerCase().includes(_searchText.toLowerCase())
|
||||
const lowerFilterText = filterText.toLowerCase()
|
||||
const lowerSearchText = _searchText.toLowerCase()
|
||||
|
||||
if (lowerFilterText.includes(lowerSearchText)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (tinyPinyin.isSupported() && /[\u4e00-\u9fa5]/.test(filterText)) {
|
||||
const pinyinText = tinyPinyin.convertToPinyin(filterText, '', true)
|
||||
if (pinyinText.toLowerCase().includes(lowerSearchText)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
setIndex(newList.length > 0 ? ctx.defaultIndex || 0 : -1)
|
||||
@@ -120,7 +143,7 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
if (textArea) {
|
||||
setInputText(textArea.value)
|
||||
}
|
||||
} else if (action && !['outsideclick', 'esc'].includes(action)) {
|
||||
} else if (action && !['outsideclick', 'esc', 'enter_empty'].includes(action)) {
|
||||
clearSearchText(true)
|
||||
}
|
||||
},
|
||||
@@ -175,6 +198,7 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
}, [searchText])
|
||||
|
||||
// 获取当前输入的搜索词
|
||||
const isComposing = useRef(false)
|
||||
useEffect(() => {
|
||||
if (!ctx.isVisible) return
|
||||
|
||||
@@ -196,11 +220,25 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleCompositionUpdate = () => {
|
||||
isComposing.current = true
|
||||
}
|
||||
|
||||
const handleCompositionEnd = () => {
|
||||
isComposing.current = false
|
||||
}
|
||||
|
||||
textArea.addEventListener('input', handleInput)
|
||||
textArea.addEventListener('compositionupdate', handleCompositionUpdate)
|
||||
textArea.addEventListener('compositionend', handleCompositionEnd)
|
||||
|
||||
return () => {
|
||||
textArea.removeEventListener('input', handleInput)
|
||||
setSearchText('')
|
||||
textArea.removeEventListener('compositionupdate', handleCompositionUpdate)
|
||||
textArea.removeEventListener('compositionend', handleCompositionEnd)
|
||||
setTimeout(() => {
|
||||
setSearchText('')
|
||||
}, 200) // 等待面板关闭动画结束后,再清空搜索词
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ctx.isVisible])
|
||||
@@ -236,7 +274,7 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Enter', 'Escape'].includes(e.key)) {
|
||||
if (['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Escape'].includes(e.key)) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsMouseOver(false)
|
||||
@@ -312,8 +350,16 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
break
|
||||
|
||||
case 'Enter':
|
||||
if (isComposing.current) return
|
||||
|
||||
if (list?.[index]) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsMouseOver(false)
|
||||
|
||||
handleItemAction(list[index], 'enter')
|
||||
} else {
|
||||
handleClose('enter_empty')
|
||||
}
|
||||
break
|
||||
case 'Escape':
|
||||
@@ -366,7 +412,11 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
}, [ctx.isVisible])
|
||||
|
||||
return (
|
||||
<QuickPanelContainer $pageSize={ctx.pageSize} className={ctx.isVisible ? 'visible' : ''}>
|
||||
<QuickPanelContainer
|
||||
$pageSize={ctx.pageSize}
|
||||
$selectedColor={selectedColor}
|
||||
$selectedColorHover={selectedColorHover}
|
||||
className={ctx.isVisible ? 'visible' : ''}>
|
||||
<QuickPanelBody ref={bodyRef} onMouseMove={() => setIsMouseOver(true)}>
|
||||
<QuickPanelContent ref={contentRef} $pageSize={ctx.pageSize} $isMouseOver={isMouseOver}>
|
||||
{list.map((item, i) => (
|
||||
@@ -450,9 +500,14 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
)
|
||||
}
|
||||
|
||||
const QuickPanelContainer = styled.div<{ $pageSize: number }>`
|
||||
const QuickPanelContainer = styled.div<{
|
||||
$pageSize: number
|
||||
$selectedColor: string
|
||||
$selectedColorHover: string
|
||||
}>`
|
||||
--focused-color: rgba(0, 0, 0, 0.06);
|
||||
--selected-color: rgba(0, 0, 0, 0.03);
|
||||
--selected-color: ${(props) => props.$selectedColor};
|
||||
--selected-color-dark: ${(props) => props.$selectedColorHover};
|
||||
max-height: 0;
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
@@ -465,26 +520,35 @@ const QuickPanelContainer = styled.div<{ $pageSize: number }>`
|
||||
transition: max-height 0.2s ease;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
|
||||
&.visible {
|
||||
pointer-events: auto;
|
||||
max-height: ${(props) => props.$pageSize * 31 + 100}px;
|
||||
}
|
||||
body[theme-mode='dark'] & {
|
||||
--focused-color: rgba(255, 255, 255, 0.1);
|
||||
--selected-color: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
`
|
||||
|
||||
const QuickPanelBody = styled.div`
|
||||
background-color: rgba(240, 240, 240, 0.5);
|
||||
backdrop-filter: blur(35px) saturate(150%);
|
||||
border-radius: 8px 8px 0 0;
|
||||
padding: 5px 0;
|
||||
border-width: 0.5px 0.5px 0 0.5px;
|
||||
border-style: solid;
|
||||
border-color: var(--color-border);
|
||||
body[theme-mode='dark'] & {
|
||||
background-color: rgba(40, 40, 40, 0.4);
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-color: rgba(240, 240, 240, 0.5);
|
||||
backdrop-filter: blur(35px) saturate(150%);
|
||||
z-index: -1;
|
||||
|
||||
body[theme-mode='dark'] & {
|
||||
background-color: rgba(40, 40, 40, 0.4);
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
@@ -503,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;
|
||||
@@ -541,6 +605,9 @@ const QuickPanelItem = styled.div`
|
||||
margin-bottom: 1px;
|
||||
&.selected {
|
||||
background-color: var(--selected-color);
|
||||
&.focused {
|
||||
background-color: var(--selected-color-dark);
|
||||
}
|
||||
}
|
||||
&.focused {
|
||||
background-color: var(--focused-color);
|
||||
@@ -568,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;
|
||||
|
||||
@@ -130,6 +130,7 @@ import XirangModelLogoDark from '@renderer/assets/images/models/xirang_dark.png'
|
||||
import YiModelLogo from '@renderer/assets/images/models/yi.png'
|
||||
import YiModelLogoDark from '@renderer/assets/images/models/yi_dark.png'
|
||||
import { getProviderByModel } from '@renderer/services/AssistantService'
|
||||
import WebSearchService from '@renderer/services/WebSearchService'
|
||||
import { Assistant, Model } from '@renderer/types'
|
||||
import OpenAI from 'openai'
|
||||
|
||||
@@ -185,7 +186,7 @@ export const TEXT_TO_IMAGE_REGEX = /flux|diffusion|stabilityai|sd-|dall|cogview|
|
||||
|
||||
// Reasoning models
|
||||
export const REASONING_REGEX =
|
||||
/^(o\d+(?:-[\w-]+)?|.*\b(?:reasoner|thinking)\b.*|.*-[rR]\d+.*|.*\bqwq(?:-[\w-]+)?\b.*|.*\bhunyuan-t1(?:-[\w-]+)?\b.*|.*\bglm-zero-preview\b.*)$/i
|
||||
/^(o\d+(?:-[\w-]+)?|.*\b(?:reasoner|thinking)\b.*|.*-[rR]\d+.*|.*\bqwq(?:-[\w-]+)?\b.*|.*\bhunyuan-t1(?:-[\w-]+)?\b.*|.*\bglm-zero-preview\b.*|.*\bgrok-3-mini(?:-[\w-]+)?\b.*)$/i
|
||||
|
||||
// Embedding models
|
||||
export const EMBEDDING_REGEX =
|
||||
@@ -209,7 +210,8 @@ export const FUNCTION_CALLING_MODELS = [
|
||||
'deepseek',
|
||||
'glm-4(?:-[\\w-]+)?',
|
||||
'learnlm(?:-[\\w-]+)?',
|
||||
'gemini(?:-[\\w-]+)?' // 提前排除了gemini的嵌入模型
|
||||
'gemini(?:-[\\w-]+)?', // 提前排除了gemini的嵌入模型
|
||||
'grok-3(?:-[\\w-]+)?'
|
||||
]
|
||||
|
||||
const FUNCTION_CALLING_EXCLUDED_MODELS = [
|
||||
@@ -233,6 +235,10 @@ export function isFunctionCallingModel(model: Model): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
if (model.provider === 'qiniu') {
|
||||
return ['deepseek-v3-tool', 'deepseek-v3-0324', 'qwq-32b', 'qwen2.5-72b-instruct'].includes(model.id)
|
||||
}
|
||||
|
||||
if (['deepseek', 'anthropic'].includes(model.provider)) {
|
||||
return true
|
||||
}
|
||||
@@ -499,12 +505,6 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
|
||||
name: 'text-embedding-3-small',
|
||||
group: '嵌入模型'
|
||||
},
|
||||
{
|
||||
id: 'text-embedding-3-small',
|
||||
provider: 'o3',
|
||||
name: 'text-embedding-3-small',
|
||||
group: '嵌入模型'
|
||||
},
|
||||
{
|
||||
id: 'text-embedding-ada-002',
|
||||
provider: 'o3',
|
||||
@@ -2014,7 +2014,56 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
|
||||
group: 'Voyage Rerank V2'
|
||||
}
|
||||
],
|
||||
qiniu: []
|
||||
qiniu: [
|
||||
{
|
||||
id: 'deepseek-r1',
|
||||
provider: 'qiniu',
|
||||
name: 'DeepSeek R1',
|
||||
group: 'DeepSeek'
|
||||
},
|
||||
{
|
||||
id: 'deepseek-r1-search',
|
||||
provider: 'qiniu',
|
||||
name: 'DeepSeek R1 Search',
|
||||
group: 'DeepSeek'
|
||||
},
|
||||
{
|
||||
id: 'deepseek-r1-32b',
|
||||
provider: 'qiniu',
|
||||
name: 'DeepSeek R1 32B',
|
||||
group: 'DeepSeek'
|
||||
},
|
||||
{
|
||||
id: 'deepseek-v3',
|
||||
provider: 'qiniu',
|
||||
name: 'DeepSeek V3',
|
||||
group: 'DeepSeek'
|
||||
},
|
||||
{
|
||||
id: 'deepseek-v3-search',
|
||||
provider: 'qiniu',
|
||||
name: 'DeepSeek V3 Search',
|
||||
group: 'DeepSeek'
|
||||
},
|
||||
{
|
||||
id: 'deepseek-v3-tool',
|
||||
provider: 'qiniu',
|
||||
name: 'DeepSeek V3 Tool',
|
||||
group: 'DeepSeek'
|
||||
},
|
||||
{
|
||||
id: 'qwq-32b',
|
||||
provider: 'qiniu',
|
||||
name: 'QWQ 32B',
|
||||
group: 'Qwen'
|
||||
},
|
||||
{
|
||||
id: 'qwen2.5-72b-instruct',
|
||||
provider: 'qiniu',
|
||||
name: 'Qwen2.5 72B Instruct',
|
||||
group: 'Qwen'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export const TEXT_TO_IMAGES_MODELS = [
|
||||
@@ -2154,12 +2203,29 @@ export function isOpenAIWebSearch(model: Model): boolean {
|
||||
return model.id.includes('gpt-4o-search-preview') || model.id.includes('gpt-4o-mini-search-preview')
|
||||
}
|
||||
|
||||
export function isSupportedResoningEffortModel(model?: Model): boolean {
|
||||
export function isSupportedReasoningEffortModel(model?: Model): boolean {
|
||||
if (!model) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (model.id.includes('claude-3-7-sonnet') || model.id.includes('claude-3.7-sonnet') || isOpenAIoSeries(model)) {
|
||||
if (
|
||||
model.id.includes('claude-3-7-sonnet') ||
|
||||
model.id.includes('claude-3.7-sonnet') ||
|
||||
isOpenAIoSeries(model) ||
|
||||
isGrokReasoningModel(model)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function isGrokReasoningModel(model?: Model): boolean {
|
||||
if (!model) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (model.id.includes('grok-3-mini')) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -2270,6 +2336,9 @@ export function isGenerateImageModel(model: Model): boolean {
|
||||
}
|
||||
|
||||
export function getOpenAIWebSearchParams(assistant: Assistant, model: Model): Record<string, any> {
|
||||
if (WebSearchService.isWebSearchEnabled() && WebSearchService.isOverwriteEnabled()) {
|
||||
return {}
|
||||
}
|
||||
if (isWebSearchModel(model)) {
|
||||
if (assistant.enableWebSearch) {
|
||||
const webSearchTools = getWebSearchTools(model)
|
||||
|
||||
@@ -31,5 +31,20 @@ export const WEB_SEARCH_PROVIDER_CONFIG = {
|
||||
official: 'https://exa.ai',
|
||||
apiKey: 'https://dashboard.exa.ai/api-keys'
|
||||
}
|
||||
},
|
||||
'local-google': {
|
||||
websites: {
|
||||
official: 'https://www.google.com'
|
||||
}
|
||||
},
|
||||
'local-bing': {
|
||||
websites: {
|
||||
official: 'https://www.bing.com'
|
||||
}
|
||||
},
|
||||
'local-baidu': {
|
||||
websites: {
|
||||
official: 'https://www.baidu.com'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import {
|
||||
addSubscribeSource as _addSubscribeSource,
|
||||
removeSubscribeSource as _removeSubscribeSource,
|
||||
setDefaultProvider as _setDefaultProvider,
|
||||
setSubscribeSources as _setSubscribeSources,
|
||||
updateSubscribeBlacklist as _updateSubscribeBlacklist,
|
||||
updateWebSearchProvider,
|
||||
updateWebSearchProviders
|
||||
} from '@renderer/store/websearch'
|
||||
@@ -25,11 +29,20 @@ export const useDefaultWebSearchProvider = () => {
|
||||
|
||||
export const useWebSearchProviders = () => {
|
||||
const providers = useAppSelector((state) => state.websearch.providers)
|
||||
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
return {
|
||||
providers,
|
||||
updateWebSearchProviders: (providers: WebSearchProvider[]) => dispatch(updateWebSearchProviders(providers))
|
||||
updateWebSearchProviders: (providers: WebSearchProvider[]) => dispatch(updateWebSearchProviders(providers)),
|
||||
addWebSearchProvider: (provider: WebSearchProvider) => {
|
||||
// Check if provider exists
|
||||
const exists = providers.some((p) => p.id === provider.id)
|
||||
if (!exists) {
|
||||
// Use the existing update action to add the new provider
|
||||
dispatch(updateWebSearchProviders([...providers, provider]))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +50,7 @@ export const useWebSearchProvider = (id: string) => {
|
||||
const providers = useAppSelector((state) => state.websearch.providers)
|
||||
const provider = providers.find((provider) => provider.id === id)
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
if (!provider) {
|
||||
throw new Error(`Web search provider with id ${id} not found`)
|
||||
}
|
||||
@@ -47,3 +61,32 @@ export const useWebSearchProvider = (id: string) => {
|
||||
|
||||
return { provider, updateProvider }
|
||||
}
|
||||
|
||||
export const useBlacklist = () => {
|
||||
const dispatch = useAppDispatch()
|
||||
const websearch = useAppSelector((state) => state.websearch)
|
||||
|
||||
const addSubscribeSource = ({ url, name, blacklist }) => {
|
||||
dispatch(_addSubscribeSource({ url, name, blacklist }))
|
||||
}
|
||||
|
||||
const removeSubscribeSource = (key: number) => {
|
||||
dispatch(_removeSubscribeSource(key))
|
||||
}
|
||||
|
||||
const updateSubscribeBlacklist = (key: number, blacklist: string[]) => {
|
||||
dispatch(_updateSubscribeBlacklist({ key, blacklist }))
|
||||
}
|
||||
|
||||
const setSubscribeSources = (sources: { key: number; url: string; name: string; blacklist?: string[] }[]) => {
|
||||
dispatch(_setSubscribeSources(sources))
|
||||
}
|
||||
|
||||
return {
|
||||
websearch,
|
||||
addSubscribeSource,
|
||||
removeSubscribeSource,
|
||||
updateSubscribeBlacklist,
|
||||
setSubscribeSources
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
"settings.reasoning_effort.low": "low",
|
||||
"settings.reasoning_effort.medium": "medium",
|
||||
"settings.reasoning_effort.off": "off",
|
||||
"settings.reasoning_effort.tip": "Only supports OpenAI o-series and Anthropic reasoning models",
|
||||
"settings.reasoning_effort.tip": "Only supported by OpenAI o-series, Anthropic, and Grok reasoning models",
|
||||
"settings.more": "Assistant Settings"
|
||||
},
|
||||
"auth": {
|
||||
@@ -299,7 +299,12 @@
|
||||
"topics": "Topics",
|
||||
"warning": "Warning",
|
||||
"you": "You",
|
||||
"reasoning_content": "Deep reasoning"
|
||||
"reasoning_content": "Deep reasoning",
|
||||
"sort": {
|
||||
"pinyin": "Sort by Pinyin",
|
||||
"pinyin.asc": "Sort by Pinyin (A-Z)",
|
||||
"pinyin.desc": "Sort by Pinyin (Z-A)"
|
||||
}
|
||||
},
|
||||
"docs": {
|
||||
"title": "Docs"
|
||||
@@ -493,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",
|
||||
@@ -551,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",
|
||||
@@ -1018,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",
|
||||
@@ -1028,8 +1161,9 @@
|
||||
"argsTooltip": "Each argument on a new line",
|
||||
"baseUrlTooltip": "Remote server base URL",
|
||||
"command": "Command",
|
||||
"sse": "Server-Sent Events(sse)",
|
||||
"stdio": "Standard Input/Output(stdio)",
|
||||
"sse": "Server-Sent Events (sse)",
|
||||
"streamableHttp": "Streamable HTTP (streamableHttp)",
|
||||
"stdio": "Standard Input/Output (stdio)",
|
||||
"inMemory": "Memory",
|
||||
"config_description": "Configure Model Context Protocol servers",
|
||||
"deleteError": "Failed to delete server",
|
||||
@@ -1057,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",
|
||||
@@ -1066,7 +1199,6 @@
|
||||
"scope_required": "Please enter npm scope",
|
||||
"search": "Search",
|
||||
"search_error": "Search error",
|
||||
"title": "NPX Package List",
|
||||
"usage": "Usage",
|
||||
"version": "Version"
|
||||
},
|
||||
@@ -1083,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?",
|
||||
@@ -1107,6 +1254,7 @@
|
||||
"messages.input.send_shortcuts": "Send shortcuts",
|
||||
"messages.input.show_estimated_tokens": "Show estimated tokens",
|
||||
"messages.input.title": "Input Settings",
|
||||
"messages.input.enable_quick_triggers": "Enable '/' and '@' triggers",
|
||||
"messages.markdown_rendering_input_message": "Markdown render input message",
|
||||
"messages.math_engine": "Math engine",
|
||||
"messages.metrics": "{{time_first_token_millsec}}ms to first token | {{token_speed}} tok/sec",
|
||||
@@ -1293,8 +1441,18 @@
|
||||
"title": "Tavily"
|
||||
},
|
||||
"title": "Web Search",
|
||||
"subscribe": "Blacklist Subscription",
|
||||
"subscribe_update": "Update now",
|
||||
"subscribe_add": "Add Subscription",
|
||||
"subscribe_url": "Subscription feed address",
|
||||
"subscribe_name": "Alternative name",
|
||||
"subscribe_name.placeholder": "Alternative name used when the downloaded subscription feed has no name.",
|
||||
"subscribe_add_success": "Subscription feed added successfully!",
|
||||
"subscribe_delete": "Delete subscription source",
|
||||
"overwrite": "Override search service",
|
||||
"overwrite_tooltip": "Force use search service instead of LLM"
|
||||
"overwrite_tooltip": "Force use search service instead of LLM",
|
||||
"apikey": "API key",
|
||||
"free": "Free"
|
||||
},
|
||||
"quickPhrase": {
|
||||
"title": "Quick Phrases",
|
||||
@@ -1362,4 +1520,4 @@
|
||||
"visualization": "Visualization"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,7 +59,7 @@
|
||||
"settings.reasoning_effort.low": "短い",
|
||||
"settings.reasoning_effort.medium": "中程度",
|
||||
"settings.reasoning_effort.off": "オフ",
|
||||
"settings.reasoning_effort.tip": "OpenAIのoシリーズとAnthropicの推論モデルのみサポートしています",
|
||||
"settings.reasoning_effort.tip": "OpenAI o-series、Anthropic、および Grok の推論モデルのみサポート",
|
||||
"settings.more": "アシスタント設定"
|
||||
},
|
||||
"auth": {
|
||||
@@ -299,7 +299,12 @@
|
||||
"topics": "トピック",
|
||||
"warning": "警告",
|
||||
"you": "あなた",
|
||||
"reasoning_content": "深く考察済み"
|
||||
"reasoning_content": "深く考察済み",
|
||||
"sort": {
|
||||
"pinyin": "ピンインでソート",
|
||||
"pinyin.asc": "ピンインで昇順ソート",
|
||||
"pinyin.desc": "ピンインで降順ソート"
|
||||
}
|
||||
},
|
||||
"docs": {
|
||||
"title": "ドキュメント"
|
||||
@@ -550,7 +555,8 @@
|
||||
"switch.disabled": "現在の応答が完了するまで切り替えを無効にします",
|
||||
"tools": {
|
||||
"completed": "完了",
|
||||
"invoking": "呼び出し中"
|
||||
"invoking": "呼び出し中",
|
||||
"error": "エラーが発生しました"
|
||||
},
|
||||
"topic.added": "新しいトピックが追加されました",
|
||||
"upgrade.success.button": "再起動",
|
||||
@@ -1027,8 +1033,9 @@
|
||||
"argsTooltip": "1行に1つの引数を入力してください",
|
||||
"baseUrlTooltip": "リモートURLアドレス",
|
||||
"command": "コマンド",
|
||||
"sse": "サーバー送信イベント(sse)",
|
||||
"stdio": "標準入力/出力(stdio)",
|
||||
"sse": "サーバー送信イベント (sse)",
|
||||
"streamableHttp": "ストリーミング可能なHTTP (streamable)",
|
||||
"stdio": "標準入力/出力 (stdio)",
|
||||
"inMemory": "メモリ",
|
||||
"config_description": "モデルコンテキストプロトコルサーバーの設定",
|
||||
"deleteError": "サーバーの削除に失敗しました",
|
||||
@@ -1056,7 +1063,6 @@
|
||||
"newServer": "MCP サーバー",
|
||||
"npx_list": {
|
||||
"actions": "アクション",
|
||||
"desc": "npm パッケージを検索して MCP サーバーとして追加",
|
||||
"description": "説明",
|
||||
"no_packages": "パッケージが見つかりません",
|
||||
"npm": "NPM",
|
||||
@@ -1065,7 +1071,6 @@
|
||||
"scope_required": "npm スコープを入力してください",
|
||||
"search": "検索",
|
||||
"search_error": "パッケージの検索に失敗しました",
|
||||
"title": "NPX パッケージリスト",
|
||||
"usage": "使用法",
|
||||
"version": "バージョン"
|
||||
},
|
||||
@@ -1082,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": "このサーバーを削除してもよろしいですか?",
|
||||
@@ -1106,6 +1126,7 @@
|
||||
"messages.input.send_shortcuts": "送信ショートカット",
|
||||
"messages.input.show_estimated_tokens": "推定トークン数を表示",
|
||||
"messages.input.title": "入力設定",
|
||||
"messages.input.enable_quick_triggers": "'/' と '@' を有効にしてクイックメニューを表示します。",
|
||||
"messages.markdown_rendering_input_message": "Markdownで入力メッセージをレンダリング",
|
||||
"messages.math_engine": "数式エンジン",
|
||||
"messages.metrics": "最初のトークンまでの時間 {{time_first_token_millsec}}ms | トークン速度 {{token_speed}} tok/sec",
|
||||
@@ -1272,7 +1293,6 @@
|
||||
"websearch": {
|
||||
"blacklist": "ブラックリスト",
|
||||
"blacklist_description": "以下のウェブサイトの結果は検索結果に表示されません",
|
||||
"blacklist_tooltip": "以下の形式を使用してください(改行区切り)\nexample.com\nhttps://www.example.com\nhttps://example.com\n*://*.example.com",
|
||||
"check": "チェック",
|
||||
"check_failed": "検証に失敗しました",
|
||||
"check_success": "検証に成功しました",
|
||||
@@ -1292,8 +1312,19 @@
|
||||
"title": "Tavily"
|
||||
},
|
||||
"title": "ウェブ検索",
|
||||
"blacklist_tooltip": "マッチパターン: *://*.example.com/*\n正規表現: /example\\.(net|org)/",
|
||||
"subscribe": "ブラックリスト購読",
|
||||
"subscribe_update": "今すぐ更新",
|
||||
"subscribe_add": "サブスクリプションを追加",
|
||||
"subscribe_url": "フィードのURL",
|
||||
"subscribe_name": "代替名",
|
||||
"subscribe_name.placeholder": "ダウンロードしたフィードに名前がない場合に使用される代替名",
|
||||
"subscribe_add_success": "フィードの追加が成功しました!",
|
||||
"subscribe_delete": "フィードの削除",
|
||||
"overwrite": "サービス検索を上書き",
|
||||
"overwrite_tooltip": "大規模言語モデルではなく、サービス検索を使用する"
|
||||
"overwrite_tooltip": "大規模言語モデルではなく、サービス検索を使用する",
|
||||
"apikey": "API キー",
|
||||
"free": "無料"
|
||||
},
|
||||
"general.auto_check_update.title": "自動更新チェックを有効にする",
|
||||
"quickPhrase": {
|
||||
@@ -1320,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": {
|
||||
@@ -1362,4 +1451,4 @@
|
||||
"visualization": "可視化"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,7 +59,7 @@
|
||||
"settings.reasoning_effort.low": "Короткая",
|
||||
"settings.reasoning_effort.medium": "Средняя",
|
||||
"settings.reasoning_effort.off": "Выключено",
|
||||
"settings.reasoning_effort.tip": "Поддерживается только моделями с рассуждением OpenAI o-series и Anthropic",
|
||||
"settings.reasoning_effort.tip": "Поддерживается только моделями рассуждений OpenAI o-series, Anthropic и Grok",
|
||||
"settings.more": "Настройки ассистента"
|
||||
},
|
||||
"auth": {
|
||||
@@ -299,7 +299,12 @@
|
||||
"topics": "Топики",
|
||||
"warning": "Предупреждение",
|
||||
"you": "Вы",
|
||||
"reasoning_content": "Глубокий анализ"
|
||||
"reasoning_content": "Глубокий анализ",
|
||||
"sort": {
|
||||
"pinyin": "Сортировать по пиньинь",
|
||||
"pinyin.asc": "Сортировать по пиньинь (А-Я)",
|
||||
"pinyin.desc": "Сортировать по пиньинь (Я-А)"
|
||||
}
|
||||
},
|
||||
"docs": {
|
||||
"title": "Документация"
|
||||
@@ -551,7 +556,8 @@
|
||||
"switch.disabled": "Пожалуйста, дождитесь завершения текущего ответа",
|
||||
"tools": {
|
||||
"completed": "Завершено",
|
||||
"invoking": "Вызов"
|
||||
"invoking": "Вызов",
|
||||
"error": "Произошла ошибка"
|
||||
},
|
||||
"topic.added": "Новый топик добавлен",
|
||||
"upgrade.success.button": "Перезапустить",
|
||||
@@ -1027,8 +1033,9 @@
|
||||
"argsTooltip": "Каждый аргумент с новой строки",
|
||||
"baseUrlTooltip": "Адрес удаленного URL",
|
||||
"command": "Команда",
|
||||
"sse": "События, отправляемые сервером(sse)",
|
||||
"stdio": "Стандартный ввод/вывод(stdio)",
|
||||
"sse": "События, отправляемые сервером (sse)",
|
||||
"streamableHttp": "Потоковый HTTP (streamableHttp)",
|
||||
"stdio": "Стандартный ввод/вывод (stdio)",
|
||||
"inMemory": "Память",
|
||||
"config_description": "Настройка серверов протокола контекста модели",
|
||||
"deleteError": "Не удалось удалить сервер",
|
||||
@@ -1056,7 +1063,6 @@
|
||||
"newServer": "MCP сервер",
|
||||
"npx_list": {
|
||||
"actions": "Действия",
|
||||
"desc": "Поиск и добавление npm пакетов в качестве MCP серверов",
|
||||
"description": "Описание",
|
||||
"no_packages": "Ничего не найдено",
|
||||
"npm": "NPM",
|
||||
@@ -1065,7 +1071,6 @@
|
||||
"scope_required": "Пожалуйста, введите область npm",
|
||||
"search": "Поиск",
|
||||
"search_error": "Ошибка поиска",
|
||||
"title": "Список пакетов NPX",
|
||||
"usage": "Использование",
|
||||
"version": "Версия"
|
||||
},
|
||||
@@ -1082,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": "Вы уверены, что хотите удалить этот сервер?",
|
||||
@@ -1106,6 +1126,7 @@
|
||||
"messages.input.send_shortcuts": "Горячие клавиши для отправки",
|
||||
"messages.input.show_estimated_tokens": "Показывать затраты токенов",
|
||||
"messages.input.title": "Настройки ввода",
|
||||
"messages.input.enable_quick_triggers": "Включите '/' и '@', чтобы вызвать быстрое меню.",
|
||||
"messages.markdown_rendering_input_message": "Отображение ввода в формате Markdown",
|
||||
"messages.math_engine": "Математический движок",
|
||||
"messages.metrics": "{{time_first_token_millsec}}ms до первого токена | {{token_speed}} tok/sec",
|
||||
@@ -1272,7 +1293,6 @@
|
||||
"websearch": {
|
||||
"blacklist": "Черный список",
|
||||
"blacklist_description": "Результаты из следующих веб-сайтов не будут отображаться в результатах поиска",
|
||||
"blacklist_tooltip": "Пожалуйста, используйте следующий формат (разделенный переносами строк)\nexample.com\nhttps://www.example.com\nhttps://example.com\n*://*.example.com",
|
||||
"check": "проверка",
|
||||
"check_failed": "Проверка не прошла",
|
||||
"check_success": "Проверка успешна",
|
||||
@@ -1292,8 +1312,19 @@
|
||||
"title": "Tavily"
|
||||
},
|
||||
"title": "Поиск в Интернете",
|
||||
"blacklist_tooltip": "Соответствующий шаблон: *://*.example.com/*\nРегулярное выражение: /example\\.(net|org)/",
|
||||
"subscribe": "Черный список подписки",
|
||||
"subscribe_update": "Обновить сейчас",
|
||||
"subscribe_add": "Добавить подписку",
|
||||
"subscribe_url": "Адрес источника подписки",
|
||||
"subscribe_name": "альтернативное имя",
|
||||
"subscribe_name.placeholder": "替代名称, используемый, когда загружаемый подписочный источник не имеет названия",
|
||||
"subscribe_add_success": "Подписка добавлена успешно!",
|
||||
"subscribe_delete": "Удалить источник подписки",
|
||||
"overwrite": "Переопределить поставщика поиска",
|
||||
"overwrite_tooltip": "Использовать поставщика поиска вместо LLM"
|
||||
"overwrite_tooltip": "Использовать поставщика поиска вместо LLM",
|
||||
"apikey": "Ключ API",
|
||||
"free": "Бесплатно"
|
||||
},
|
||||
"general.auto_check_update.title": "Включить автоматическую проверку обновлений",
|
||||
"quickPhrase": {
|
||||
@@ -1320,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": {
|
||||
@@ -1362,4 +1447,4 @@
|
||||
"visualization": "Визуализация"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,7 +59,7 @@
|
||||
"settings.reasoning_effort.low": "短",
|
||||
"settings.reasoning_effort.medium": "中",
|
||||
"settings.reasoning_effort.off": "关",
|
||||
"settings.reasoning_effort.tip": "仅支持 OpenAI o-series 和 Anthropic 推理模型",
|
||||
"settings.reasoning_effort.tip": "仅支持 OpenAI o-series、Anthropic、Grok 推理模型",
|
||||
"settings.more": "助手设置"
|
||||
},
|
||||
"auth": {
|
||||
@@ -299,7 +299,12 @@
|
||||
"topics": "话题",
|
||||
"warning": "警告",
|
||||
"you": "用户",
|
||||
"reasoning_content": "已深度思考"
|
||||
"reasoning_content": "已深度思考",
|
||||
"sort": {
|
||||
"pinyin": "按拼音排序",
|
||||
"pinyin.asc": "按拼音升序",
|
||||
"pinyin.desc": "按拼音降序"
|
||||
}
|
||||
},
|
||||
"docs": {
|
||||
"title": "帮助文档"
|
||||
@@ -493,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 地址",
|
||||
@@ -551,7 +562,8 @@
|
||||
"switch.disabled": "请等待当前回复完成后操作",
|
||||
"tools": {
|
||||
"completed": "已完成",
|
||||
"invoking": "调用中"
|
||||
"invoking": "调用中",
|
||||
"error": "发生错误"
|
||||
},
|
||||
"topic.added": "话题添加成功",
|
||||
"upgrade.success.button": "重启",
|
||||
@@ -1018,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": "启用",
|
||||
@@ -1028,8 +1222,9 @@
|
||||
"argsTooltip": "每个参数占一行",
|
||||
"baseUrlTooltip": "远程 URL 地址",
|
||||
"command": "命令",
|
||||
"sse": "服务器发送事件(sse)",
|
||||
"stdio": "标准输入/输出(stdio)",
|
||||
"sse": "服务器发送事件 (sse)",
|
||||
"streamableHttp": "可流式传输的HTTP (streamableHttp)",
|
||||
"stdio": "标准输入/输出 (stdio)",
|
||||
"inMemory": "内存",
|
||||
"config_description": "配置模型上下文协议服务器",
|
||||
"deleteError": "删除服务器失败",
|
||||
@@ -1057,7 +1252,6 @@
|
||||
"newServer": "MCP 服务器",
|
||||
"npx_list": {
|
||||
"actions": "操作",
|
||||
"desc": "搜索并添加 npm 包作为 MCP 服务",
|
||||
"description": "描述",
|
||||
"no_packages": "未找到包",
|
||||
"npm": "NPM",
|
||||
@@ -1066,7 +1260,6 @@
|
||||
"scope_required": "请输入 npm 作用域",
|
||||
"search": "搜索",
|
||||
"search_error": "搜索失败",
|
||||
"title": "NPX 包列表",
|
||||
"usage": "用法",
|
||||
"version": "版本"
|
||||
},
|
||||
@@ -1083,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": "确定要删除此服务器吗?",
|
||||
@@ -1107,6 +1315,7 @@
|
||||
"messages.input.send_shortcuts": "发送快捷键",
|
||||
"messages.input.show_estimated_tokens": "显示预估 Token 数",
|
||||
"messages.input.title": "输入设置",
|
||||
"messages.input.enable_quick_triggers": "启用 '/' 和 '@' 触发快捷菜单",
|
||||
"messages.markdown_rendering_input_message": "Markdown 渲染输入消息",
|
||||
"messages.math_engine": "数学公式引擎",
|
||||
"messages.metrics": "首字时延 {{time_first_token_millsec}}ms | 每秒 {{token_speed}} tokens",
|
||||
@@ -1273,7 +1482,7 @@
|
||||
"websearch": {
|
||||
"blacklist": "黑名单",
|
||||
"blacklist_description": "在搜索结果中不会出现以下网站的结果",
|
||||
"blacklist_tooltip": "请使用以下格式(换行分隔)\nexample.com\nhttps://www.example.com\nhttps://example.com\n*://*.example.com",
|
||||
"blacklist_tooltip": "请使用以下格式(换行分隔)\n匹配模式: *://*.example.com/*\n正则表达式: /example\\.(net|org)/",
|
||||
"check": "检查",
|
||||
"check_failed": "验证失败",
|
||||
"check_success": "验证成功",
|
||||
@@ -1286,6 +1495,14 @@
|
||||
"search_max_result": "搜索结果个数",
|
||||
"search_provider": "搜索服务商",
|
||||
"search_provider_placeholder": "选择一个搜索服务商",
|
||||
"subscribe": "黑名单订阅",
|
||||
"subscribe_update": "立即更新",
|
||||
"subscribe_add": "添加订阅",
|
||||
"subscribe_url": "订阅源地址",
|
||||
"subscribe_name": "替代名字",
|
||||
"subscribe_name.placeholder": "当下载的订阅源没有名称时所使用的替代名称",
|
||||
"subscribe_add_success": "订阅源添加成功!",
|
||||
"subscribe_delete": "删除订阅源",
|
||||
"search_result_default": "默认",
|
||||
"search_with_time": "搜索包含日期",
|
||||
"tavily": {
|
||||
@@ -1294,7 +1511,9 @@
|
||||
"description": "Tavily 是一个为 AI 代理量身定制的搜索引擎,提供实时、准确的结果、智能查询建议和深入的研究能力",
|
||||
"title": "Tavily"
|
||||
},
|
||||
"title": "网络搜索"
|
||||
"title": "网络搜索",
|
||||
"apikey": "API 密钥",
|
||||
"free": "免费"
|
||||
},
|
||||
"quickPhrase": {
|
||||
"title": "快捷短语",
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
"settings.reasoning_effort.low": "短",
|
||||
"settings.reasoning_effort.medium": "中",
|
||||
"settings.reasoning_effort.off": "關",
|
||||
"settings.reasoning_effort.tip": "僅支援 OpenAI o-series 和 Anthropic 推理模型",
|
||||
"settings.reasoning_effort.tip": "僅支援 OpenAI o-series、Anthropic 和 Grok 推理模型",
|
||||
"settings.more": "助手設定"
|
||||
},
|
||||
"auth": {
|
||||
@@ -299,7 +299,12 @@
|
||||
"topics": "話題",
|
||||
"warning": "警告",
|
||||
"you": "您",
|
||||
"reasoning_content": "已深度思考"
|
||||
"reasoning_content": "已深度思考",
|
||||
"sort": {
|
||||
"pinyin": "按拼音排序",
|
||||
"pinyin.asc": "按拼音升序",
|
||||
"pinyin.desc": "按拼音降序"
|
||||
}
|
||||
},
|
||||
"docs": {
|
||||
"title": "說明文件"
|
||||
@@ -551,7 +556,8 @@
|
||||
"switch.disabled": "請等待當前回覆完成",
|
||||
"tools": {
|
||||
"completed": "已完成",
|
||||
"invoking": "調用中"
|
||||
"invoking": "調用中",
|
||||
"error": "發生錯誤"
|
||||
},
|
||||
"topic.added": "新話題已新增",
|
||||
"upgrade.success.button": "重新啟動",
|
||||
@@ -1027,8 +1033,9 @@
|
||||
"argsTooltip": "每個參數佔一行",
|
||||
"baseUrlTooltip": "遠端 URL 地址",
|
||||
"command": "指令",
|
||||
"sse": "伺服器傳送事件(sse)",
|
||||
"stdio": "標準輸入/輸出(stdio)",
|
||||
"sse": "伺服器傳送事件 (sse)",
|
||||
"streamableHttp": "可串流的HTTP (streamableHttp)",
|
||||
"stdio": "標準輸入/輸出 (stdio)",
|
||||
"inMemory": "記憶體",
|
||||
"config_description": "設定模型上下文協議伺服器",
|
||||
"deleteError": "刪除伺服器失敗",
|
||||
@@ -1056,7 +1063,6 @@
|
||||
"newServer": "MCP 伺服器",
|
||||
"npx_list": {
|
||||
"actions": "操作",
|
||||
"desc": "搜索並添加 npm 包作為 MCP 服務",
|
||||
"description": "描述",
|
||||
"no_packages": "未找到包",
|
||||
"npm": "NPM",
|
||||
@@ -1065,7 +1071,6 @@
|
||||
"scope_required": "請輸入 npm 作用域",
|
||||
"search": "搜索",
|
||||
"search_error": "搜索失敗",
|
||||
"title": "NPX 包列表",
|
||||
"usage": "用法",
|
||||
"version": "版本"
|
||||
},
|
||||
@@ -1082,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": "確定要刪除此伺服器嗎?",
|
||||
@@ -1106,6 +1126,7 @@
|
||||
"messages.input.send_shortcuts": "傳送快捷鍵",
|
||||
"messages.input.show_estimated_tokens": "顯示預估 Token 數",
|
||||
"messages.input.title": "輸入設定",
|
||||
"messages.input.enable_quick_triggers": "啟用 '/' 和 '@' 觸發快捷選單",
|
||||
"messages.markdown_rendering_input_message": "Markdown 渲染輸入訊息",
|
||||
"messages.math_engine": "Markdown 渲染輸入訊息",
|
||||
"messages.metrics": "首字延遲 {{time_first_token_millsec}}ms | 每秒 {{token_speed}} tokens",
|
||||
@@ -1270,20 +1291,10 @@
|
||||
"tray.show": "顯示系统匣圖示",
|
||||
"tray.title": "系统匣",
|
||||
"websearch": {
|
||||
"blacklist": "黑名單",
|
||||
"blacklist_description": "以下網站不會出現在搜尋結果中",
|
||||
"blacklist_tooltip": "請使用以下格式 (換行符號分隔)\nexample.com\nhttps://www.example.com\nhttps://example.com\n*://*.example.com",
|
||||
"check": "檢查",
|
||||
"check_failed": "驗證失敗",
|
||||
"check_success": "驗證成功",
|
||||
"enhance_mode": "搜索增強模式",
|
||||
"enhance_mode_tooltip": "使用預設模型提取關鍵詞後搜索",
|
||||
"get_api_key": "點選這裡取得金鑰",
|
||||
"no_provider_selected": "請選擇搜尋服務商後再檢查",
|
||||
"search_max_result": "搜尋結果個數",
|
||||
"search_provider": "搜尋服務商",
|
||||
"search_provider_placeholder": "選擇一個搜尋服務商",
|
||||
"search_result_default": "預設",
|
||||
"search_with_time": "搜尋包含日期",
|
||||
"tavily": {
|
||||
"api_key": "Tavily API 金鑰",
|
||||
@@ -1291,9 +1302,29 @@
|
||||
"description": "Tavily 是一個為 AI 代理量身訂製的搜尋引擎,提供即時、準確的結果、智慧查詢建議和深入的研究能力",
|
||||
"title": "Tavily"
|
||||
},
|
||||
"blacklist": "黑名單",
|
||||
"blacklist_description": "以下網站不會出現在搜索結果中",
|
||||
"search_max_result": "搜尋結果個數",
|
||||
"search_result_default": "預設",
|
||||
"check": "檢查",
|
||||
"search_provider": "搜尋服務商",
|
||||
"search_provider_placeholder": "選擇一個搜尋服務商",
|
||||
"no_provider_selected": "請選擇搜索服務商後再檢查",
|
||||
"check_failed": "驗證失敗",
|
||||
"blacklist_tooltip": "匹配模式: *://*.example.com/*\n正则表达式: /example\\.(net|org)/",
|
||||
"subscribe": "黑名單訂閱",
|
||||
"subscribe_update": "立即更新",
|
||||
"subscribe_add": "添加訂閱",
|
||||
"subscribe_url": "訂閱源地址",
|
||||
"subscribe_name": "替代名稱",
|
||||
"subscribe_name.placeholder": "當下載的訂閱源沒有名稱時所使用的替代名稱",
|
||||
"subscribe_add_success": "訂閱源添加成功!",
|
||||
"subscribe_delete": "刪除訂閱源",
|
||||
"title": "網路搜尋",
|
||||
"overwrite": "覆蓋搜尋服務商",
|
||||
"overwrite_tooltip": "強制使用搜尋服務商而不是大語言模型進行搜尋"
|
||||
"overwrite_tooltip": "強制使用搜尋服務商而不是大語言模型進行搜尋",
|
||||
"apikey": "API 金鑰",
|
||||
"free": "免費"
|
||||
},
|
||||
"general.auto_check_update.title": "啟用自動更新檢查",
|
||||
"quickPhrase": {
|
||||
@@ -1320,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": {
|
||||
@@ -1362,4 +1451,4 @@
|
||||
"visualization": "視覺化"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": "Έκδοση"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,22 @@
|
||||
import { FileOutlined } from '@ant-design/icons'
|
||||
import {
|
||||
FileExcelFilled,
|
||||
FileImageFilled,
|
||||
FileMarkdownFilled,
|
||||
FilePdfFilled,
|
||||
FilePptFilled,
|
||||
FileTextFilled,
|
||||
FileUnknownFilled,
|
||||
FileWordFilled,
|
||||
FileZipFilled,
|
||||
FolderOpenFilled,
|
||||
GlobalOutlined,
|
||||
LinkOutlined
|
||||
} from '@ant-design/icons'
|
||||
import CustomTag from '@renderer/components/CustomTag'
|
||||
import FileManager from '@renderer/services/FileManager'
|
||||
import { FileType } from '@renderer/types'
|
||||
import { ConfigProvider, Image, Tag } from 'antd'
|
||||
import { formatFileSize } from '@renderer/utils'
|
||||
import { Flex, Image, Tooltip } from 'antd'
|
||||
import { isEmpty } from 'lodash'
|
||||
import { FC, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
@@ -11,74 +26,128 @@ interface Props {
|
||||
setFiles: (files: FileType[]) => void
|
||||
}
|
||||
|
||||
const AttachmentPreview: FC<Props> = ({ files, setFiles }) => {
|
||||
const [visibleId, setVisibleId] = useState('')
|
||||
|
||||
const FileNameRender: FC<{ file: FileType }> = ({ file }) => {
|
||||
const [visible, setVisible] = useState<boolean>(false)
|
||||
const isImage = (ext: string) => {
|
||||
return ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'].includes(ext)
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
styles={{
|
||||
body: {
|
||||
padding: 5
|
||||
}
|
||||
}}
|
||||
fresh
|
||||
title={
|
||||
<Flex vertical gap={2} align="center">
|
||||
{isImage(file.ext) && (
|
||||
<Image
|
||||
style={{ width: 80, maxHeight: 200 }}
|
||||
src={'file://' + FileManager.getSafePath(file)}
|
||||
preview={{
|
||||
visible: visible,
|
||||
src: 'file://' + FileManager.getSafePath(file),
|
||||
onVisibleChange: setVisible
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{formatFileSize(file.size)}
|
||||
</Flex>
|
||||
}>
|
||||
<FileName
|
||||
onClick={() => {
|
||||
if (isImage(file.ext)) {
|
||||
setVisible(true)
|
||||
return
|
||||
}
|
||||
const path = FileManager.getSafePath(file)
|
||||
if (path) {
|
||||
window.api.file.openPath(path)
|
||||
}
|
||||
}}>
|
||||
{FileManager.formatFileName(file)}
|
||||
</FileName>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
const AttachmentPreview: FC<Props> = ({ files, setFiles }) => {
|
||||
const getFileIcon = (type?: string) => {
|
||||
if (!type) return <FileUnknownFilled />
|
||||
|
||||
const ext = type.toLowerCase()
|
||||
|
||||
if (['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'].includes(ext)) {
|
||||
return <FileImageFilled />
|
||||
}
|
||||
|
||||
if (['.doc', '.docx'].includes(ext)) {
|
||||
return <FileWordFilled />
|
||||
}
|
||||
if (['.xls', '.xlsx'].includes(ext)) {
|
||||
return <FileExcelFilled />
|
||||
}
|
||||
if (['.ppt', '.pptx'].includes(ext)) {
|
||||
return <FilePptFilled />
|
||||
}
|
||||
if (ext === '.pdf') {
|
||||
return <FilePdfFilled />
|
||||
}
|
||||
if (['.md', '.markdown'].includes(ext)) {
|
||||
return <FileMarkdownFilled />
|
||||
}
|
||||
|
||||
if (['.zip', '.rar', '.7z', '.tar', '.gz'].includes(ext)) {
|
||||
return <FileZipFilled />
|
||||
}
|
||||
|
||||
if (['.txt', '.json', '.log', '.yml', '.yaml', '.xml', '.csv'].includes(ext)) {
|
||||
return <FileTextFilled />
|
||||
}
|
||||
|
||||
if (['.url'].includes(ext)) {
|
||||
return <LinkOutlined />
|
||||
}
|
||||
|
||||
if (['.sitemap'].includes(ext)) {
|
||||
return <GlobalOutlined />
|
||||
}
|
||||
|
||||
if (['.folder'].includes(ext)) {
|
||||
return <FolderOpenFilled />
|
||||
}
|
||||
|
||||
return <FileUnknownFilled />
|
||||
}
|
||||
|
||||
if (isEmpty(files)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<ContentContainer>
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
components: {
|
||||
Tag: {
|
||||
borderRadiusSM: 100
|
||||
}
|
||||
}
|
||||
}}>
|
||||
{files.map((file) => (
|
||||
<Tag
|
||||
key={file.id}
|
||||
icon={<FileOutlined />}
|
||||
bordered={false}
|
||||
color="cyan"
|
||||
closable
|
||||
onClose={() => setFiles(files.filter((f) => f.id !== file.id))}>
|
||||
<FileName
|
||||
onClick={() => {
|
||||
if (isImage(file.ext)) {
|
||||
setVisibleId(file.id)
|
||||
return
|
||||
}
|
||||
const path = FileManager.getSafePath(file)
|
||||
if (path) {
|
||||
window.api.file.openPath(path)
|
||||
}
|
||||
}}>
|
||||
{FileManager.formatFileName(file)}
|
||||
{isImage(file.ext) && (
|
||||
<Image
|
||||
style={{ display: 'none' }}
|
||||
src={'file://' + FileManager.getSafePath(file)}
|
||||
preview={{
|
||||
visible: visibleId === file.id,
|
||||
src: 'file://' + FileManager.getSafePath(file),
|
||||
onVisibleChange: (value) => {
|
||||
setVisibleId(value ? file.id : '')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</FileName>
|
||||
</Tag>
|
||||
))}
|
||||
</ConfigProvider>
|
||||
{files.map((file) => (
|
||||
<CustomTag
|
||||
key={file.id}
|
||||
icon={getFileIcon(file.ext)}
|
||||
color="#37a5aa"
|
||||
closable
|
||||
onClose={() => setFiles(files.filter((f) => f.id !== file.id))}>
|
||||
<FileNameRender file={file} />
|
||||
</CustomTag>
|
||||
))}
|
||||
</ContentContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const ContentContainer = styled.div`
|
||||
width: 100%;
|
||||
padding: 5px 15px 5px 15px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 0;
|
||||
padding: 5px 15px 0 10px;
|
||||
gap: 4px 4px;
|
||||
`
|
||||
|
||||
const FileName = styled.span`
|
||||
|
||||
@@ -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'
|
||||
@@ -84,7 +83,8 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
pasteLongTextAsFile,
|
||||
pasteLongTextThreshold,
|
||||
showInputEstimatedTokens,
|
||||
autoTranslateWithSpace
|
||||
autoTranslateWithSpace,
|
||||
enableQuickPanelTriggers
|
||||
} = useSettings()
|
||||
const [expended, setExpend] = useState(false)
|
||||
const [estimateTokenCount, setEstimateTokenCount] = useState(0)
|
||||
@@ -118,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)
|
||||
|
||||
@@ -180,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
|
||||
@@ -198,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)
|
||||
@@ -230,7 +354,6 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
inputEmpty,
|
||||
loading,
|
||||
mentionModels,
|
||||
model,
|
||||
resizeTextArea,
|
||||
selectedKnowledgeBases,
|
||||
text,
|
||||
@@ -346,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: '',
|
||||
@@ -378,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()
|
||||
@@ -531,13 +676,25 @@ 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
|
||||
const lastSymbol = newText[cursorPosition - 1]
|
||||
|
||||
if (!quickPanel.isVisible && lastSymbol === '/') {
|
||||
if (enableQuickPanelTriggers && !quickPanel.isVisible && lastSymbol === '/') {
|
||||
quickPanel.open({
|
||||
title: t('settings.quickPanel.title'),
|
||||
list: quickPanelMenu,
|
||||
@@ -545,7 +702,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
})
|
||||
}
|
||||
|
||||
if (!quickPanel.isVisible && lastSymbol === '@') {
|
||||
if (enableQuickPanelTriggers && !quickPanel.isVisible && lastSymbol === '@') {
|
||||
mentionModelsButtonRef.current?.openQuickPanel()
|
||||
}
|
||||
}
|
||||
@@ -554,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()
|
||||
@@ -689,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 })
|
||||
@@ -777,20 +965,33 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
})
|
||||
}
|
||||
|
||||
const onEnableWebSearch = () => {
|
||||
if (!isWebSearchModel(model)) {
|
||||
if (!WebSearchService.isWebSearchEnabled()) {
|
||||
window.modal.confirm({
|
||||
title: t('chat.input.web_search.enable'),
|
||||
content: t('chat.input.web_search.enable_content'),
|
||||
centered: true,
|
||||
okText: t('chat.input.web_search.button.ok'),
|
||||
onOk: () => {
|
||||
navigate('/settings/web-search')
|
||||
}
|
||||
})
|
||||
return
|
||||
const showWebSearchEnableModal = () => {
|
||||
window.modal.confirm({
|
||||
title: t('chat.input.web_search.enable'),
|
||||
content: t('chat.input.web_search.enable_content'),
|
||||
centered: true,
|
||||
okText: t('chat.input.web_search.button.ok'),
|
||||
onOk: () => {
|
||||
navigate('/settings/web-search')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const shouldShowEnableModal = () => {
|
||||
// 网络搜索功能是否未启用
|
||||
const webSearchNotEnabled = !WebSearchService.isWebSearchEnabled()
|
||||
// 非网络搜索模型:仅当网络搜索功能未启用时显示启用提示
|
||||
if (!isWebSearchModel(model)) {
|
||||
return webSearchNotEnabled
|
||||
}
|
||||
// 网络搜索模型:当允许覆盖但网络搜索功能未启用时显示启用提示
|
||||
return WebSearchService.isOverwriteEnabled() && webSearchNotEnabled
|
||||
}
|
||||
|
||||
const onEnableWebSearch = () => {
|
||||
if (shouldShowEnableModal()) {
|
||||
showWebSearchEnableModal()
|
||||
return
|
||||
}
|
||||
|
||||
updateAssistant({ ...assistant, enableWebSearch: !assistant.enableWebSearch })
|
||||
@@ -872,12 +1073,16 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
id="inputbar"
|
||||
className={classNames('inputbar-container', inputFocus && 'focus')}
|
||||
ref={containerRef}>
|
||||
<AttachmentPreview files={files} setFiles={setFiles} />
|
||||
<KnowledgeBaseInput
|
||||
selectedKnowledgeBases={selectedKnowledgeBases}
|
||||
onRemoveKnowledgeBase={handleRemoveKnowledgeBase}
|
||||
/>
|
||||
<MentionModelsInput selectedModels={mentionModels} onRemoveModel={handleRemoveModel} />
|
||||
{files.length > 0 && <AttachmentPreview files={files} setFiles={setFiles} />}
|
||||
{selectedKnowledgeBases.length > 0 && (
|
||||
<KnowledgeBaseInput
|
||||
selectedKnowledgeBases={selectedKnowledgeBases}
|
||||
onRemoveKnowledgeBase={handleRemoveKnowledgeBase}
|
||||
/>
|
||||
)}
|
||||
{mentionModels.length > 0 && (
|
||||
<MentionModelsInput selectedModels={mentionModels} onRemoveModel={handleRemoveModel} />
|
||||
)}
|
||||
<Textarea
|
||||
value={text}
|
||||
onChange={onChange}
|
||||
@@ -941,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}
|
||||
@@ -968,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}>
|
||||
@@ -1042,6 +1239,7 @@ const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
`
|
||||
|
||||
const InputBarContainer = styled.div`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FileSearchOutlined } from '@ant-design/icons'
|
||||
import CustomTag from '@renderer/components/CustomTag'
|
||||
import { KnowledgeBase } from '@renderer/types'
|
||||
import { ConfigProvider, Flex, Tag } from 'antd'
|
||||
import { FC } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@@ -9,34 +9,27 @@ const KnowledgeBaseInput: FC<{
|
||||
onRemoveKnowledgeBase: (knowledgeBase: KnowledgeBase) => void
|
||||
}> = ({ selectedKnowledgeBases, onRemoveKnowledgeBase }) => {
|
||||
return (
|
||||
<Container gap="4px 0" wrap>
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
components: {
|
||||
Tag: {
|
||||
borderRadiusSM: 100
|
||||
}
|
||||
}
|
||||
}}>
|
||||
{selectedKnowledgeBases.map((knowledgeBase) => (
|
||||
<Tag
|
||||
icon={<FileSearchOutlined />}
|
||||
bordered={false}
|
||||
color="success"
|
||||
key={knowledgeBase.id}
|
||||
closable
|
||||
onClose={() => onRemoveKnowledgeBase(knowledgeBase)}>
|
||||
{knowledgeBase.name}
|
||||
</Tag>
|
||||
))}
|
||||
</ConfigProvider>
|
||||
<Container>
|
||||
{selectedKnowledgeBases.map((knowledgeBase) => (
|
||||
<CustomTag
|
||||
icon={<FileSearchOutlined />}
|
||||
color="#3d9d0f"
|
||||
key={knowledgeBase.id}
|
||||
closable
|
||||
onClose={() => onRemoveKnowledgeBase(knowledgeBase)}>
|
||||
{knowledgeBase.name}
|
||||
</CustomTag>
|
||||
))}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled(Flex)`
|
||||
const Container = styled.div`
|
||||
width: 100%;
|
||||
padding: 5px 15px 0 10px;
|
||||
padding: 5px 15px 5px 15px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 4px;
|
||||
`
|
||||
|
||||
export default KnowledgeBaseInput
|
||||
|
||||
@@ -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 += `\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) {
|
||||
|
||||
@@ -51,7 +51,7 @@ const MentionModelsButton: FC<Props> = ({ ref, mentionModels, onMentionModel, To
|
||||
.reverse()
|
||||
.map((item) => ({
|
||||
label: `${item.provider.isSystem ? t(`provider.${item.provider.id}`) : item.provider.name} | ${item.model.name}`,
|
||||
description: <ModelTagsWithLabel model={item.model} showLabel={false} size={10} />,
|
||||
description: <ModelTagsWithLabel model={item.model} showLabel={false} size={10} style={{ opacity: 0.8 }} />,
|
||||
icon: (
|
||||
<Avatar src={getModelLogo(item.model.id)} size={20}>
|
||||
{first(item.model.name)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import CustomTag from '@renderer/components/CustomTag'
|
||||
import { useProviders } from '@renderer/hooks/useProvider'
|
||||
import { getModelUniqId } from '@renderer/services/ModelService'
|
||||
import { Model } from '@renderer/types'
|
||||
import { ConfigProvider, Flex, Tag } from 'antd'
|
||||
import { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
@@ -19,38 +19,27 @@ const MentionModelsInput: FC<{
|
||||
}
|
||||
|
||||
return (
|
||||
<Container gap="4px 0" wrap>
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
components: {
|
||||
Tag: {
|
||||
borderRadiusSM: 100
|
||||
}
|
||||
}
|
||||
}}>
|
||||
{selectedModels.map((model) => (
|
||||
<Tag
|
||||
icon={<i className="iconfont icon-at" />}
|
||||
bordered={false}
|
||||
color="processing"
|
||||
key={getModelUniqId(model)}
|
||||
closable
|
||||
onClose={() => onRemoveModel(model)}>
|
||||
{model.name} ({getProviderName(model)})
|
||||
</Tag>
|
||||
))}
|
||||
</ConfigProvider>
|
||||
<Container>
|
||||
{selectedModels.map((model) => (
|
||||
<CustomTag
|
||||
icon={<i className="iconfont icon-at" />}
|
||||
color="#1677ff"
|
||||
key={getModelUniqId(model)}
|
||||
closable
|
||||
onClose={() => onRemoveModel(model)}>
|
||||
{model.name} ({getProviderName(model)})
|
||||
</CustomTag>
|
||||
))}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled(Flex)`
|
||||
const Container = styled.div`
|
||||
width: 100%;
|
||||
padding: 5px 15px 10px;
|
||||
i.iconfont {
|
||||
font-size: 12px;
|
||||
margin-inline-end: 7px;
|
||||
}
|
||||
padding: 5px 15px 5px 15px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 4px;
|
||||
`
|
||||
|
||||
export default MentionModelsInput
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -129,12 +129,7 @@ const CodeBlock: React.FC<CodeBlockProps> = ({ children, className }) => {
|
||||
return match ? (
|
||||
<CodeBlockWrapper className="code-block">
|
||||
<CodeHeader>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
{codeCollapsible && shouldShowExpandButton && (
|
||||
<CollapseIcon expanded={isExpanded} onClick={() => setIsExpanded(!isExpanded)} />
|
||||
)}
|
||||
<CodeLanguage>{'<' + language.toUpperCase() + '>'}</CodeLanguage>
|
||||
</div>
|
||||
<CodeLanguage>{'<' + language.toUpperCase() + '>'}</CodeLanguage>
|
||||
</CodeHeader>
|
||||
<StickyWrapper>
|
||||
<HStack
|
||||
@@ -144,6 +139,9 @@ const CodeBlock: React.FC<CodeBlockProps> = ({ children, className }) => {
|
||||
style={{ bottom: '0.2rem', right: '1rem', height: '27px' }}>
|
||||
{showDownloadButton && <DownloadButton language={language} data={children} />}
|
||||
{codeWrappable && <UnwrapButton unwrapped={isUnwrapped} onClick={() => setIsUnwrapped(!isUnwrapped)} />}
|
||||
{codeCollapsible && shouldShowExpandButton && (
|
||||
<CollapseIcon expanded={isExpanded} onClick={() => setIsExpanded(!isExpanded)} />
|
||||
)}
|
||||
<CopyButton text={children} />
|
||||
</HStack>
|
||||
</StickyWrapper>
|
||||
@@ -186,10 +184,23 @@ const CodeBlock: React.FC<CodeBlockProps> = ({ children, className }) => {
|
||||
}
|
||||
|
||||
const CollapseIcon: React.FC<{ expanded: boolean; onClick: () => void }> = ({ expanded, onClick }) => {
|
||||
const { t } = useTranslation()
|
||||
const [tooltipVisible, setTooltipVisible] = useState(false)
|
||||
|
||||
const handleClick = () => {
|
||||
setTooltipVisible(false)
|
||||
onClick()
|
||||
}
|
||||
|
||||
return (
|
||||
<CollapseIconWrapper onClick={onClick}>
|
||||
{expanded ? <DownOutlined style={{ fontSize: 12 }} /> : <RightOutlined style={{ fontSize: 12 }} />}
|
||||
</CollapseIconWrapper>
|
||||
<Tooltip
|
||||
title={expanded ? t('code_block.collapse') : t('code_block.expand')}
|
||||
open={tooltipVisible}
|
||||
onOpenChange={setTooltipVisible}>
|
||||
<CollapseIconWrapper onClick={handleClick}>
|
||||
{expanded ? <DownOutlined style={{ fontSize: 12 }} /> : <RightOutlined style={{ fontSize: 12 }} />}
|
||||
</CollapseIconWrapper>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -227,6 +238,7 @@ const UnwrapButton: React.FC<{ unwrapped: boolean; onClick: () => void }> = ({ u
|
||||
const CopyButton: React.FC<{ text: string; style?: React.CSSProperties }> = ({ text, style }) => {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
const copy = t('common.copy')
|
||||
|
||||
const onCopy = () => {
|
||||
if (!text) return
|
||||
@@ -236,10 +248,12 @@ const CopyButton: React.FC<{ text: string; style?: React.CSSProperties }> = ({ t
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
return copied ? (
|
||||
<CheckOutlined style={{ color: 'var(--color-primary)', ...style }} />
|
||||
) : (
|
||||
<CopyIcon className="copy" style={style} onClick={onCopy} />
|
||||
return (
|
||||
<Tooltip title={copy}>
|
||||
<CopyButtonWrapper onClick={onCopy} style={style}>
|
||||
{copied ? <CheckOutlined style={{ color: 'var(--color-primary)' }} /> : <CopyIcon className="copy" />}
|
||||
</CopyButtonWrapper>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -319,14 +333,6 @@ const CodeHeader = styled.div`
|
||||
padding: 0 10px;
|
||||
border-top-left-radius: 8px;
|
||||
border-top-right-radius: 8px;
|
||||
.copy {
|
||||
cursor: pointer;
|
||||
color: var(--color-text-3);
|
||||
transition: color 0.3s;
|
||||
}
|
||||
.copy:hover {
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
`
|
||||
|
||||
const CodeLanguage = styled.div`
|
||||
@@ -348,7 +354,19 @@ const CodeFooter = styled.div`
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
`
|
||||
const CopyButtonWrapper = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-3);
|
||||
transition: color 0.3s;
|
||||
font-size: 16px;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
`
|
||||
const ExpandButtonWrapper = styled.div`
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
@@ -388,7 +406,6 @@ const CollapseIconWrapper = styled.div`
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-background-soft);
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
`
|
||||
|
||||
@@ -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
|
||||
}, [])
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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" />
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -36,35 +36,51 @@ const MessageImage: FC<Props> = ({ message }) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 复制 base64 图片到剪贴板
|
||||
const onCopy = async (imageBase64: string) => {
|
||||
// 复制图片到剪贴板
|
||||
const onCopy = async (type: string, image: string) => {
|
||||
try {
|
||||
const base64Data = imageBase64.split(',')[1]
|
||||
const mimeType = imageBase64.split(';')[0].split(':')[1]
|
||||
switch (type) {
|
||||
case 'base64': {
|
||||
// 处理 base64 格式的图片
|
||||
const parts = image.split(';base64,')
|
||||
if (parts.length === 2) {
|
||||
const mimeType = parts[0].replace('data:', '')
|
||||
const base64Data = parts[1]
|
||||
const byteCharacters = atob(base64Data)
|
||||
const byteArrays: Uint8Array[] = []
|
||||
|
||||
const byteCharacters = atob(base64Data)
|
||||
const byteArrays: Uint8Array[] = []
|
||||
for (let offset = 0; offset < byteCharacters.length; offset += 512) {
|
||||
const slice = byteCharacters.slice(offset, offset + 512)
|
||||
const byteNumbers = new Array(slice.length)
|
||||
for (let i = 0; i < slice.length; i++) {
|
||||
byteNumbers[i] = slice.charCodeAt(i)
|
||||
}
|
||||
const byteArray = new Uint8Array(byteNumbers)
|
||||
byteArrays.push(byteArray)
|
||||
}
|
||||
|
||||
for (let i = 0; i < byteCharacters.length; i += 512) {
|
||||
const slice = byteCharacters.slice(i, i + 512)
|
||||
|
||||
const byteNumbers = new Array(slice.length)
|
||||
for (let j = 0; j < slice.length; j++) {
|
||||
byteNumbers[j] = slice.charCodeAt(j)
|
||||
const blob = new Blob(byteArrays, { type: mimeType })
|
||||
await navigator.clipboard.write([new ClipboardItem({ [mimeType]: blob })])
|
||||
} else {
|
||||
throw new Error('无效的 base64 图片格式')
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'url':
|
||||
{
|
||||
// 处理 URL 格式的图片
|
||||
const response = await fetch(image)
|
||||
const blob = await response.blob()
|
||||
|
||||
const byteArray = new Uint8Array(byteNumbers)
|
||||
byteArrays.push(byteArray)
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({
|
||||
[blob.type]: blob
|
||||
})
|
||||
])
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
const blob = new Blob(byteArrays, { type: mimeType })
|
||||
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({
|
||||
[blob.type]: blob
|
||||
})
|
||||
])
|
||||
|
||||
window.message.success(t('message.copy.success'))
|
||||
} catch (error) {
|
||||
console.error('复制图片失败:', error)
|
||||
@@ -95,7 +111,7 @@ const MessageImage: FC<Props> = ({ message }) => {
|
||||
<ZoomOutOutlined disabled={scale === 1} onClick={onZoomOut} />
|
||||
<ZoomInOutlined disabled={scale === 50} onClick={onZoomIn} />
|
||||
<UndoOutlined onClick={onReset} />
|
||||
<CopyOutlined onClick={() => onCopy(image)} />
|
||||
<CopyOutlined onClick={() => onCopy(message.metadata?.generateImage?.type!, image)} />
|
||||
<DownloadOutlined onClick={() => onDownload(image, index)} />
|
||||
</ToobarWrapper>
|
||||
)
|
||||
|
||||
@@ -70,20 +70,7 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
|
||||
const isUserMessage = message.role === 'user'
|
||||
|
||||
const exportMenuOptions = useSelector(
|
||||
(state: RootState) =>
|
||||
state.settings.exportMenuOptions || {
|
||||
image: true,
|
||||
markdown: true,
|
||||
markdown_reason: true,
|
||||
notion: true,
|
||||
yuque: true,
|
||||
joplin: true,
|
||||
obsidian: true,
|
||||
siyuan: true,
|
||||
docx: true
|
||||
}
|
||||
)
|
||||
const exportMenuOptions = useSelector((state: RootState) => state.settings.exportMenuOptions)
|
||||
|
||||
const onCopy = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
@@ -125,6 +112,14 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
|
||||
let textToEdit = message.content
|
||||
|
||||
// 如果是包含图片的消息,添加图片的 markdown 格式
|
||||
if (message.metadata?.generateImage?.images) {
|
||||
const imageMarkdown = message.metadata.generateImage.images
|
||||
.map((image, index) => ``)
|
||||
.join('\n')
|
||||
textToEdit = `${textToEdit}\n\n${imageMarkdown}`
|
||||
}
|
||||
|
||||
if (message.role === 'assistant' && message.model && isReasoningModel(message.model)) {
|
||||
const processedMessage = withMessageThought(clone(message))
|
||||
textToEdit = processedMessage.content
|
||||
@@ -148,8 +143,47 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
})
|
||||
|
||||
if (editedText && editedText !== textToEdit) {
|
||||
await editMessage(message.id, { content: editedText })
|
||||
resendMessage && handleResendUserMessage({ ...message, content: editedText })
|
||||
// 解析编辑后的文本,提取图片 URL
|
||||
const imageRegex = /!\[image-\d+\]\((.*?)\)/g
|
||||
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, {
|
||||
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
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [message, editMessage, handleResendUserMessage, t])
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,9 +316,10 @@ 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;
|
||||
`
|
||||
|
||||
export default Messages
|
||||
|
||||
@@ -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 }>`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { DeleteOutlined, EditOutlined, MinusCircleOutlined, SaveOutlined } from '@ant-design/icons'
|
||||
import {
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
MinusCircleOutlined,
|
||||
SaveOutlined,
|
||||
SortAscendingOutlined,
|
||||
SortDescendingOutlined
|
||||
} from '@ant-design/icons'
|
||||
import ModelAvatar from '@renderer/components/Avatar/ModelAvatar'
|
||||
import CopyIcon from '@renderer/components/Icons/CopyIcon'
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { useAssistants } from '@renderer/hooks/useAssistant'
|
||||
import { modelGenerating } from '@renderer/hooks/useRuntime'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import AssistantSettingsPopup from '@renderer/pages/settings/AssistantSettings'
|
||||
@@ -16,6 +24,7 @@ import { omit } from 'lodash'
|
||||
import { FC, startTransition, useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
import * as tinyPinyin from 'tiny-pinyin'
|
||||
|
||||
interface AssistantItemProps {
|
||||
assistant: Assistant
|
||||
@@ -32,6 +41,7 @@ const AssistantItem: FC<AssistantItemProps> = ({ assistant, isActive, onSwitch,
|
||||
const { removeAllTopics } = useAssistant(assistant.id) // 使用当前助手的ID
|
||||
const { clickAssistantToShowTopic, topicPosition, showAssistantIcon } = useSettings()
|
||||
const defaultModel = getDefaultModel()
|
||||
const { assistants, updateAssistants } = useAssistants()
|
||||
|
||||
const [isPending, setIsPending] = useState(false)
|
||||
useEffect(() => {
|
||||
@@ -44,6 +54,24 @@ const AssistantItem: FC<AssistantItemProps> = ({ assistant, isActive, onSwitch,
|
||||
}
|
||||
}, [isActive, assistant.topics])
|
||||
|
||||
const sortByPinyinAsc = useCallback(() => {
|
||||
const sorted = [...assistants].sort((a, b) => {
|
||||
const pinyinA = tinyPinyin.convertToPinyin(a.name, '', true)
|
||||
const pinyinB = tinyPinyin.convertToPinyin(b.name, '', true)
|
||||
return pinyinA.localeCompare(pinyinB)
|
||||
})
|
||||
updateAssistants(sorted)
|
||||
}, [assistants, updateAssistants])
|
||||
|
||||
const sortByPinyinDesc = useCallback(() => {
|
||||
const sorted = [...assistants].sort((a, b) => {
|
||||
const pinyinA = tinyPinyin.convertToPinyin(a.name, '', true)
|
||||
const pinyinB = tinyPinyin.convertToPinyin(b.name, '', true)
|
||||
return pinyinB.localeCompare(pinyinA)
|
||||
})
|
||||
updateAssistants(sorted)
|
||||
}, [assistants, updateAssistants])
|
||||
|
||||
const getMenuItems = useCallback(
|
||||
(assistant: Assistant): ItemType[] => [
|
||||
{
|
||||
@@ -92,6 +120,19 @@ const AssistantItem: FC<AssistantItemProps> = ({ assistant, isActive, onSwitch,
|
||||
}
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
label: t('common.sort.pinyin.asc'),
|
||||
key: 'sort-asc',
|
||||
icon: <SortAscendingOutlined />,
|
||||
onClick: () => sortByPinyinAsc()
|
||||
},
|
||||
{
|
||||
label: t('common.sort.pinyin.desc'),
|
||||
key: 'sort-desc',
|
||||
icon: <SortDescendingOutlined />,
|
||||
onClick: () => sortByPinyinDesc()
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
label: t('common.delete'),
|
||||
key: 'delete',
|
||||
@@ -108,7 +149,7 @@ const AssistantItem: FC<AssistantItemProps> = ({ assistant, isActive, onSwitch,
|
||||
}
|
||||
}
|
||||
],
|
||||
[addAgent, addAssistant, onSwitch, removeAllTopics, t, onDelete]
|
||||
[addAgent, addAssistant, onSwitch, removeAllTopics, t, onDelete, sortByPinyinAsc, sortByPinyinDesc]
|
||||
)
|
||||
|
||||
const handleSwitch = useCallback(async () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import { useAgents } from '@renderer/hooks/useAgents'
|
||||
import { useAssistants } from '@renderer/hooks/useAssistant'
|
||||
import { Assistant } from '@renderer/types'
|
||||
import { FC, useCallback, useState } from 'react'
|
||||
import { FC, useCallback, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@@ -27,6 +27,7 @@ const Assistants: FC<AssistantsTabProps> = ({
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const { addAgent } = useAgents()
|
||||
const { t } = useTranslation()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const onDelete = useCallback(
|
||||
(assistant: Assistant) => {
|
||||
@@ -41,7 +42,7 @@ const Assistants: FC<AssistantsTabProps> = ({
|
||||
)
|
||||
|
||||
return (
|
||||
<Container className="assistants-tab">
|
||||
<Container className="assistants-tab" ref={containerRef}>
|
||||
<DragableList
|
||||
list={assistants}
|
||||
onUpdate={updateAssistants}
|
||||
@@ -74,7 +75,7 @@ const Assistants: FC<AssistantsTabProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
// 样式组件(只定义一次)
|
||||
// 样式组件
|
||||
const Container = styled(Scrollbar)`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
isMac,
|
||||
isWindows
|
||||
} from '@renderer/config/constant'
|
||||
import { isSupportedResoningEffortModel } from '@renderer/config/models'
|
||||
import { isGrokReasoningModel, isSupportedReasoningEffortModel } from '@renderer/config/models'
|
||||
import { codeThemes } from '@renderer/context/SyntaxHighlighterProvider'
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
setCodeShowLineNumbers,
|
||||
setCodeStyle,
|
||||
setCodeWrappable,
|
||||
setEnableQuickPanelTriggers,
|
||||
setFontSize,
|
||||
setMathEngine,
|
||||
setMessageFont,
|
||||
@@ -43,7 +44,7 @@ import {
|
||||
import { Assistant, AssistantSettings, CodeStyleVarious, ThemeMode, TranslateLanguageVarious } from '@renderer/types'
|
||||
import { modalConfirm } from '@renderer/utils'
|
||||
import { Button, Col, InputNumber, Row, Segmented, Select, Slider, Switch, Tooltip } from 'antd'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
import { FC, useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@@ -88,7 +89,8 @@ const SettingsTab: FC<Props> = (props) => {
|
||||
pasteLongTextThreshold,
|
||||
multiModelMessageStyle,
|
||||
thoughtAutoCollapse,
|
||||
messageNavigation
|
||||
messageNavigation,
|
||||
enableQuickPanelTriggers
|
||||
} = useSettings()
|
||||
|
||||
const onUpdateAssistantSettings = (settings: Partial<AssistantSettings>) => {
|
||||
@@ -113,9 +115,12 @@ const SettingsTab: FC<Props> = (props) => {
|
||||
}
|
||||
}
|
||||
|
||||
const onReasoningEffortChange = (value) => {
|
||||
updateAssistantSettings({ reasoning_effort: value })
|
||||
}
|
||||
const onReasoningEffortChange = useCallback(
|
||||
(value?: 'low' | 'medium' | 'high') => {
|
||||
updateAssistantSettings({ reasoning_effort: value })
|
||||
},
|
||||
[updateAssistantSettings]
|
||||
)
|
||||
|
||||
const onReset = () => {
|
||||
setTemperature(DEFAULT_TEMPERATURE)
|
||||
@@ -146,6 +151,22 @@ const SettingsTab: FC<Props> = (props) => {
|
||||
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.
|
||||
if (isGrokReasoningModel(assistant?.model || getDefaultModel())) {
|
||||
const currentEffort = assistant?.settings?.reasoning_effort
|
||||
if (!currentEffort || currentEffort === 'low') {
|
||||
setReasoningEffort('low') // Default to 'low' if no effort is set or if it's already 'low'.
|
||||
onReasoningEffortChange('low')
|
||||
} else if (currentEffort === 'medium' || currentEffort === 'high') {
|
||||
setReasoningEffort('high') // Force 'high' for 'medium' or 'high' to simplify the configuration.
|
||||
onReasoningEffortChange('high')
|
||||
}
|
||||
}
|
||||
}, [assistant?.model, assistant?.settings?.reasoning_effort, onReasoningEffortChange])
|
||||
|
||||
const formatSliderTooltip = (value?: number) => {
|
||||
if (value === undefined) return ''
|
||||
return value === 20 ? '∞' : value.toString()
|
||||
@@ -261,7 +282,7 @@ const SettingsTab: FC<Props> = (props) => {
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
{isSupportedResoningEffortModel(assistant?.model || getDefaultModel()) && (
|
||||
{isSupportedReasoningEffortModel(assistant?.model || getDefaultModel()) && (
|
||||
<>
|
||||
<SettingDivider />
|
||||
<Row align="middle">
|
||||
@@ -280,12 +301,19 @@ const SettingsTab: FC<Props> = (props) => {
|
||||
setReasoningEffort(typedValue)
|
||||
onReasoningEffortChange(typedValue)
|
||||
}}
|
||||
options={[
|
||||
{ value: 'low', label: t('assistants.settings.reasoning_effort.low') },
|
||||
{ value: 'medium', label: t('assistants.settings.reasoning_effort.medium') },
|
||||
{ value: 'high', label: t('assistants.settings.reasoning_effort.high') },
|
||||
{ value: 'off', label: t('assistants.settings.reasoning_effort.off') }
|
||||
]}
|
||||
options={
|
||||
isGrokReasoningModel(assistant?.model || getDefaultModel())
|
||||
? [
|
||||
{ value: 'low', label: t('assistants.settings.reasoning_effort.low') },
|
||||
{ value: 'high', label: t('assistants.settings.reasoning_effort.high') }
|
||||
]
|
||||
: [
|
||||
{ value: 'low', label: t('assistants.settings.reasoning_effort.low') },
|
||||
{ value: 'medium', label: t('assistants.settings.reasoning_effort.medium') },
|
||||
{ value: 'high', label: t('assistants.settings.reasoning_effort.high') },
|
||||
{ value: 'off', label: t('assistants.settings.reasoning_effort.off') }
|
||||
]
|
||||
}
|
||||
name="group"
|
||||
block
|
||||
/>
|
||||
@@ -570,6 +598,15 @@ const SettingsTab: FC<Props> = (props) => {
|
||||
<SettingDivider />
|
||||
</>
|
||||
)}
|
||||
<SettingRow>
|
||||
<SettingRowTitleSmall>{t('settings.messages.input.enable_quick_triggers')}</SettingRowTitleSmall>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={enableQuickPanelTriggers}
|
||||
onChange={(checked) => dispatch(setEnableQuickPanelTriggers(checked))}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitleSmall>{t('settings.input.target_language')}</SettingRowTitleSmall>
|
||||
<StyledSelect
|
||||
|
||||
@@ -156,20 +156,7 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
|
||||
[setActiveTopic]
|
||||
)
|
||||
|
||||
const exportMenuOptions = useSelector(
|
||||
(state: RootState) =>
|
||||
state.settings.exportMenuOptions || {
|
||||
image: true,
|
||||
markdown: true,
|
||||
markdown_reason: true,
|
||||
notion: true,
|
||||
yuque: true,
|
||||
joplin: true,
|
||||
obsidian: true,
|
||||
siyuan: true,
|
||||
docx: true
|
||||
}
|
||||
)
|
||||
const exportMenuOptions = useSelector((state: RootState) => state.settings.exportMenuOptions)
|
||||
|
||||
const getTopicMenuItems = useCallback(
|
||||
(topic: Topic) => {
|
||||
|
||||
@@ -13,20 +13,7 @@ const ExportMenuOptions: FC = () => {
|
||||
const { theme } = useTheme()
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const exportMenuOptions = useSelector(
|
||||
(state: RootState) =>
|
||||
state.settings.exportMenuOptions || {
|
||||
image: true,
|
||||
markdown: true,
|
||||
markdown_reason: true,
|
||||
notion: true,
|
||||
yuque: true,
|
||||
joplin: true,
|
||||
obsidian: true,
|
||||
siyuan: true,
|
||||
docx: true
|
||||
}
|
||||
)
|
||||
const exportMenuOptions = useSelector((state: RootState) => state.settings.exportMenuOptions)
|
||||
|
||||
const handleToggleOption = (option: string, checked: boolean) => {
|
||||
dispatch(
|
||||
|
||||
@@ -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')
|
||||
@@ -43,8 +44,8 @@ const InstallNpxUv: FC<Props> = ({ mini = false }) => {
|
||||
} catch (error: any) {
|
||||
window.message.error({ content: `${t('settings.mcp.installError')}: ${error.message}`, key: 'mcp-install-error' })
|
||||
setIsInstallingUv(false)
|
||||
checkBinaries()
|
||||
}
|
||||
setTimeout(checkBinaries, 1000)
|
||||
}
|
||||
|
||||
const installBun = async () => {
|
||||
@@ -59,8 +60,8 @@ const InstallNpxUv: FC<Props> = ({ mini = false }) => {
|
||||
key: 'mcp-install-error'
|
||||
})
|
||||
setIsInstallingBun(false)
|
||||
checkBinaries()
|
||||
}
|
||||
setTimeout(checkBinaries, 1000)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -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')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
96
src/renderer/src/pages/settings/MCPSettings/McpPrompt.tsx
Normal file
96
src/renderer/src/pages/settings/MCPSettings/McpPrompt.tsx
Normal 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
|
||||
@@ -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])
|
||||
@@ -149,7 +175,7 @@ const McpSettings: React.FC<Props> = ({ server }) => {
|
||||
}
|
||||
|
||||
// set stdio or sse server
|
||||
if (values.serverType === 'sse') {
|
||||
if (values.serverType === 'sse' || server.type === 'streamableHttp') {
|
||||
mcpServer.baseUrl = values.baseUrl
|
||||
} else {
|
||||
mcpServer.command = values.command
|
||||
@@ -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'
|
||||
@@ -358,7 +369,8 @@ const McpSettings: React.FC<Props> = ({ server }) => {
|
||||
onChange={(e) => setServerType(e.target.value)}
|
||||
options={[
|
||||
{ label: t('settings.mcp.stdio'), value: 'stdio' },
|
||||
{ label: t('settings.mcp.sse'), value: 'sse' }
|
||||
{ label: t('settings.mcp.sse'), value: 'sse' },
|
||||
{ label: t('settings.mcp.streamableHttp'), value: 'streamableHttp' }
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
@@ -372,6 +384,15 @@ const McpSettings: React.FC<Props> = ({ server }) => {
|
||||
<Input placeholder="http://localhost:3000/sse" />
|
||||
</Form.Item>
|
||||
)}
|
||||
{serverType === 'streamableHttp' && (
|
||||
<Form.Item
|
||||
name="baseUrl"
|
||||
label={t('settings.mcp.url')}
|
||||
rules={[{ required: serverType === 'streamableHttp', message: '' }]}
|
||||
tooltip={t('settings.mcp.baseUrlTooltip')}>
|
||||
<Input placeholder="http://localhost:3000/mcp" />
|
||||
</Form.Item>
|
||||
)}
|
||||
{serverType === 'stdio' && (
|
||||
<>
|
||||
<Form.Item
|
||||
@@ -430,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>
|
||||
)
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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;
|
||||
`
|
||||
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
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'
|
||||
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
|
||||
@@ -19,15 +18,16 @@ interface SearchResult {
|
||||
usage: string
|
||||
npmLink: string
|
||||
fullName: string
|
||||
type: 'stdio' | 'sse' | 'inMemory'
|
||||
type: MCPServer['type']
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -35,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
|
||||
|
||||
@@ -115,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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
`
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
220
src/renderer/src/pages/settings/MemorySettings/MemoryMindMap.tsx
Normal file
220
src/renderer/src/pages/settings/MemorySettings/MemoryMindMap.tsx
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
1327
src/renderer/src/pages/settings/MemorySettings/index.tsx
Normal file
1327
src/renderer/src/pages/settings/MemorySettings/index.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,23 +1,53 @@
|
||||
import { Center, VStack } from '@renderer/components/Layout'
|
||||
import { TopView } from '@renderer/components/TopView'
|
||||
import ImageStorage from '@renderer/services/ImageStorage'
|
||||
import { Provider, ProviderType } from '@renderer/types'
|
||||
import { Divider, Form, Input, Modal, Select } from 'antd'
|
||||
import { useState } from 'react'
|
||||
import { compressImage } from '@renderer/utils'
|
||||
import { Divider, Dropdown, Form, Input, Modal, Select, Upload } from 'antd'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface Props {
|
||||
provider?: Provider
|
||||
resolve: (result: { name: string; type: ProviderType }) => void
|
||||
resolve: (result: { name: string; type: ProviderType; logo?: string; logoFile?: File }) => void
|
||||
}
|
||||
|
||||
const PopupContainer: React.FC<Props> = ({ provider, resolve }) => {
|
||||
const [open, setOpen] = useState(true)
|
||||
const [name, setName] = useState(provider?.name || '')
|
||||
const [type, setType] = useState<ProviderType>(provider?.type || 'openai')
|
||||
const [logo, setLogo] = useState<string | null>(null)
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
|
||||
const onOk = () => {
|
||||
useEffect(() => {
|
||||
if (provider?.id) {
|
||||
const loadLogo = async () => {
|
||||
try {
|
||||
const logoData = await ImageStorage.get(`provider-${provider.id}`)
|
||||
if (logoData) {
|
||||
setLogo(logoData)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load logo', error)
|
||||
}
|
||||
}
|
||||
loadLogo()
|
||||
}
|
||||
}, [provider])
|
||||
|
||||
const onOk = async () => {
|
||||
setOpen(false)
|
||||
resolve({ name, type })
|
||||
|
||||
// 返回结果,但不包含文件对象,因为文件已经直接保存到 ImageStorage
|
||||
const result = {
|
||||
name,
|
||||
type,
|
||||
logo: logo || undefined
|
||||
}
|
||||
|
||||
resolve(result)
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
@@ -26,11 +56,94 @@ const PopupContainer: React.FC<Props> = ({ provider, resolve }) => {
|
||||
}
|
||||
|
||||
const onClose = () => {
|
||||
resolve({ name, type })
|
||||
resolve({ name, type, logo: logo || undefined })
|
||||
}
|
||||
|
||||
const buttonDisabled = name.length === 0
|
||||
|
||||
const handleReset = async () => {
|
||||
try {
|
||||
setLogo(null)
|
||||
|
||||
if (provider?.id) {
|
||||
await ImageStorage.set(`provider-${provider.id}`, '')
|
||||
}
|
||||
|
||||
setDropdownOpen(false)
|
||||
} catch (error: any) {
|
||||
window.message.error(error.message)
|
||||
}
|
||||
}
|
||||
|
||||
const getInitials = () => {
|
||||
return name.charAt(0).toUpperCase() || 'P'
|
||||
}
|
||||
|
||||
const items = [
|
||||
{
|
||||
key: 'upload',
|
||||
label: (
|
||||
<div style={{ width: '100%', textAlign: 'center' }}>
|
||||
<Upload
|
||||
customRequest={() => {}}
|
||||
accept="image/png, image/jpeg, image/gif"
|
||||
itemRender={() => null}
|
||||
maxCount={1}
|
||||
onChange={async ({ file }) => {
|
||||
try {
|
||||
const _file = file.originFileObj as File
|
||||
let logoData: string | Blob
|
||||
|
||||
if (_file.type === 'image/gif') {
|
||||
logoData = _file
|
||||
} else {
|
||||
logoData = await compressImage(_file)
|
||||
}
|
||||
|
||||
if (provider?.id) {
|
||||
if (logoData instanceof Blob && !(logoData instanceof File)) {
|
||||
const fileFromBlob = new File([logoData], 'logo.png', { type: logoData.type })
|
||||
await ImageStorage.set(`provider-${provider.id}`, fileFromBlob)
|
||||
} else {
|
||||
await ImageStorage.set(`provider-${provider.id}`, logoData)
|
||||
}
|
||||
const savedLogo = await ImageStorage.get(`provider-${provider.id}`)
|
||||
setLogo(savedLogo)
|
||||
} else {
|
||||
// 临时保存在内存中,等创建 provider 后会在调用方保存
|
||||
const tempUrl = await new Promise<string>((resolve) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(reader.result as string)
|
||||
reader.readAsDataURL(logoData)
|
||||
})
|
||||
setLogo(tempUrl)
|
||||
}
|
||||
|
||||
setDropdownOpen(false)
|
||||
} catch (error: any) {
|
||||
window.message.error(error.message)
|
||||
}
|
||||
}}>
|
||||
{t('settings.general.image_upload')}
|
||||
</Upload>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'reset',
|
||||
label: (
|
||||
<div
|
||||
style={{ width: '100%', textAlign: 'center' }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleReset()
|
||||
}}>
|
||||
{t('settings.general.avatar.reset')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
@@ -43,6 +156,23 @@ const PopupContainer: React.FC<Props> = ({ provider, resolve }) => {
|
||||
title={t('settings.provider.add.title')}
|
||||
okButtonProps={{ disabled: buttonDisabled }}>
|
||||
<Divider style={{ margin: '8px 0' }} />
|
||||
|
||||
<Center mt="10px" mb="20px">
|
||||
<VStack alignItems="center" gap="10px">
|
||||
<Dropdown
|
||||
menu={{ items }}
|
||||
trigger={['click']}
|
||||
open={dropdownOpen}
|
||||
align={{ offset: [0, 4] }}
|
||||
placement="bottom"
|
||||
onOpenChange={(visible) => {
|
||||
setDropdownOpen(visible)
|
||||
}}>
|
||||
{logo ? <ProviderLogo src={logo} /> : <ProviderInitialsLogo>{getInitials()}</ProviderInitialsLogo>}
|
||||
</Dropdown>
|
||||
</VStack>
|
||||
</Center>
|
||||
|
||||
<Form layout="vertical" style={{ gap: 8 }}>
|
||||
<Form.Item label={t('settings.provider.add.name')} style={{ marginBottom: 8 }}>
|
||||
<Input
|
||||
@@ -70,13 +200,46 @@ const PopupContainer: React.FC<Props> = ({ provider, resolve }) => {
|
||||
)
|
||||
}
|
||||
|
||||
const ProviderLogo = styled.img`
|
||||
cursor: pointer;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 12px;
|
||||
object-fit: contain;
|
||||
transition: opacity 0.3s ease;
|
||||
background-color: var(--color-background-soft);
|
||||
padding: 5px;
|
||||
border: 0.5px solid var(--color-border);
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
`
|
||||
|
||||
const ProviderInitialsLogo = styled.div`
|
||||
cursor: pointer;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 30px;
|
||||
font-weight: 500;
|
||||
transition: opacity 0.3s ease;
|
||||
background-color: var(--color-background-soft);
|
||||
border: 0.5px solid var(--color-border);
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
`
|
||||
|
||||
export default class AddProviderPopup {
|
||||
static topviewId = 0
|
||||
static hide() {
|
||||
TopView.hide('AddProviderPopup')
|
||||
}
|
||||
static show(provider?: Provider) {
|
||||
return new Promise<{ name: string; type: ProviderType }>((resolve) => {
|
||||
return new Promise<{ name: string; type: ProviderType; logo?: string; logoFile?: File }>((resolve) => {
|
||||
TopView.show(
|
||||
<PopupContainer
|
||||
provider={provider}
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -10,12 +10,12 @@ import {
|
||||
SettingOutlined
|
||||
} from '@ant-design/icons'
|
||||
import CustomCollapse from '@renderer/components/CustomCollapse'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import ModelTagsWithLabel from '@renderer/components/ModelTagsWithLabel'
|
||||
import { getModelLogo } from '@renderer/config/models'
|
||||
import { PROVIDER_CONFIG } from '@renderer/config/providers'
|
||||
import { useAssistants, useDefaultModel } from '@renderer/hooks/useAssistant'
|
||||
import { useProvider } from '@renderer/hooks/useProvider'
|
||||
import FileItem from '@renderer/pages/files/FileItem'
|
||||
import { ModelCheckStatus } from '@renderer/services/HealthCheckService'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import { setModel } from '@renderer/store/assistants'
|
||||
@@ -270,52 +270,48 @@ const ModelList: React.FC<ModelListProps> = ({ providerId, modelStatuses = [], s
|
||||
const isChecking = modelStatus?.checking === true
|
||||
|
||||
return (
|
||||
<FileItem
|
||||
key={model.id}
|
||||
fileInfo={{
|
||||
icon: <Avatar src={getModelLogo(model.id)}>{model?.name?.[0]?.toUpperCase()}</Avatar>,
|
||||
name: (
|
||||
<ListItemName>
|
||||
<Tooltip
|
||||
styles={{
|
||||
root: {
|
||||
width: 'auto',
|
||||
maxWidth: '500px'
|
||||
}
|
||||
}}
|
||||
destroyTooltipOnHide
|
||||
title={
|
||||
<Typography.Text style={{ color: 'white' }} copyable={{ text: model.id }}>
|
||||
{model.id}
|
||||
</Typography.Text>
|
||||
<ListItem key={model.id}>
|
||||
<HStack alignItems="center" gap={10} style={{ flex: 1 }}>
|
||||
<Avatar src={getModelLogo(model.id)} style={{ width: 30, height: 30 }}>
|
||||
{model?.name?.[0]?.toUpperCase()}
|
||||
</Avatar>
|
||||
<ListItemName>
|
||||
<Tooltip
|
||||
styles={{
|
||||
root: {
|
||||
width: 'auto',
|
||||
maxWidth: '500px'
|
||||
}
|
||||
placement="top">
|
||||
<span>{model.name}</span>
|
||||
</Tooltip>
|
||||
<ModelTagsWithLabel model={model} size={11} />
|
||||
</ListItemName>
|
||||
),
|
||||
ext: '.model',
|
||||
actions: (
|
||||
<Flex gap={4} align="center">
|
||||
{renderLatencyText(modelStatus)}
|
||||
{renderStatusIndicator(modelStatus)}
|
||||
<Button
|
||||
type="text"
|
||||
onClick={() => !isChecking && onEditModel(model)}
|
||||
disabled={isChecking}
|
||||
icon={<SettingOutlined />}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
onClick={() => !isChecking && removeModel(model)}
|
||||
disabled={isChecking}
|
||||
icon={<MinusOutlined />}
|
||||
/>
|
||||
</Flex>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
}}
|
||||
destroyTooltipOnHide
|
||||
title={
|
||||
<Typography.Text style={{ color: 'white' }} copyable={{ text: model.id }}>
|
||||
{model.id}
|
||||
</Typography.Text>
|
||||
}
|
||||
placement="top">
|
||||
<span>{model.name}</span>
|
||||
</Tooltip>
|
||||
<ModelTagsWithLabel model={model} size={11} />
|
||||
</ListItemName>
|
||||
</HStack>
|
||||
<Flex gap={4} align="center">
|
||||
{renderLatencyText(modelStatus)}
|
||||
{renderStatusIndicator(modelStatus)}
|
||||
<Button
|
||||
type="text"
|
||||
onClick={() => !isChecking && onEditModel(model)}
|
||||
disabled={isChecking}
|
||||
icon={<SettingOutlined />}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
onClick={() => !isChecking && removeModel(model)}
|
||||
disabled={isChecking}
|
||||
icon={<MinusOutlined />}
|
||||
/>
|
||||
</Flex>
|
||||
</ListItem>
|
||||
)
|
||||
})}
|
||||
</Flex>
|
||||
@@ -357,6 +353,16 @@ const ModelList: React.FC<ModelListProps> = ({ providerId, modelStatuses = [], s
|
||||
)
|
||||
}
|
||||
|
||||
const ListItem = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--color-text);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
`
|
||||
|
||||
const ListItemName = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
@@ -3,10 +3,11 @@ import { DragDropContext, Draggable, Droppable, DropResult } from '@hello-pangea
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import { getProviderLogo } from '@renderer/config/providers'
|
||||
import { useAllProviders, useProviders } from '@renderer/hooks/useProvider'
|
||||
import ImageStorage from '@renderer/services/ImageStorage'
|
||||
import { Provider } from '@renderer/types'
|
||||
import { droppableReorder, generateColorFromChar, getFirstCharacter, uuid } from '@renderer/utils'
|
||||
import { Avatar, Button, Dropdown, Input, MenuProps, Tag } from 'antd'
|
||||
import { FC, useState } from 'react'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@@ -20,6 +21,28 @@ const ProvidersList: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const [searchText, setSearchText] = useState<string>('')
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const [providerLogos, setProviderLogos] = useState<Record<string, string>>({})
|
||||
|
||||
useEffect(() => {
|
||||
const loadAllLogos = async () => {
|
||||
const logos: Record<string, string> = {}
|
||||
for (const provider of providers) {
|
||||
if (provider.id) {
|
||||
try {
|
||||
const logoData = await ImageStorage.get(`provider-${provider.id}`)
|
||||
if (logoData) {
|
||||
logos[provider.id] = logoData
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to load logo for provider ${provider.id}`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
setProviderLogos(logos)
|
||||
}
|
||||
|
||||
loadAllLogos()
|
||||
}, [providers])
|
||||
|
||||
const onDragEnd = (result: DropResult) => {
|
||||
setDragging(false)
|
||||
@@ -32,15 +55,15 @@ const ProvidersList: FC = () => {
|
||||
}
|
||||
|
||||
const onAddProvider = async () => {
|
||||
const { name: prividerName, type } = await AddProviderPopup.show()
|
||||
const { name: providerName, type, logo } = await AddProviderPopup.show()
|
||||
|
||||
if (!prividerName.trim()) {
|
||||
if (!providerName.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
const provider = {
|
||||
id: uuid(),
|
||||
name: prividerName.trim(),
|
||||
name: providerName.trim(),
|
||||
type,
|
||||
apiKey: '',
|
||||
apiHost: '',
|
||||
@@ -49,6 +72,21 @@ const ProvidersList: FC = () => {
|
||||
isSystem: false
|
||||
} as Provider
|
||||
|
||||
let updatedLogos = { ...providerLogos }
|
||||
if (logo) {
|
||||
try {
|
||||
await ImageStorage.set(`provider-${provider.id}`, logo)
|
||||
updatedLogos = {
|
||||
...updatedLogos,
|
||||
[provider.id]: logo
|
||||
}
|
||||
setProviderLogos(updatedLogos)
|
||||
} catch (error) {
|
||||
console.error('Failed to save logo', error)
|
||||
window.message.error('保存Provider Logo失败')
|
||||
}
|
||||
}
|
||||
|
||||
addProvider(provider)
|
||||
setSelectedProvider(provider)
|
||||
}
|
||||
@@ -60,8 +98,36 @@ const ProvidersList: FC = () => {
|
||||
key: 'edit',
|
||||
icon: <EditOutlined />,
|
||||
async onClick() {
|
||||
const { name, type } = await AddProviderPopup.show(provider)
|
||||
name && updateProvider({ ...provider, name, type })
|
||||
const { name, type, logoFile, logo } = await AddProviderPopup.show(provider)
|
||||
|
||||
if (name) {
|
||||
updateProvider({ ...provider, name, type })
|
||||
if (provider.id) {
|
||||
if (logoFile && logo) {
|
||||
try {
|
||||
await ImageStorage.set(`provider-${provider.id}`, logo)
|
||||
setProviderLogos((prev) => ({
|
||||
...prev,
|
||||
[provider.id]: logo
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error('Failed to save logo', error)
|
||||
window.message.error('更新Provider Logo失败')
|
||||
}
|
||||
} else if (logo === undefined && logoFile === undefined) {
|
||||
try {
|
||||
await ImageStorage.set(`provider-${provider.id}`, '')
|
||||
setProviderLogos((prev) => {
|
||||
const newLogos = { ...prev }
|
||||
delete newLogos[provider.id]
|
||||
return newLogos
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to reset logo', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -76,7 +142,21 @@ const ProvidersList: FC = () => {
|
||||
okButtonProps: { danger: true },
|
||||
okText: t('common.delete'),
|
||||
centered: true,
|
||||
onOk: () => {
|
||||
onOk: async () => {
|
||||
// 删除provider前先清理其logo
|
||||
if (provider.id) {
|
||||
try {
|
||||
await ImageStorage.remove(`provider-${provider.id}`)
|
||||
setProviderLogos((prev) => {
|
||||
const newLogos = { ...prev }
|
||||
delete newLogos[provider.id]
|
||||
return newLogos
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to delete logo', error)
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedProvider(providers.filter((p) => p.isSystem)[0])
|
||||
removeProvider(provider)
|
||||
}
|
||||
@@ -96,17 +176,33 @@ const ProvidersList: FC = () => {
|
||||
return menus
|
||||
}
|
||||
|
||||
//will match the providers and the models that provider provides
|
||||
const getProviderAvatar = (provider: Provider) => {
|
||||
if (provider.isSystem) {
|
||||
return <ProviderLogo shape="square" src={getProviderLogo(provider.id)} size={25} />
|
||||
}
|
||||
|
||||
const customLogo = providerLogos[provider.id]
|
||||
if (customLogo) {
|
||||
return <ProviderLogo shape="square" src={customLogo} size={25} />
|
||||
}
|
||||
|
||||
return (
|
||||
<ProviderLogo
|
||||
size={25}
|
||||
shape="square"
|
||||
style={{ backgroundColor: generateColorFromChar(provider.name), minWidth: 25 }}>
|
||||
{getFirstCharacter(provider.name)}
|
||||
</ProviderLogo>
|
||||
)
|
||||
}
|
||||
|
||||
const filteredProviders = providers.filter((provider) => {
|
||||
// 获取 provider 的名称
|
||||
const providerName = provider.isSystem ? t(`provider.${provider.id}`) : provider.name
|
||||
|
||||
// 检查 provider 的 id 和 name 是否匹配搜索条件
|
||||
const isProviderMatch =
|
||||
provider.id.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
providerName.toLowerCase().includes(searchText.toLowerCase())
|
||||
|
||||
// 检查 provider.models 中是否有 model 的 id 或 name 匹配搜索条件
|
||||
const isModelMatch = provider.models.some((model) => {
|
||||
return (
|
||||
model.id.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
@@ -114,7 +210,6 @@ const ProvidersList: FC = () => {
|
||||
)
|
||||
})
|
||||
|
||||
// 如果 provider 或 model 匹配,则保留该 provider
|
||||
return isProviderMatch || isModelMatch
|
||||
})
|
||||
|
||||
@@ -161,17 +256,7 @@ const ProvidersList: FC = () => {
|
||||
key={JSON.stringify(provider)}
|
||||
className={provider.id === selectedProvider?.id ? 'active' : ''}
|
||||
onClick={() => setSelectedProvider(provider)}>
|
||||
{provider.isSystem && (
|
||||
<ProviderLogo shape="square" src={getProviderLogo(provider.id)} size={25} />
|
||||
)}
|
||||
{!provider.isSystem && (
|
||||
<ProviderLogo
|
||||
size={25}
|
||||
shape="square"
|
||||
style={{ backgroundColor: generateColorFromChar(provider.name), minWidth: 25 }}>
|
||||
{getFirstCharacter(provider.name)}
|
||||
</ProviderLogo>
|
||||
)}
|
||||
{getProviderAvatar(provider)}
|
||||
<ProviderItemName className="text-nowrap">
|
||||
{provider.isSystem ? t(`provider.${provider.id}`) : provider.name}
|
||||
</ProviderItemName>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { TopView } from '@renderer/components/TopView'
|
||||
import { Button, Form, FormProps, Input, Modal } from 'antd'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface ShowParams {
|
||||
title: string
|
||||
}
|
||||
|
||||
interface Props extends ShowParams {
|
||||
resolve: (data: any) => void
|
||||
}
|
||||
|
||||
type FieldType = {
|
||||
url: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
const PopupContainer: React.FC<Props> = ({ title, resolve }) => {
|
||||
const [open, setOpen] = useState(true)
|
||||
const [form] = Form.useForm()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const onOk = () => {
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const onClose = () => {
|
||||
resolve({})
|
||||
}
|
||||
|
||||
const onFinish: FormProps<FieldType>['onFinish'] = (values) => {
|
||||
const url = values.url.trim()
|
||||
const name = values.name?.trim() || url
|
||||
|
||||
if (!url) {
|
||||
window.message.error(t('settings.websearch.url_required'))
|
||||
return
|
||||
}
|
||||
|
||||
// 验证URL格式
|
||||
try {
|
||||
new URL(url)
|
||||
} catch (e) {
|
||||
window.message.error(t('settings.websearch.url_invalid'))
|
||||
return
|
||||
}
|
||||
|
||||
resolve({ url, name })
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={title}
|
||||
open={open}
|
||||
onOk={onOk}
|
||||
onCancel={onCancel}
|
||||
maskClosable={false}
|
||||
afterClose={onClose}
|
||||
footer={null}
|
||||
centered>
|
||||
<Form
|
||||
form={form}
|
||||
labelCol={{ flex: '110px' }}
|
||||
labelAlign="left"
|
||||
colon={false}
|
||||
style={{ marginTop: 25 }}
|
||||
onFinish={onFinish}>
|
||||
<Form.Item name="url" label={t('settings.websearch.subscribe_url')} rules={[{ required: true }]}>
|
||||
<Input
|
||||
placeholder="https://git.io/ublacklist"
|
||||
spellCheck={false}
|
||||
maxLength={500}
|
||||
onChange={(e) => {
|
||||
try {
|
||||
const url = new URL(e.target.value)
|
||||
form.setFieldValue('name', url.hostname)
|
||||
} catch (e) {
|
||||
// URL不合法,忽略
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label={t('settings.websearch.subscribe_name')}>
|
||||
<Input placeholder={t('settings.websearch.subscribe_name.placeholder')} spellCheck={false} />
|
||||
</Form.Item>
|
||||
<Form.Item label=" ">
|
||||
<Button type="primary" htmlType="submit">
|
||||
{t('settings.websearch.subscribe_add')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default class AddSubscribePopup {
|
||||
static topviewId = 0
|
||||
static hide() {
|
||||
TopView.hide('AddSubscribePopup')
|
||||
}
|
||||
static show(props: ShowParams) {
|
||||
return new Promise<any>((resolve) => {
|
||||
TopView.show(
|
||||
<PopupContainer
|
||||
{...props}
|
||||
resolve={(v) => {
|
||||
resolve(v)
|
||||
this.hide()
|
||||
}}
|
||||
/>,
|
||||
'AddSubscribePopup'
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -46,8 +46,8 @@ const BasicSettings: FC = () => {
|
||||
</SettingRowTitle>
|
||||
<Switch checked={enhanceMode} onChange={(checked) => dispatch(setEnhanceMode(checked))} />
|
||||
</SettingRow>
|
||||
<SettingDivider style={{ marginTop: 15, marginBottom: 12 }} />
|
||||
<SettingRow>
|
||||
<SettingDivider style={{ marginTop: 15, marginBottom: 10 }} />
|
||||
<SettingRow style={{ height: 40 }}>
|
||||
<SettingRowTitle>{t('settings.websearch.search_max_result')}</SettingRowTitle>
|
||||
<Slider
|
||||
defaultValue={maxResults}
|
||||
|
||||
@@ -1,22 +1,63 @@
|
||||
import { CheckOutlined, InfoCircleOutlined, LoadingOutlined } from '@ant-design/icons'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useBlacklist } from '@renderer/hooks/useWebSearchProviders'
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import { setExcludeDomains } from '@renderer/store/websearch'
|
||||
import { parseMatchPattern } from '@renderer/utils/blacklistMatchPattern'
|
||||
import { Alert, Button } from 'antd'
|
||||
import { parseMatchPattern, parseSubscribeContent } from '@renderer/utils/blacklistMatchPattern'
|
||||
import { Alert, Button, Table, TableProps } from 'antd'
|
||||
import TextArea from 'antd/es/input/TextArea'
|
||||
import { t } from 'i18next'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
|
||||
import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..'
|
||||
import AddSubscribePopup from './AddSubscribePopup'
|
||||
|
||||
type TableRowSelection<T extends object = object> = TableProps<T>['rowSelection']
|
||||
interface DataType {
|
||||
key: React.Key
|
||||
url: string
|
||||
name: string
|
||||
}
|
||||
|
||||
const columns: TableProps<DataType>['columns'] = [
|
||||
{ title: t('common.name'), dataIndex: 'name', key: 'name' },
|
||||
{
|
||||
title: 'URL',
|
||||
dataIndex: 'url',
|
||||
key: 'url'
|
||||
}
|
||||
]
|
||||
|
||||
const BlacklistSettings: FC = () => {
|
||||
const [errFormat, setErrFormat] = useState(false)
|
||||
const [blacklistInput, setBlacklistInput] = useState('')
|
||||
const excludeDomains = useAppSelector((state) => state.websearch.excludeDomains)
|
||||
const { websearch, setSubscribeSources, addSubscribeSource } = useBlacklist()
|
||||
const { theme } = useTheme()
|
||||
const [subscribeChecking, setSubscribeChecking] = useState(false)
|
||||
const [subscribeValid, setSubscribeValid] = useState(false)
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([])
|
||||
const [dataSource, setDataSource] = useState<DataType[]>(
|
||||
websearch.subscribeSources?.map((source) => ({
|
||||
key: source.key,
|
||||
url: source.url,
|
||||
name: source.name
|
||||
})) || []
|
||||
)
|
||||
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
useEffect(() => {
|
||||
setDataSource(
|
||||
(websearch.subscribeSources || []).map((source) => ({
|
||||
key: source.key,
|
||||
url: source.url,
|
||||
name: source.name
|
||||
}))
|
||||
)
|
||||
console.log('subscribeSources', websearch.subscribeSources)
|
||||
}, [websearch.subscribeSources])
|
||||
|
||||
useEffect(() => {
|
||||
if (excludeDomains) {
|
||||
setBlacklistInput(excludeDomains.join('\n'))
|
||||
@@ -40,6 +81,137 @@ const BlacklistSettings: FC = () => {
|
||||
if (hasError) return
|
||||
|
||||
dispatch(setExcludeDomains(validDomains))
|
||||
window.message.info({
|
||||
content: t('message.save.success.title'),
|
||||
duration: 4,
|
||||
icon: <InfoCircleOutlined />,
|
||||
key: 'save-blacklist-info'
|
||||
})
|
||||
}
|
||||
const onSelectChange = (newSelectedRowKeys: React.Key[]) => {
|
||||
console.log('selectedRowKeys changed: ', newSelectedRowKeys)
|
||||
setSelectedRowKeys(newSelectedRowKeys)
|
||||
}
|
||||
|
||||
const rowSelection: TableRowSelection<DataType> = {
|
||||
selectedRowKeys,
|
||||
onChange: onSelectChange
|
||||
}
|
||||
async function updateSubscribe() {
|
||||
setSubscribeChecking(true)
|
||||
|
||||
try {
|
||||
// 获取选中的订阅源
|
||||
const selectedSources = dataSource.filter((item) => selectedRowKeys.includes(item.key))
|
||||
|
||||
// 用于存储所有成功解析的订阅源数据
|
||||
const updatedSources: {
|
||||
key: number
|
||||
url: string
|
||||
name: string
|
||||
blacklist: string[]
|
||||
}[] = []
|
||||
|
||||
// 为每个选中的订阅源获取并解析内容
|
||||
for (const source of selectedSources) {
|
||||
try {
|
||||
// 获取并解析订阅源内容
|
||||
const blacklist = await parseSubscribeContent(source.url)
|
||||
|
||||
if (blacklist.length > 0) {
|
||||
updatedSources.push({
|
||||
key: Number(source.key),
|
||||
url: source.url,
|
||||
name: source.name,
|
||||
blacklist
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error updating subscribe source ${source.url}:`, error)
|
||||
// 显示具体源更新失败的消息
|
||||
window.message.warning({
|
||||
content: t('settings.websearch.subscribe_source_update_failed', { url: source.url }),
|
||||
duration: 3
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (updatedSources.length > 0) {
|
||||
// 更新 Redux store
|
||||
setSubscribeSources(updatedSources)
|
||||
setSubscribeValid(true)
|
||||
// 显示成功消息
|
||||
window.message.success({
|
||||
content: t('settings.websearch.subscribe_update_success'),
|
||||
duration: 2
|
||||
})
|
||||
setTimeout(() => setSubscribeValid(false), 3000)
|
||||
} else {
|
||||
setSubscribeValid(false)
|
||||
throw new Error('No valid sources updated')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating subscribes:', error)
|
||||
window.message.error({
|
||||
content: t('settings.websearch.subscribe_update_failed'),
|
||||
duration: 2
|
||||
})
|
||||
}
|
||||
setSubscribeChecking(false)
|
||||
}
|
||||
|
||||
// 修改 handleAddSubscribe 函数
|
||||
async function handleAddSubscribe() {
|
||||
setSubscribeChecking(true)
|
||||
const result = await AddSubscribePopup.show({
|
||||
title: t('settings.websearch.subscribe_add')
|
||||
})
|
||||
|
||||
if (result && result.url) {
|
||||
try {
|
||||
// 获取并解析订阅源内容
|
||||
const blacklist = await parseSubscribeContent(result.url)
|
||||
|
||||
if (blacklist.length === 0) {
|
||||
throw new Error('No valid patterns found in subscribe content')
|
||||
}
|
||||
// 添加到 Redux store
|
||||
addSubscribeSource({
|
||||
url: result.url,
|
||||
name: result.name || result.url,
|
||||
blacklist
|
||||
})
|
||||
setSubscribeValid(true)
|
||||
// 显示成功消息
|
||||
window.message.success({
|
||||
content: t('settings.websearch.subscribe_add_success'),
|
||||
duration: 2
|
||||
})
|
||||
setTimeout(() => setSubscribeValid(false), 3000)
|
||||
} catch (error) {
|
||||
setSubscribeValid(false)
|
||||
window.message.error({
|
||||
content: t('settings.websearch.subscribe_add_failed'),
|
||||
duration: 2
|
||||
})
|
||||
}
|
||||
}
|
||||
setSubscribeChecking(false)
|
||||
}
|
||||
function handleDeleteSubscribe() {
|
||||
try {
|
||||
// 过滤掉被选中要删除的项目
|
||||
const remainingSources =
|
||||
websearch.subscribeSources?.filter((source) => !selectedRowKeys.includes(source.key)) || []
|
||||
|
||||
// 更新 Redux store
|
||||
setSubscribeSources(remainingSources)
|
||||
|
||||
// 清空选中状态
|
||||
setSelectedRowKeys([])
|
||||
} catch (error) {
|
||||
console.error('Error deleting subscribes:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -62,6 +234,46 @@ const BlacklistSettings: FC = () => {
|
||||
</Button>
|
||||
{errFormat && <Alert message={t('settings.websearch.blacklist_tooltip')} type="error" />}
|
||||
</SettingGroup>
|
||||
<SettingGroup theme={theme}>
|
||||
<SettingTitle>
|
||||
{t('settings.websearch.subscribe')}
|
||||
<Button
|
||||
type={subscribeValid ? 'primary' : 'default'}
|
||||
ghost={subscribeValid}
|
||||
disabled={subscribeChecking}
|
||||
onClick={handleAddSubscribe}>
|
||||
{t('settings.websearch.subscribe_add')}
|
||||
</Button>
|
||||
</SettingTitle>
|
||||
<SettingDivider />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '5px' }}>
|
||||
<Table<DataType>
|
||||
rowSelection={{ type: 'checkbox', ...rowSelection }}
|
||||
columns={columns}
|
||||
dataSource={dataSource}
|
||||
pagination={{ position: ['none'] }}
|
||||
/>
|
||||
<SettingRow style={{ height: 50 }}>
|
||||
<Button
|
||||
type={subscribeValid ? 'primary' : 'default'}
|
||||
ghost={subscribeValid}
|
||||
disabled={subscribeChecking || selectedRowKeys.length === 0}
|
||||
style={{ width: 100 }}
|
||||
onClick={updateSubscribe}>
|
||||
{subscribeChecking ? (
|
||||
<LoadingOutlined spin />
|
||||
) : subscribeValid ? (
|
||||
<CheckOutlined />
|
||||
) : (
|
||||
t('settings.websearch.subscribe_update')
|
||||
)}
|
||||
</Button>
|
||||
<Button style={{ width: 100 }} disabled={selectedRowKeys.length === 0} onClick={handleDeleteSubscribe}>
|
||||
{t('settings.websearch.subscribe_delete')}
|
||||
</Button>
|
||||
</SettingRow>
|
||||
</div>
|
||||
</SettingGroup>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -117,7 +117,6 @@ const WebSearchProviderSetting: FC<Props> = ({ provider: _provider }) => {
|
||||
<SettingTitle>
|
||||
<Flex align="center" gap={8}>
|
||||
<ProviderLogo shape="square" src={getWebSearchProviderLogo(provider.id)} size={16} />
|
||||
|
||||
<ProviderName> {provider.name}</ProviderName>
|
||||
{officialWebsite && webSearchProviderConfig?.websites && (
|
||||
<Link target="_blank" href={webSearchProviderConfig.websites.official}>
|
||||
@@ -156,7 +155,6 @@ const WebSearchProviderSetting: FC<Props> = ({ provider: _provider }) => {
|
||||
</SettingHelpTextRow>
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasObjectKey(provider, 'apiHost') && (
|
||||
<>
|
||||
<SettingSubtitle style={{ marginTop: 5, marginBottom: 10 }}>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user