Compare commits
183 Commits
main
...
v1.5.0-rc.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e44baf3b1 | ||
|
|
2afc65bfc6 | ||
|
|
501c352d8b | ||
|
|
6e3079c1ae | ||
|
|
26b8f34634 | ||
|
|
f7822bdd89 | ||
|
|
b1c8811622 | ||
|
|
d966c286ff | ||
|
|
98de63b0f3 | ||
|
|
8e34b024e2 | ||
|
|
c302a04c3e | ||
|
|
f4367f5589 | ||
|
|
210586f2e9 | ||
|
|
68b0f5f460 | ||
|
|
0d9fd1e6f8 | ||
|
|
29af30b8d2 | ||
|
|
4d8c60c3a5 | ||
|
|
794fc2721e | ||
|
|
535d00dae2 | ||
|
|
3498423b6f | ||
|
|
b43cdd124c | ||
|
|
550740b42a | ||
|
|
ef4a7b042b | ||
|
|
9566ce3d9a | ||
|
|
571195e372 | ||
|
|
d38bcddefa | ||
|
|
51d64293d6 | ||
|
|
6f38702cab | ||
|
|
62669732a0 | ||
|
|
3c8d13fc43 | ||
|
|
c7bfec9098 | ||
|
|
b4b71ee099 | ||
|
|
95d9e2ced4 | ||
|
|
c578e29621 | ||
|
|
4530872372 | ||
|
|
f907f7fe5e | ||
|
|
f32ccd0fba | ||
|
|
79f63dd954 | ||
|
|
8589cda241 | ||
|
|
4e03c489b0 | ||
|
|
574975f32d | ||
|
|
dc3e28b414 | ||
|
|
ba35ec1b4c | ||
|
|
9329eeb2c3 | ||
|
|
8b1743419d | ||
|
|
a9b251e11b | ||
|
|
a05b0502bb | ||
|
|
58dc927347 | ||
|
|
63718b7405 | ||
|
|
f5dfe3c7c0 | ||
|
|
e318f8023a | ||
|
|
d0a9a47d31 | ||
|
|
2db480f97a | ||
|
|
e7531a26bf | ||
|
|
f03b7e1eff | ||
|
|
fcd999e3b4 | ||
|
|
62251f456b | ||
|
|
0d4ee05d13 | ||
|
|
a2172a1d6b | ||
|
|
1d626af789 | ||
|
|
262173774e | ||
|
|
43f2237cb9 | ||
|
|
8bb6cb2074 | ||
|
|
bf44d283c6 | ||
|
|
3abc11a29b | ||
|
|
5cf2b5f9ee | ||
|
|
91b012a2cc | ||
|
|
e4a546a776 | ||
|
|
5b8644db50 | ||
|
|
b3354dfc27 | ||
|
|
d0fb21aab3 | ||
|
|
d3fe587b6e | ||
|
|
c597905b78 | ||
|
|
7739e67140 | ||
|
|
d676dd349b | ||
|
|
1115c54460 | ||
|
|
3f2140807f | ||
|
|
beec45b373 | ||
|
|
71ef510ac0 | ||
|
|
8992c2788b | ||
|
|
68840ff907 | ||
|
|
1b2b17f56d | ||
|
|
76d4433519 | ||
|
|
7474b654eb | ||
|
|
f72796b59f | ||
|
|
1bc52eecc6 | ||
|
|
d015faa8ab | ||
|
|
593dc903fd | ||
|
|
bff7ccc479 | ||
|
|
401ca04896 | ||
|
|
4d0d99daaf | ||
|
|
2378587684 | ||
|
|
7cd2738ea0 | ||
|
|
07c8dfa996 | ||
|
|
f34a8231b0 | ||
|
|
7c03228816 | ||
|
|
7f63201f67 | ||
|
|
bed0da6d31 | ||
|
|
f21771266e | ||
|
|
9310166307 | ||
|
|
dd3d25762b | ||
|
|
d9d9d435b1 | ||
|
|
40809d4f2f | ||
|
|
449a62ba7a | ||
|
|
a351fba483 | ||
|
|
4570e21965 | ||
|
|
28da94c362 | ||
|
|
85043f1faf | ||
|
|
d17ba3a835 | ||
|
|
3b5617a0e2 | ||
|
|
dcc5e7eb4b | ||
|
|
4b7e38425f | ||
|
|
62cfccc035 | ||
|
|
88204878b0 | ||
|
|
d1f1703d4e | ||
|
|
8de8e6e5a1 | ||
|
|
3b3453c963 | ||
|
|
fa7d7deda6 | ||
|
|
9418701ccd | ||
|
|
cdb32ce992 | ||
|
|
1257c49d96 | ||
|
|
1c9bba353b | ||
|
|
777eb48e6e | ||
|
|
d767057c50 | ||
|
|
642c2404fb | ||
|
|
6a26a5619a | ||
|
|
af37568ec4 | ||
|
|
58253a210d | ||
|
|
01d2518bf1 | ||
|
|
d7df076672 | ||
|
|
791bd37adf | ||
|
|
de58296136 | ||
|
|
6cc0ec20a7 | ||
|
|
8269ede05b | ||
|
|
e4dea4be45 | ||
|
|
109a355116 | ||
|
|
c5349af29f | ||
|
|
b305c23c52 | ||
|
|
02580a1998 | ||
|
|
4d48a533b7 | ||
|
|
f7e8b1803a | ||
|
|
09761f21bc | ||
|
|
ec6f76520e | ||
|
|
9b5d0cec69 | ||
|
|
bb65fec348 | ||
|
|
602d80c699 | ||
|
|
98707a9d59 | ||
|
|
c3124a8cfc | ||
|
|
7ab1380407 | ||
|
|
c14dd77861 | ||
|
|
47c8e8096c | ||
|
|
148249eaca | ||
|
|
1db577ed16 | ||
|
|
7f69ef5356 | ||
|
|
91f3948efd | ||
|
|
7f310bc29f | ||
|
|
29ccbf16d8 | ||
|
|
aa44396a3e | ||
|
|
0d794cae36 | ||
|
|
22f86d0476 | ||
|
|
9799aa873a | ||
|
|
a3e300e672 | ||
|
|
5be9b318ea | ||
|
|
78f3123ddd | ||
|
|
ec9b35af5d | ||
|
|
1242dedee2 | ||
|
|
da1c07ff4c | ||
|
|
721a255a22 | ||
|
|
52838f93e3 | ||
|
|
bbdcd22a4a | ||
|
|
25bdb61c56 | ||
|
|
adb34464c9 | ||
|
|
0553f75f02 | ||
|
|
5fe4bf3f19 | ||
|
|
64838cb3fb | ||
|
|
4775a3a77d | ||
|
|
2941651189 | ||
|
|
7122353c54 | ||
|
|
d787dc140e | ||
|
|
a824e81b30 | ||
|
|
7196931527 | ||
|
|
024aac9c4a | ||
|
|
60d5f7035c |
8
.github/workflows/release.yml
vendored
8
.github/workflows/release.yml
vendored
@@ -44,11 +44,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
|
|
||||||
- name: macos-latest dependencies fix
|
|
||||||
if: matrix.os == 'macos-latest'
|
|
||||||
run: |
|
|
||||||
brew install python-setuptools
|
|
||||||
|
|
||||||
- name: Install corepack
|
- name: Install corepack
|
||||||
run: corepack enable && corepack prepare yarn@4.6.0 --activate
|
run: corepack enable && corepack prepare yarn@4.6.0 --activate
|
||||||
|
|
||||||
@@ -79,6 +74,7 @@ jobs:
|
|||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||||
NODE_OPTIONS: --max-old-space-size=8192
|
NODE_OPTIONS: --max-old-space-size=8192
|
||||||
|
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
|
||||||
|
|
||||||
- name: Build Mac
|
- name: Build Mac
|
||||||
if: matrix.os == 'macos-latest'
|
if: matrix.os == 'macos-latest'
|
||||||
@@ -95,6 +91,7 @@ jobs:
|
|||||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
NODE_OPTIONS: --max-old-space-size=8192
|
NODE_OPTIONS: --max-old-space-size=8192
|
||||||
|
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
|
||||||
|
|
||||||
- name: Build Windows
|
- name: Build Windows
|
||||||
if: matrix.os == 'windows-latest'
|
if: matrix.os == 'windows-latest'
|
||||||
@@ -105,6 +102,7 @@ jobs:
|
|||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||||
NODE_OPTIONS: --max-old-space-size=8192
|
NODE_OPTIONS: --max-old-space-size=8192
|
||||||
|
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
|
||||||
|
|
||||||
- name: Release
|
- name: Release
|
||||||
uses: ncipollo/release-action@v1
|
uses: ncipollo/release-action@v1
|
||||||
|
|||||||
@@ -11,6 +11,54 @@ index 852f6c4d16f86a7bb8a78bf1ed5a14647a279aa1..60e7f5f16a844541eb1909b215fcda18
|
|||||||
if (minimumSystemVersion != null) {
|
if (minimumSystemVersion != null) {
|
||||||
appPlist.LSMinimumSystemVersion = minimumSystemVersion;
|
appPlist.LSMinimumSystemVersion = minimumSystemVersion;
|
||||||
}
|
}
|
||||||
|
diff --git a/out/packager.js b/out/packager.js
|
||||||
|
index 579f7f4b62f61037234098ba378ab30efb64c7a9..02df1dd38f83a57a60258a51b2ee9f0b4020dab4 100644
|
||||||
|
--- a/out/packager.js
|
||||||
|
+++ b/out/packager.js
|
||||||
|
@@ -426,12 +426,12 @@ class Packager {
|
||||||
|
throw new Error(`Unknown platform: ${platform}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
- async installAppDependencies(platform, arch) {
|
||||||
|
+ async installAppDependencies(platform, arch, excludeReBuildModules) {
|
||||||
|
if (this.options.prepackaged != null || !this.framework.isNpmRebuildRequired) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const frameworkInfo = { version: this.framework.version, useCustomDist: true };
|
||||||
|
- const config = this.config;
|
||||||
|
+ const config= this.config;
|
||||||
|
if (config.nodeGypRebuild === true) {
|
||||||
|
await (0, yarn_1.nodeGypRebuild)(platform.nodeName, builder_util_1.Arch[arch], frameworkInfo);
|
||||||
|
}
|
||||||
|
@@ -462,6 +462,7 @@ class Packager {
|
||||||
|
platform: platform.nodeName,
|
||||||
|
arch: builder_util_1.Arch[arch],
|
||||||
|
productionDeps: this.getNodeDependencyInfo(null, false),
|
||||||
|
+ excludeReBuildModules,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
diff --git a/out/platformPackager.js b/out/platformPackager.js
|
||||||
|
index 05c740ead5e1e16dd77d5e523309c8b2c3bd581e..38f730d148ddda2e1bae64371658d7917e8ff05a 100644
|
||||||
|
--- a/out/platformPackager.js
|
||||||
|
+++ b/out/platformPackager.js
|
||||||
|
@@ -148,6 +148,7 @@ class PlatformPackager {
|
||||||
|
// Due to node-gyp rewriting GYP_MSVS_VERSION when reused across the same session, we must reset the env var: https://github.com/electron-userland/electron-builder/issues/7256
|
||||||
|
delete process.env.GYP_MSVS_VERSION;
|
||||||
|
const { outDir, appOutDir, platformName, arch, platformSpecificBuildOptions, targets, options } = packOptions;
|
||||||
|
+ builder_util_1.log.info(platformSpecificBuildOptions,"platformSpecificBuildOptions");
|
||||||
|
await this.info.emitBeforePack({
|
||||||
|
appOutDir,
|
||||||
|
outDir,
|
||||||
|
@@ -156,7 +157,7 @@ class PlatformPackager {
|
||||||
|
packager: this,
|
||||||
|
electronPlatformName: platformName,
|
||||||
|
});
|
||||||
|
- await this.info.installAppDependencies(this.platform, arch);
|
||||||
|
+ await this.info.installAppDependencies(this.platform, arch, platformSpecificBuildOptions.excludeReBuildModules);
|
||||||
|
if (this.info.cancellationToken.cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
diff --git a/out/publish/updateInfoBuilder.js b/out/publish/updateInfoBuilder.js
|
diff --git a/out/publish/updateInfoBuilder.js b/out/publish/updateInfoBuilder.js
|
||||||
index 7924c5b47d01f8dfccccb8f46658015fa66da1f7..1a1588923c3939ae1297b87931ba83f0ebc052d8 100644
|
index 7924c5b47d01f8dfccccb8f46658015fa66da1f7..1a1588923c3939ae1297b87931ba83f0ebc052d8 100644
|
||||||
--- a/out/publish/updateInfoBuilder.js
|
--- a/out/publish/updateInfoBuilder.js
|
||||||
@@ -66,14 +114,14 @@ index e8bd7bb46c8a54b3f55cf3a853ef924195271e01..f956e9f3fe9eb903c78aef3502553b01
|
|||||||
file: installerPath,
|
file: installerPath,
|
||||||
updateInfo,
|
updateInfo,
|
||||||
diff --git a/out/util/yarn.js b/out/util/yarn.js
|
diff --git a/out/util/yarn.js b/out/util/yarn.js
|
||||||
index 1ee20f8b252a8f28d0c7b103789cf0a9a427aec1..c2878ec54d57da50bf14225e0c70c9c88664eb8a 100644
|
index 1ee20f8b252a8f28d0c7b103789cf0a9a427aec1..74177e6f230d9b7176bdaddd3007922cc99ccdb9 100644
|
||||||
--- a/out/util/yarn.js
|
--- a/out/util/yarn.js
|
||||||
+++ b/out/util/yarn.js
|
+++ b/out/util/yarn.js
|
||||||
@@ -140,6 +140,7 @@ async function rebuild(config, { appDir, projectDir }, options) {
|
@@ -140,6 +140,7 @@ async function rebuild(config, { appDir, projectDir }, options) {
|
||||||
arch,
|
arch,
|
||||||
platform,
|
platform,
|
||||||
buildFromSource,
|
buildFromSource,
|
||||||
+ ignoreModules: config.excludeReBuildModules || undefined,
|
+ ignoreModules: options.excludeReBuildModules || undefined,
|
||||||
projectRootPath: projectDir,
|
projectRootPath: projectDir,
|
||||||
mode: config.nativeRebuilder || "sequential",
|
mode: config.nativeRebuilder || "sequential",
|
||||||
disablePreGypCopy: true,
|
disablePreGypCopy: true,
|
||||||
|
|||||||
222
docs/features/memory-guide-zh.md
Normal file
222
docs/features/memory-guide-zh.md
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
# Cherry Studio 记忆功能指南
|
||||||
|
|
||||||
|
## 功能介绍
|
||||||
|
|
||||||
|
Cherry Studio 的记忆功能是一个强大的工具,能够帮助 AI 助手记住对话中的重要信息、用户偏好和上下文。通过记忆功能,您的 AI 助手可以:
|
||||||
|
|
||||||
|
- 📝 **记住重要信息**:自动从对话中提取并存储关键事实和信息
|
||||||
|
- 🧠 **个性化响应**:基于存储的记忆提供更加个性化和相关的回答
|
||||||
|
- 🔍 **智能检索**:在需要时自动搜索相关记忆,增强对话的连贯性
|
||||||
|
- 👥 **多用户支持**:为不同用户维护独立的记忆上下文
|
||||||
|
|
||||||
|
记忆功能特别适用于需要长期保持上下文的场景,例如个人助手、客户服务、教育辅导等。
|
||||||
|
|
||||||
|
## 如何启用记忆功能
|
||||||
|
|
||||||
|
### 1. 全局配置(首次设置)
|
||||||
|
|
||||||
|
在使用记忆功能之前,您需要先进行全局配置:
|
||||||
|
|
||||||
|
1. 点击侧边栏的 **记忆** 图标(记忆棒图标)进入记忆管理页面
|
||||||
|
2. 点击右上角的 **更多** 按钮(三个点),选择 **设置**
|
||||||
|
3. 在设置弹窗中配置以下必要项:
|
||||||
|
- **LLM 模型**:选择用于处理记忆的语言模型(推荐使用 GPT-4 或 Claude 等高级模型)
|
||||||
|
- **嵌入模型**:选择用于生成向量嵌入的模型(如 text-embedding-3-small)
|
||||||
|
- **嵌入维度**:输入嵌入模型的维度(通常为 1536)
|
||||||
|
4. 点击 **确定** 保存配置
|
||||||
|
|
||||||
|
> ⚠️ **注意**:嵌入模型和维度一旦设置后无法更改,请谨慎选择。
|
||||||
|
|
||||||
|
### 2. 为助手启用记忆
|
||||||
|
|
||||||
|
完成全局配置后,您可以为特定助手启用记忆功能:
|
||||||
|
|
||||||
|
1. 进入 **助手** 页面
|
||||||
|
2. 选择要启用记忆的助手,点击 **编辑**
|
||||||
|
3. 在助手设置中找到 **记忆** 部分
|
||||||
|
4. 打开记忆功能开关
|
||||||
|
5. 保存助手设置
|
||||||
|
|
||||||
|
启用后,该助手将在对话过程中自动提取和使用记忆。
|
||||||
|
|
||||||
|
## 使用方法
|
||||||
|
|
||||||
|
### 查看记忆
|
||||||
|
|
||||||
|
1. 点击侧边栏的 **记忆** 图标进入记忆管理页面
|
||||||
|
2. 您可以看到所有存储的记忆卡片,包括:
|
||||||
|
- 记忆内容
|
||||||
|
- 创建时间
|
||||||
|
- 所属用户
|
||||||
|
|
||||||
|
### 添加记忆
|
||||||
|
|
||||||
|
手动添加记忆有两种方式:
|
||||||
|
|
||||||
|
**方式一:在记忆管理页面添加**
|
||||||
|
|
||||||
|
1. 点击右上角的 **添加记忆** 按钮
|
||||||
|
2. 在弹窗中输入记忆内容
|
||||||
|
3. 点击 **添加** 保存
|
||||||
|
|
||||||
|
**方式二:在对话中自动提取**
|
||||||
|
|
||||||
|
- 当助手启用记忆功能后,系统会自动从对话中提取重要信息并存储为记忆
|
||||||
|
|
||||||
|
### 编辑记忆
|
||||||
|
|
||||||
|
1. 在记忆卡片上点击 **更多** 按钮(三个点)
|
||||||
|
2. 选择 **编辑**
|
||||||
|
3. 修改记忆内容
|
||||||
|
4. 点击 **保存**
|
||||||
|
|
||||||
|
### 删除记忆
|
||||||
|
|
||||||
|
1. 在记忆卡片上点击 **更多** 按钮
|
||||||
|
2. 选择 **删除**
|
||||||
|
3. 确认删除操作
|
||||||
|
|
||||||
|
## 记忆搜索
|
||||||
|
|
||||||
|
记忆管理页面提供了强大的搜索功能:
|
||||||
|
|
||||||
|
1. 在页面顶部的搜索框中输入关键词
|
||||||
|
2. 系统会实时过滤显示匹配的记忆
|
||||||
|
3. 搜索支持模糊匹配,可以搜索记忆内容的任何部分
|
||||||
|
|
||||||
|
## 用户管理
|
||||||
|
|
||||||
|
记忆功能支持多用户,您可以为不同的用户维护独立的记忆库:
|
||||||
|
|
||||||
|
### 切换用户
|
||||||
|
|
||||||
|
1. 在记忆管理页面,点击右上角的用户选择器
|
||||||
|
2. 选择要切换到的用户
|
||||||
|
3. 页面会自动加载该用户的记忆
|
||||||
|
|
||||||
|
### 添加新用户
|
||||||
|
|
||||||
|
1. 点击用户选择器
|
||||||
|
2. 选择 **添加新用户**
|
||||||
|
3. 输入用户 ID(支持字母、数字、下划线和连字符)
|
||||||
|
4. 点击 **添加**
|
||||||
|
|
||||||
|
### 删除用户
|
||||||
|
|
||||||
|
1. 切换到要删除的用户
|
||||||
|
2. 点击右上角的 **更多** 按钮
|
||||||
|
3. 选择 **删除用户**
|
||||||
|
4. 确认删除(注意:这将删除该用户的所有记忆)
|
||||||
|
|
||||||
|
> 💡 **提示**:默认用户(default-user)无法删除。
|
||||||
|
|
||||||
|
## 设置说明
|
||||||
|
|
||||||
|
### LLM 模型
|
||||||
|
|
||||||
|
- 用于处理记忆提取和更新的语言模型
|
||||||
|
- 建议选择能力较强的模型以获得更好的记忆提取效果
|
||||||
|
- 可随时更改
|
||||||
|
|
||||||
|
### 嵌入模型
|
||||||
|
|
||||||
|
- 用于将文本转换为向量,支持语义搜索
|
||||||
|
- 一旦设置后无法更改(为了保证现有记忆的兼容性)
|
||||||
|
- 推荐使用 OpenAI 的 text-embedding 系列模型
|
||||||
|
|
||||||
|
### 嵌入维度
|
||||||
|
|
||||||
|
- 嵌入向量的维度,需要与选择的嵌入模型匹配
|
||||||
|
- 常见维度:
|
||||||
|
- text-embedding-3-small: 1536
|
||||||
|
- text-embedding-3-large: 3072
|
||||||
|
- text-embedding-ada-002: 1536
|
||||||
|
|
||||||
|
### 自定义提示词(可选)
|
||||||
|
|
||||||
|
- **事实提取提示词**:自定义如何从对话中提取信息
|
||||||
|
- **记忆更新提示词**:自定义如何更新现有记忆
|
||||||
|
|
||||||
|
## 最佳实践
|
||||||
|
|
||||||
|
### 1. 合理组织记忆
|
||||||
|
|
||||||
|
- 保持记忆简洁明了,每条记忆专注于一个具体信息
|
||||||
|
- 使用清晰的语言描述事实,避免模糊表达
|
||||||
|
- 定期审查和清理过时或不准确的记忆
|
||||||
|
|
||||||
|
### 2. 多用户场景
|
||||||
|
|
||||||
|
- 为不同的使用场景创建独立用户(如工作、个人、学习等)
|
||||||
|
- 使用有意义的用户 ID,便于识别和管理
|
||||||
|
- 定期备份重要用户的记忆数据
|
||||||
|
|
||||||
|
### 3. 模型选择建议
|
||||||
|
|
||||||
|
- **LLM 模型**:GPT-4、Claude 3 等高级模型能更准确地提取和理解信息
|
||||||
|
- **嵌入模型**:选择与您的主要使用语言匹配的模型
|
||||||
|
|
||||||
|
### 4. 性能优化
|
||||||
|
|
||||||
|
- 避免存储过多冗余记忆,这可能影响搜索性能
|
||||||
|
- 定期整理和合并相似的记忆
|
||||||
|
- 对于大量记忆的场景,考虑按主题或时间进行分类管理
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### Q: 为什么我无法启用记忆功能?
|
||||||
|
|
||||||
|
A: 请确保您已经完成全局配置,包括选择 LLM 模型和嵌入模型。
|
||||||
|
|
||||||
|
### Q: 记忆会自动同步到所有助手吗?
|
||||||
|
|
||||||
|
A: 不会。每个助手的记忆功能需要单独启用,且记忆是按用户隔离的。
|
||||||
|
|
||||||
|
### Q: 如何导出我的记忆数据?
|
||||||
|
|
||||||
|
A: 目前系统暂不支持直接导出功能,但所有记忆都存储在本地数据库中。
|
||||||
|
|
||||||
|
### Q: 删除的记忆可以恢复吗?
|
||||||
|
|
||||||
|
A: 删除操作是永久的,无法恢复。建议在删除前仔细确认。
|
||||||
|
|
||||||
|
### Q: 记忆功能会影响对话速度吗?
|
||||||
|
|
||||||
|
A: 记忆功能在后台异步处理,不会明显影响对话响应速度。但过多的记忆可能会略微增加搜索时间。
|
||||||
|
|
||||||
|
### Q: 如何清空所有记忆?
|
||||||
|
|
||||||
|
A: 您可以删除当前用户并重新创建,或者手动删除所有记忆条目。
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
### 隐私保护
|
||||||
|
|
||||||
|
- 所有记忆数据都存储在您的本地设备上,不会上传到云端
|
||||||
|
- 请勿在记忆中存储敏感信息(如密码、私钥等)
|
||||||
|
- 定期审查记忆内容,确保没有意外存储的隐私信息
|
||||||
|
|
||||||
|
### 数据安全
|
||||||
|
|
||||||
|
- 记忆数据存储在本地数据库中
|
||||||
|
- 建议定期备份重要数据
|
||||||
|
- 更换设备时请注意迁移记忆数据
|
||||||
|
|
||||||
|
### 使用限制
|
||||||
|
|
||||||
|
- 单条记忆的长度建议不超过 500 字
|
||||||
|
- 每个用户的记忆数量建议控制在 1000 条以内
|
||||||
|
- 过多的记忆可能影响系统性能
|
||||||
|
|
||||||
|
## 技术细节
|
||||||
|
|
||||||
|
记忆功能使用了先进的 RAG(检索增强生成)技术:
|
||||||
|
|
||||||
|
1. **信息提取**:使用 LLM 从对话中智能提取关键信息
|
||||||
|
2. **向量化存储**:通过嵌入模型将文本转换为向量,支持语义搜索
|
||||||
|
3. **智能检索**:在对话时自动搜索相关记忆,提供给 AI 作为上下文
|
||||||
|
4. **持续学习**:随着对话进行,不断更新和完善记忆库
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
💡 **提示**:记忆功能是 Cherry Studio 的高级特性,合理使用可以大大提升 AI 助手的智能程度和用户体验。如有更多问题,欢迎查阅文档或联系支持团队。
|
||||||
@@ -49,6 +49,8 @@ files:
|
|||||||
- '!node_modules/mammoth/{mammoth.browser.js,mammoth.browser.min.js}'
|
- '!node_modules/mammoth/{mammoth.browser.js,mammoth.browser.min.js}'
|
||||||
- '!node_modules/selection-hook/prebuilds/**/*' # we rebuild .node, don't use prebuilds
|
- '!node_modules/selection-hook/prebuilds/**/*' # we rebuild .node, don't use prebuilds
|
||||||
- '!**/*.{h,iobj,ipdb,tlog,recipe,vcxproj,vcxproj.filters}' # filter .node build files
|
- '!**/*.{h,iobj,ipdb,tlog,recipe,vcxproj,vcxproj.filters}' # filter .node build files
|
||||||
|
- '!node_modules/pdfjs-dist/web/**/*'
|
||||||
|
- '!node_modules/pdfjs-dist/legacy/web/*'
|
||||||
asarUnpack:
|
asarUnpack:
|
||||||
- resources/**
|
- resources/**
|
||||||
- '**/*.{metal,exp,lib}'
|
- '**/*.{metal,exp,lib}'
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
external: ['@libsql/client', 'bufferutil', 'utf-8-validate'],
|
external: ['@libsql/client', 'bufferutil', 'utf-8-validate', '@cherrystudio/mac-system-ocr'],
|
||||||
output: {
|
output: {
|
||||||
// 彻底禁用代码分割 - 返回 null 强制单文件打包
|
// 彻底禁用代码分割 - 返回 null 强制单文件打包
|
||||||
manualChunks: undefined,
|
manualChunks: undefined,
|
||||||
@@ -30,7 +30,7 @@ export default defineConfig({
|
|||||||
sourcemap: process.env.NODE_ENV === 'development'
|
sourcemap: process.env.NODE_ENV === 'development'
|
||||||
},
|
},
|
||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
noDiscovery: process.env.NODE_ENV === 'development'
|
disabled: 'dev'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
preload: {
|
preload: {
|
||||||
|
|||||||
12
package.json
12
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "CherryStudio",
|
"name": "CherryStudio",
|
||||||
"version": "1.4.7",
|
"version": "1.5.0-rc.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "A powerful AI assistant for producer.",
|
"description": "A powerful AI assistant for producer.",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "electron-vite preview",
|
"start": "electron-vite preview",
|
||||||
"dev": "electron-vite dev",
|
"dev": "electron-vite dev",
|
||||||
"debug": "electron-vite -- --inspect --sourcemap --remote-debugging-port=9222",
|
"debug": "electron-vite dev -- --inspect --sourcemap --remote-debugging-port=9222",
|
||||||
"build": "npm run typecheck && electron-vite build",
|
"build": "npm run typecheck && electron-vite build",
|
||||||
"build:check": "yarn typecheck && yarn check:i18n && yarn test",
|
"build:check": "yarn typecheck && yarn check:i18n && yarn test",
|
||||||
"build:unpack": "dotenv npm run build && electron-builder --dir",
|
"build:unpack": "dotenv npm run build && electron-builder --dir",
|
||||||
@@ -58,6 +58,7 @@
|
|||||||
"prepare": "husky"
|
"prepare": "husky"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@cherrystudio/pdf-to-img-napi": "^0.0.1",
|
||||||
"@libsql/client": "0.14.0",
|
"@libsql/client": "0.14.0",
|
||||||
"@libsql/win32-x64-msvc": "^0.4.7",
|
"@libsql/win32-x64-msvc": "^0.4.7",
|
||||||
"@strongtz/win32-arm64-msvc": "^0.4.7",
|
"@strongtz/win32-arm64-msvc": "^0.4.7",
|
||||||
@@ -65,7 +66,8 @@
|
|||||||
"node-stream-zip": "^1.15.0",
|
"node-stream-zip": "^1.15.0",
|
||||||
"notion-helper": "^1.3.22",
|
"notion-helper": "^1.3.22",
|
||||||
"os-proxy-config": "^1.1.2",
|
"os-proxy-config": "^1.1.2",
|
||||||
"selection-hook": "^0.9.23",
|
"pdfjs-dist": "4.10.38",
|
||||||
|
"selection-hook": "^1.0.1",
|
||||||
"turndown": "7.2.0"
|
"turndown": "7.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -100,6 +102,7 @@
|
|||||||
"@kangfenmao/keyv-storage": "^0.1.0",
|
"@kangfenmao/keyv-storage": "^0.1.0",
|
||||||
"@langchain/community": "^0.3.36",
|
"@langchain/community": "^0.3.36",
|
||||||
"@langchain/ollama": "^0.2.1",
|
"@langchain/ollama": "^0.2.1",
|
||||||
|
"@mistralai/mistralai": "^1.6.0",
|
||||||
"@modelcontextprotocol/sdk": "^1.11.4",
|
"@modelcontextprotocol/sdk": "^1.11.4",
|
||||||
"@mozilla/readability": "^0.6.0",
|
"@mozilla/readability": "^0.6.0",
|
||||||
"@notionhq/client": "^2.2.15",
|
"@notionhq/client": "^2.2.15",
|
||||||
@@ -222,6 +225,9 @@
|
|||||||
"word-extractor": "^1.0.4",
|
"word-extractor": "^1.0.4",
|
||||||
"zipread": "^1.3.3"
|
"zipread": "^1.3.3"
|
||||||
},
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@cherrystudio/mac-system-ocr": "^0.2.2"
|
||||||
|
},
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
"pdf-parse@npm:1.1.1": "patch:pdf-parse@npm%3A1.1.1#~/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch",
|
"pdf-parse@npm:1.1.1": "patch:pdf-parse@npm%3A1.1.1#~/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch",
|
||||||
"@langchain/openai@npm:^0.3.16": "patch:@langchain/openai@npm%3A0.3.16#~/.yarn/patches/@langchain-openai-npm-0.3.16-e525b59526.patch",
|
"@langchain/openai@npm:^0.3.16": "patch:@langchain/openai@npm%3A0.3.16#~/.yarn/patches/@langchain-openai-npm-0.3.16-e525b59526.patch",
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ export enum IpcChannel {
|
|||||||
App_InstallUvBinary = 'app:install-uv-binary',
|
App_InstallUvBinary = 'app:install-uv-binary',
|
||||||
App_InstallBunBinary = 'app:install-bun-binary',
|
App_InstallBunBinary = 'app:install-bun-binary',
|
||||||
|
|
||||||
|
App_MacIsProcessTrusted = 'app:mac-is-process-trusted',
|
||||||
|
App_MacRequestProcessTrust = 'app:mac-request-process-trust',
|
||||||
|
|
||||||
App_QuoteToMain = 'app:quote-to-main',
|
App_QuoteToMain = 'app:quote-to-main',
|
||||||
|
|
||||||
Notification_Send = 'notification:send',
|
Notification_Send = 'notification:send',
|
||||||
@@ -115,6 +118,7 @@ export enum IpcChannel {
|
|||||||
KnowledgeBase_Remove = 'knowledge-base:remove',
|
KnowledgeBase_Remove = 'knowledge-base:remove',
|
||||||
KnowledgeBase_Search = 'knowledge-base:search',
|
KnowledgeBase_Search = 'knowledge-base:search',
|
||||||
KnowledgeBase_Rerank = 'knowledge-base:rerank',
|
KnowledgeBase_Rerank = 'knowledge-base:rerank',
|
||||||
|
KnowledgeBase_Check_Quota = 'knowledge-base:check-quota',
|
||||||
|
|
||||||
//file
|
//file
|
||||||
File_Open = 'file:open',
|
File_Open = 'file:open',
|
||||||
@@ -125,9 +129,10 @@ export enum IpcChannel {
|
|||||||
File_Clear = 'file:clear',
|
File_Clear = 'file:clear',
|
||||||
File_Read = 'file:read',
|
File_Read = 'file:read',
|
||||||
File_Delete = 'file:delete',
|
File_Delete = 'file:delete',
|
||||||
|
File_DeleteDir = 'file:deleteDir',
|
||||||
File_Get = 'file:get',
|
File_Get = 'file:get',
|
||||||
File_SelectFolder = 'file:selectFolder',
|
File_SelectFolder = 'file:selectFolder',
|
||||||
File_Create = 'file:create',
|
File_CreateTempFile = 'file:createTempFile',
|
||||||
File_Write = 'file:write',
|
File_Write = 'file:write',
|
||||||
File_WriteWithId = 'file:writeWithId',
|
File_WriteWithId = 'file:writeWithId',
|
||||||
File_SaveImage = 'file:saveImage',
|
File_SaveImage = 'file:saveImage',
|
||||||
@@ -140,6 +145,12 @@ export enum IpcChannel {
|
|||||||
File_GetPdfInfo = 'file:getPdfInfo',
|
File_GetPdfInfo = 'file:getPdfInfo',
|
||||||
Fs_Read = 'fs:read',
|
Fs_Read = 'fs:read',
|
||||||
|
|
||||||
|
// file service
|
||||||
|
FileService_Upload = 'file-service:upload',
|
||||||
|
FileService_List = 'file-service:list',
|
||||||
|
FileService_Delete = 'file-service:delete',
|
||||||
|
FileService_Retrieve = 'file-service:retrieve',
|
||||||
|
|
||||||
Export_Word = 'export:word',
|
Export_Word = 'export:word',
|
||||||
|
|
||||||
Shortcuts_Update = 'shortcuts:update',
|
Shortcuts_Update = 'shortcuts:update',
|
||||||
@@ -217,5 +228,16 @@ export enum IpcChannel {
|
|||||||
Selection_ActionWindowMinimize = 'selection:action-window-minimize',
|
Selection_ActionWindowMinimize = 'selection:action-window-minimize',
|
||||||
Selection_ActionWindowPin = 'selection:action-window-pin',
|
Selection_ActionWindowPin = 'selection:action-window-pin',
|
||||||
Selection_ProcessAction = 'selection:process-action',
|
Selection_ProcessAction = 'selection:process-action',
|
||||||
Selection_UpdateActionData = 'selection:update-action-data'
|
Selection_UpdateActionData = 'selection:update-action-data',
|
||||||
|
|
||||||
|
// Memory
|
||||||
|
Memory_Add = 'memory:add',
|
||||||
|
Memory_Search = 'memory:search',
|
||||||
|
Memory_List = 'memory:list',
|
||||||
|
Memory_Delete = 'memory:delete',
|
||||||
|
Memory_Update = 'memory:update',
|
||||||
|
Memory_Get = 'memory:get',
|
||||||
|
Memory_SetConfig = 'memory:set-config',
|
||||||
|
Memory_DeleteUser = 'memory:delete-user',
|
||||||
|
Memory_GetUsersList = 'memory:get-users-list'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
|
import { ProcessingStatus } from '@types'
|
||||||
|
|
||||||
export type LoaderReturn = {
|
export type LoaderReturn = {
|
||||||
entriesAdded: number
|
entriesAdded: number
|
||||||
uniqueId: string
|
uniqueId: string
|
||||||
uniqueIds: string[]
|
uniqueIds: string[]
|
||||||
loaderType: string
|
loaderType: string
|
||||||
|
status?: ProcessingStatus
|
||||||
|
message?: string
|
||||||
|
messageSource?: 'preprocess' | 'embedding'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ exports.default = async function (context) {
|
|||||||
const node_modules_path = path.join(context.appOutDir, 'resources', 'app.asar.unpacked', 'node_modules')
|
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']
|
const _arch = arch === Arch.arm64 ? ['linux-arm64-gnu', 'linux-arm64-musl'] : ['linux-x64-gnu', 'linux-x64-musl']
|
||||||
keepPackageNodeFiles(node_modules_path, '@libsql', _arch)
|
keepPackageNodeFiles(node_modules_path, '@libsql', _arch)
|
||||||
|
|
||||||
|
// 删除 macOS 专用的 OCR 包
|
||||||
|
removeMacOnlyPackages(node_modules_path)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (platform === 'windows') {
|
if (platform === 'windows') {
|
||||||
@@ -35,6 +38,8 @@ exports.default = async function (context) {
|
|||||||
keepPackageNodeFiles(node_modules_path, '@strongtz', ['win32-x64-msvc'])
|
keepPackageNodeFiles(node_modules_path, '@strongtz', ['win32-x64-msvc'])
|
||||||
keepPackageNodeFiles(node_modules_path, '@libsql', ['win32-x64-msvc'])
|
keepPackageNodeFiles(node_modules_path, '@libsql', ['win32-x64-msvc'])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
removeMacOnlyPackages(node_modules_path)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (platform === 'windows') {
|
if (platform === 'windows') {
|
||||||
@@ -43,6 +48,22 @@ exports.default = async function (context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除 macOS 专用的包
|
||||||
|
* @param {string} nodeModulesPath
|
||||||
|
*/
|
||||||
|
function removeMacOnlyPackages(nodeModulesPath) {
|
||||||
|
const macOnlyPackages = ['@cherrystudio/mac-system-ocr']
|
||||||
|
|
||||||
|
macOnlyPackages.forEach((packageName) => {
|
||||||
|
const packagePath = path.join(nodeModulesPath, packageName)
|
||||||
|
if (fs.existsSync(packagePath)) {
|
||||||
|
fs.rmSync(packagePath, { recursive: true, force: true })
|
||||||
|
console.log(`[After Pack] Removed macOS-only package: ${packageName}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 使用指定架构的 node_modules 文件
|
* 使用指定架构的 node_modules 文件
|
||||||
* @param {*} nodeModulesPath
|
* @param {*} nodeModulesPath
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
interface IFilterList {
|
interface IFilterList {
|
||||||
WINDOWS: string[]
|
WINDOWS: string[]
|
||||||
MAC?: string[]
|
MAC: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IFinetunedList {
|
interface IFinetunedList {
|
||||||
@@ -45,14 +45,17 @@ export const SELECTION_PREDEFINED_BLACKLIST: IFilterList = {
|
|||||||
'sldworks.exe',
|
'sldworks.exe',
|
||||||
// Remote Desktop
|
// Remote Desktop
|
||||||
'mstsc.exe'
|
'mstsc.exe'
|
||||||
]
|
],
|
||||||
|
MAC: []
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SELECTION_FINETUNED_LIST: IFinetunedList = {
|
export const SELECTION_FINETUNED_LIST: IFinetunedList = {
|
||||||
EXCLUDE_CLIPBOARD_CURSOR_DETECT: {
|
EXCLUDE_CLIPBOARD_CURSOR_DETECT: {
|
||||||
WINDOWS: ['acrobat.exe', 'wps.exe', 'cajviewer.exe']
|
WINDOWS: ['acrobat.exe', 'wps.exe', 'cajviewer.exe'],
|
||||||
|
MAC: []
|
||||||
},
|
},
|
||||||
INCLUDE_CLIPBOARD_DELAY_READ: {
|
INCLUDE_CLIPBOARD_DELAY_READ: {
|
||||||
WINDOWS: ['acrobat.exe', 'wps.exe', 'cajviewer.exe', 'foxitphantom.exe']
|
WINDOWS: ['acrobat.exe', 'wps.exe', 'cajviewer.exe', 'foxitphantom.exe'],
|
||||||
|
MAC: []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import { getBinaryPath, isBinaryExists, runInstallScript } from '@main/utils/pro
|
|||||||
import { handleZoomFactor } from '@main/utils/zoom'
|
import { handleZoomFactor } from '@main/utils/zoom'
|
||||||
import { UpgradeChannel } from '@shared/config/constant'
|
import { UpgradeChannel } from '@shared/config/constant'
|
||||||
import { IpcChannel } from '@shared/IpcChannel'
|
import { IpcChannel } from '@shared/IpcChannel'
|
||||||
import { Shortcut, ThemeMode } from '@types'
|
import { FileMetadata, Provider, Shortcut, ThemeMode } from '@types'
|
||||||
import { BrowserWindow, dialog, ipcMain, session, shell, webContents } from 'electron'
|
import { BrowserWindow, dialog, ipcMain, session, shell, systemPreferences, webContents } from 'electron'
|
||||||
import log from 'electron-log'
|
import log from 'electron-log'
|
||||||
import { Notification } from 'src/renderer/src/types/notification'
|
import { Notification } from 'src/renderer/src/types/notification'
|
||||||
|
|
||||||
@@ -17,15 +17,17 @@ import BackupManager from './services/BackupManager'
|
|||||||
import { configManager } from './services/ConfigManager'
|
import { configManager } from './services/ConfigManager'
|
||||||
import CopilotService from './services/CopilotService'
|
import CopilotService from './services/CopilotService'
|
||||||
import { ExportService } from './services/ExportService'
|
import { ExportService } from './services/ExportService'
|
||||||
import FileService from './services/FileService'
|
|
||||||
import FileStorage from './services/FileStorage'
|
import FileStorage from './services/FileStorage'
|
||||||
|
import FileService from './services/FileSystemService'
|
||||||
import KnowledgeService from './services/KnowledgeService'
|
import KnowledgeService from './services/KnowledgeService'
|
||||||
import mcpService from './services/MCPService'
|
import mcpService from './services/MCPService'
|
||||||
|
import MemoryService from './services/memory/MemoryService'
|
||||||
import NotificationService from './services/NotificationService'
|
import NotificationService from './services/NotificationService'
|
||||||
import * as NutstoreService from './services/NutstoreService'
|
import * as NutstoreService from './services/NutstoreService'
|
||||||
import ObsidianVaultService from './services/ObsidianVaultService'
|
import ObsidianVaultService from './services/ObsidianVaultService'
|
||||||
import { ProxyConfig, proxyManager } from './services/ProxyManager'
|
import { ProxyConfig, proxyManager } from './services/ProxyManager'
|
||||||
import { pythonService } from './services/PythonService'
|
import { pythonService } from './services/PythonService'
|
||||||
|
import { FileServiceManager } from './services/remotefile/FileServiceManager'
|
||||||
import { searchService } from './services/SearchService'
|
import { searchService } from './services/SearchService'
|
||||||
import { SelectionService } from './services/SelectionService'
|
import { SelectionService } from './services/SelectionService'
|
||||||
import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService'
|
import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService'
|
||||||
@@ -44,6 +46,7 @@ const backupManager = new BackupManager()
|
|||||||
const exportService = new ExportService(fileManager)
|
const exportService = new ExportService(fileManager)
|
||||||
const obsidianVaultService = new ObsidianVaultService()
|
const obsidianVaultService = new ObsidianVaultService()
|
||||||
const vertexAIService = VertexAIService.getInstance()
|
const vertexAIService = VertexAIService.getInstance()
|
||||||
|
const memoryService = MemoryService.getInstance()
|
||||||
|
|
||||||
export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||||
const appUpdater = new AppUpdater(mainWindow)
|
const appUpdater = new AppUpdater(mainWindow)
|
||||||
@@ -158,6 +161,18 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
//only for mac
|
||||||
|
if (isMac) {
|
||||||
|
ipcMain.handle(IpcChannel.App_MacIsProcessTrusted, (): boolean => {
|
||||||
|
return systemPreferences.isTrustedAccessibilityClient(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
//return is only the current state, not the new state
|
||||||
|
ipcMain.handle(IpcChannel.App_MacRequestProcessTrust, (): boolean => {
|
||||||
|
return systemPreferences.isTrustedAccessibilityClient(true)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
ipcMain.handle(IpcChannel.Config_Set, (_, key: string, value: any, isNotify: boolean = false) => {
|
ipcMain.handle(IpcChannel.Config_Set, (_, key: string, value: any, isNotify: boolean = false) => {
|
||||||
configManager.set(key, value, isNotify)
|
configManager.set(key, value, isNotify)
|
||||||
})
|
})
|
||||||
@@ -354,9 +369,10 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
|||||||
ipcMain.handle(IpcChannel.File_Clear, fileManager.clear)
|
ipcMain.handle(IpcChannel.File_Clear, fileManager.clear)
|
||||||
ipcMain.handle(IpcChannel.File_Read, fileManager.readFile)
|
ipcMain.handle(IpcChannel.File_Read, fileManager.readFile)
|
||||||
ipcMain.handle(IpcChannel.File_Delete, fileManager.deleteFile)
|
ipcMain.handle(IpcChannel.File_Delete, fileManager.deleteFile)
|
||||||
|
ipcMain.handle('file:deleteDir', fileManager.deleteDir)
|
||||||
ipcMain.handle(IpcChannel.File_Get, fileManager.getFile)
|
ipcMain.handle(IpcChannel.File_Get, fileManager.getFile)
|
||||||
ipcMain.handle(IpcChannel.File_SelectFolder, fileManager.selectFolder)
|
ipcMain.handle(IpcChannel.File_SelectFolder, fileManager.selectFolder)
|
||||||
ipcMain.handle(IpcChannel.File_Create, fileManager.createTempFile)
|
ipcMain.handle(IpcChannel.File_CreateTempFile, fileManager.createTempFile)
|
||||||
ipcMain.handle(IpcChannel.File_Write, fileManager.writeFile)
|
ipcMain.handle(IpcChannel.File_Write, fileManager.writeFile)
|
||||||
ipcMain.handle(IpcChannel.File_WriteWithId, fileManager.writeFileWithId)
|
ipcMain.handle(IpcChannel.File_WriteWithId, fileManager.writeFileWithId)
|
||||||
ipcMain.handle(IpcChannel.File_SaveImage, fileManager.saveImage)
|
ipcMain.handle(IpcChannel.File_SaveImage, fileManager.saveImage)
|
||||||
@@ -368,6 +384,27 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
|||||||
ipcMain.handle(IpcChannel.File_Copy, fileManager.copyFile)
|
ipcMain.handle(IpcChannel.File_Copy, fileManager.copyFile)
|
||||||
ipcMain.handle(IpcChannel.File_BinaryImage, fileManager.binaryImage)
|
ipcMain.handle(IpcChannel.File_BinaryImage, fileManager.binaryImage)
|
||||||
|
|
||||||
|
// file service
|
||||||
|
ipcMain.handle(IpcChannel.FileService_Upload, async (_, provider: Provider, file: FileMetadata) => {
|
||||||
|
const service = FileServiceManager.getInstance().getService(provider)
|
||||||
|
return await service.uploadFile(file)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IpcChannel.FileService_List, async (_, provider: Provider) => {
|
||||||
|
const service = FileServiceManager.getInstance().getService(provider)
|
||||||
|
return await service.listFiles()
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IpcChannel.FileService_Delete, async (_, provider: Provider, fileId: string) => {
|
||||||
|
const service = FileServiceManager.getInstance().getService(provider)
|
||||||
|
return await service.deleteFile(fileId)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IpcChannel.FileService_Retrieve, async (_, provider: Provider, fileId: string) => {
|
||||||
|
const service = FileServiceManager.getInstance().getService(provider)
|
||||||
|
return await service.retrieveFile(fileId)
|
||||||
|
})
|
||||||
|
|
||||||
// fs
|
// fs
|
||||||
ipcMain.handle(IpcChannel.Fs_Read, FileService.readFile)
|
ipcMain.handle(IpcChannel.Fs_Read, FileService.readFile)
|
||||||
|
|
||||||
@@ -397,6 +434,36 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
|||||||
ipcMain.handle(IpcChannel.KnowledgeBase_Remove, KnowledgeService.remove)
|
ipcMain.handle(IpcChannel.KnowledgeBase_Remove, KnowledgeService.remove)
|
||||||
ipcMain.handle(IpcChannel.KnowledgeBase_Search, KnowledgeService.search)
|
ipcMain.handle(IpcChannel.KnowledgeBase_Search, KnowledgeService.search)
|
||||||
ipcMain.handle(IpcChannel.KnowledgeBase_Rerank, KnowledgeService.rerank)
|
ipcMain.handle(IpcChannel.KnowledgeBase_Rerank, KnowledgeService.rerank)
|
||||||
|
ipcMain.handle(IpcChannel.KnowledgeBase_Check_Quota, KnowledgeService.checkQuota)
|
||||||
|
|
||||||
|
// memory
|
||||||
|
ipcMain.handle(IpcChannel.Memory_Add, async (_, messages, config) => {
|
||||||
|
return await memoryService.add(messages, config)
|
||||||
|
})
|
||||||
|
ipcMain.handle(IpcChannel.Memory_Search, async (_, query, config) => {
|
||||||
|
return await memoryService.search(query, config)
|
||||||
|
})
|
||||||
|
ipcMain.handle(IpcChannel.Memory_List, async (_, config) => {
|
||||||
|
return await memoryService.list(config)
|
||||||
|
})
|
||||||
|
ipcMain.handle(IpcChannel.Memory_Delete, async (_, id) => {
|
||||||
|
return await memoryService.delete(id)
|
||||||
|
})
|
||||||
|
ipcMain.handle(IpcChannel.Memory_Update, async (_, id, memory, metadata) => {
|
||||||
|
return await memoryService.update(id, memory, metadata)
|
||||||
|
})
|
||||||
|
ipcMain.handle(IpcChannel.Memory_Get, async (_, memoryId) => {
|
||||||
|
return await memoryService.get(memoryId)
|
||||||
|
})
|
||||||
|
ipcMain.handle(IpcChannel.Memory_SetConfig, async (_, config) => {
|
||||||
|
memoryService.setConfig(config)
|
||||||
|
})
|
||||||
|
ipcMain.handle(IpcChannel.Memory_DeleteUser, async (_, userId) => {
|
||||||
|
return await memoryService.deleteUser(userId)
|
||||||
|
})
|
||||||
|
ipcMain.handle(IpcChannel.Memory_GetUsersList, async () => {
|
||||||
|
return await memoryService.getUsersList()
|
||||||
|
})
|
||||||
|
|
||||||
// window
|
// window
|
||||||
ipcMain.handle(IpcChannel.Windows_SetMinimumSize, (_, width: number, height: number) => {
|
ipcMain.handle(IpcChannel.Windows_SetMinimumSize, (_, width: number, height: number) => {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { JsonLoader, LocalPathLoader, RAGApplication, TextLoader } from '@cherry
|
|||||||
import type { AddLoaderReturn } from '@cherrystudio/embedjs-interfaces'
|
import type { AddLoaderReturn } from '@cherrystudio/embedjs-interfaces'
|
||||||
import { WebLoader } from '@cherrystudio/embedjs-loader-web'
|
import { WebLoader } from '@cherrystudio/embedjs-loader-web'
|
||||||
import { LoaderReturn } from '@shared/config/types'
|
import { LoaderReturn } from '@shared/config/types'
|
||||||
import { FileType, KnowledgeBaseParams } from '@types'
|
import { FileMetadata, KnowledgeBaseParams } from '@types'
|
||||||
import Logger from 'electron-log'
|
import Logger from 'electron-log'
|
||||||
|
|
||||||
import { DraftsExportLoader } from './draftsExportLoader'
|
import { DraftsExportLoader } from './draftsExportLoader'
|
||||||
@@ -39,7 +39,7 @@ const FILE_LOADER_MAP: Record<string, string> = {
|
|||||||
|
|
||||||
export async function addOdLoader(
|
export async function addOdLoader(
|
||||||
ragApplication: RAGApplication,
|
ragApplication: RAGApplication,
|
||||||
file: FileType,
|
file: FileMetadata,
|
||||||
base: KnowledgeBaseParams,
|
base: KnowledgeBaseParams,
|
||||||
forceReload: boolean
|
forceReload: boolean
|
||||||
): Promise<AddLoaderReturn> {
|
): Promise<AddLoaderReturn> {
|
||||||
@@ -65,7 +65,7 @@ export async function addOdLoader(
|
|||||||
|
|
||||||
export async function addFileLoader(
|
export async function addFileLoader(
|
||||||
ragApplication: RAGApplication,
|
ragApplication: RAGApplication,
|
||||||
file: FileType,
|
file: FileMetadata,
|
||||||
base: KnowledgeBaseParams,
|
base: KnowledgeBaseParams,
|
||||||
forceReload: boolean
|
forceReload: boolean
|
||||||
): Promise<LoaderReturn> {
|
): Promise<LoaderReturn> {
|
||||||
|
|||||||
122
src/main/ocr/BaseOcrProvider.ts
Normal file
122
src/main/ocr/BaseOcrProvider.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
import { windowService } from '@main/services/WindowService'
|
||||||
|
import { getFileExt } from '@main/utils/file'
|
||||||
|
import { FileMetadata, OcrProvider } from '@types'
|
||||||
|
import { app } from 'electron'
|
||||||
|
import { TypedArray } from 'pdfjs-dist/types/src/display/api'
|
||||||
|
|
||||||
|
export default abstract class BaseOcrProvider {
|
||||||
|
protected provider: OcrProvider
|
||||||
|
public storageDir = path.join(app.getPath('userData'), 'Data', 'Files')
|
||||||
|
|
||||||
|
constructor(provider: OcrProvider) {
|
||||||
|
if (!provider) {
|
||||||
|
throw new Error('OCR provider is not set')
|
||||||
|
}
|
||||||
|
this.provider = provider
|
||||||
|
}
|
||||||
|
abstract parseFile(sourceId: string, file: FileMetadata): Promise<{ processedFile: FileMetadata; quota?: number }>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查文件是否已经被预处理过
|
||||||
|
* 统一检测方法:如果 Data/Files/{file.id} 是目录,说明已被预处理
|
||||||
|
* @param file 文件信息
|
||||||
|
* @returns 如果已处理返回处理后的文件信息,否则返回null
|
||||||
|
*/
|
||||||
|
public async checkIfAlreadyProcessed(file: FileMetadata): Promise<FileMetadata | null> {
|
||||||
|
try {
|
||||||
|
// 检查 Data/Files/{file.id} 是否是目录
|
||||||
|
const preprocessDirPath = path.join(this.storageDir, file.id)
|
||||||
|
|
||||||
|
if (fs.existsSync(preprocessDirPath)) {
|
||||||
|
const stats = await fs.promises.stat(preprocessDirPath)
|
||||||
|
|
||||||
|
// 如果是目录,说明已经被预处理过
|
||||||
|
if (stats.isDirectory()) {
|
||||||
|
// 查找目录中的处理结果文件
|
||||||
|
const files = await fs.promises.readdir(preprocessDirPath)
|
||||||
|
|
||||||
|
// 查找主要的处理结果文件(.md 或 .txt)
|
||||||
|
const processedFile = files.find((fileName) => fileName.endsWith('.md') || fileName.endsWith('.txt'))
|
||||||
|
|
||||||
|
if (processedFile) {
|
||||||
|
const processedFilePath = path.join(preprocessDirPath, processedFile)
|
||||||
|
const processedStats = await fs.promises.stat(processedFilePath)
|
||||||
|
const ext = getFileExt(processedFile)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...file,
|
||||||
|
name: file.name.replace(file.ext, ext),
|
||||||
|
path: processedFilePath,
|
||||||
|
ext: ext,
|
||||||
|
size: processedStats.size,
|
||||||
|
created_at: processedStats.birthtime.toISOString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
} catch (error) {
|
||||||
|
// 如果检查过程中出现错误,返回null表示未处理
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 辅助方法:延迟执行
|
||||||
|
*/
|
||||||
|
public delay = (ms: number): Promise<void> => {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
|
}
|
||||||
|
|
||||||
|
public async readPdf(
|
||||||
|
source: string | URL | TypedArray,
|
||||||
|
passwordCallback?: (fn: (password: string) => void, reason: string) => string
|
||||||
|
) {
|
||||||
|
const { getDocument } = await import('pdfjs-dist/legacy/build/pdf.mjs')
|
||||||
|
const documentLoadingTask = getDocument(source)
|
||||||
|
if (passwordCallback) {
|
||||||
|
documentLoadingTask.onPassword = passwordCallback
|
||||||
|
}
|
||||||
|
|
||||||
|
const document = await documentLoadingTask.promise
|
||||||
|
return document
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendOcrProgress(sourceId: string, progress: number): Promise<void> {
|
||||||
|
const mainWindow = windowService.getMainWindow()
|
||||||
|
mainWindow?.webContents.send('file-preprocess-progress', {
|
||||||
|
itemId: sourceId,
|
||||||
|
progress: progress
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将文件移动到附件目录
|
||||||
|
* @param fileId 文件id
|
||||||
|
* @param filePaths 需要移动的文件路径数组
|
||||||
|
* @returns 移动后的文件路径数组
|
||||||
|
*/
|
||||||
|
public moveToAttachmentsDir(fileId: string, filePaths: string[]): string[] {
|
||||||
|
const attachmentsPath = path.join(this.storageDir, fileId)
|
||||||
|
if (!fs.existsSync(attachmentsPath)) {
|
||||||
|
fs.mkdirSync(attachmentsPath, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const movedPaths: string[] = []
|
||||||
|
|
||||||
|
for (const filePath of filePaths) {
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
const fileName = path.basename(filePath)
|
||||||
|
const destPath = path.join(attachmentsPath, fileName)
|
||||||
|
fs.copyFileSync(filePath, destPath)
|
||||||
|
fs.unlinkSync(filePath) // 删除原文件,实现"移动"
|
||||||
|
movedPaths.push(destPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return movedPaths
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/main/ocr/DefaultOcrProvider.ts
Normal file
12
src/main/ocr/DefaultOcrProvider.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { FileMetadata, OcrProvider } from '@types'
|
||||||
|
|
||||||
|
import BaseOcrProvider from './BaseOcrProvider'
|
||||||
|
|
||||||
|
export default class DefaultOcrProvider extends BaseOcrProvider {
|
||||||
|
constructor(provider: OcrProvider) {
|
||||||
|
super(provider)
|
||||||
|
}
|
||||||
|
public parseFile(): Promise<{ processedFile: FileMetadata }> {
|
||||||
|
throw new Error('Method not implemented.')
|
||||||
|
}
|
||||||
|
}
|
||||||
128
src/main/ocr/MacSysOcrProvider.ts
Normal file
128
src/main/ocr/MacSysOcrProvider.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { isMac } from '@main/constant'
|
||||||
|
import { FileMetadata, OcrProvider } from '@types'
|
||||||
|
import Logger from 'electron-log'
|
||||||
|
import * as fs from 'fs'
|
||||||
|
import * as path from 'path'
|
||||||
|
import { TextItem } from 'pdfjs-dist/types/src/display/api'
|
||||||
|
|
||||||
|
import BaseOcrProvider from './BaseOcrProvider'
|
||||||
|
|
||||||
|
export default class MacSysOcrProvider extends BaseOcrProvider {
|
||||||
|
private readonly MIN_TEXT_LENGTH = 1000
|
||||||
|
private MacOCR: any
|
||||||
|
|
||||||
|
private async initMacOCR() {
|
||||||
|
if (!isMac) {
|
||||||
|
throw new Error('MacSysOcrProvider is only available on macOS')
|
||||||
|
}
|
||||||
|
if (!this.MacOCR) {
|
||||||
|
try {
|
||||||
|
// @ts-ignore This module is optional and only installed/available on macOS. Runtime checks prevent execution on other platforms.
|
||||||
|
const module = await import('@cherrystudio/mac-system-ocr')
|
||||||
|
this.MacOCR = module.default
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error('[OCR] Failed to load mac-system-ocr:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.MacOCR
|
||||||
|
}
|
||||||
|
|
||||||
|
private getRecognitionLevel(level?: number) {
|
||||||
|
return level === 0 ? this.MacOCR.RECOGNITION_LEVEL_FAST : this.MacOCR.RECOGNITION_LEVEL_ACCURATE
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(provider: OcrProvider) {
|
||||||
|
super(provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async processPages(
|
||||||
|
results: any,
|
||||||
|
totalPages: number,
|
||||||
|
sourceId: string,
|
||||||
|
writeStream: fs.WriteStream
|
||||||
|
): Promise<void> {
|
||||||
|
await this.initMacOCR()
|
||||||
|
// TODO: 下个版本后面使用批处理,以及p-queue来优化
|
||||||
|
for (let i = 0; i < totalPages; i++) {
|
||||||
|
// Convert pages to buffers
|
||||||
|
const pageNum = i + 1
|
||||||
|
const pageBuffer = await results.getPage(pageNum)
|
||||||
|
|
||||||
|
// Process batch
|
||||||
|
const ocrResult = await this.MacOCR.recognizeFromBuffer(pageBuffer, {
|
||||||
|
ocrOptions: {
|
||||||
|
recognitionLevel: this.getRecognitionLevel(this.provider.options?.recognitionLevel),
|
||||||
|
minConfidence: this.provider.options?.minConfidence || 0.5
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Write results in order
|
||||||
|
writeStream.write(ocrResult.text + '\n')
|
||||||
|
|
||||||
|
// Update progress
|
||||||
|
await this.sendOcrProgress(sourceId, (pageNum / totalPages) * 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async isScanPdf(buffer: Buffer): Promise<boolean> {
|
||||||
|
const doc = await this.readPdf(new Uint8Array(buffer))
|
||||||
|
const pageLength = doc.numPages
|
||||||
|
let counts = 0
|
||||||
|
const pagesToCheck = Math.min(pageLength, 10)
|
||||||
|
for (let i = 0; i < pagesToCheck; i++) {
|
||||||
|
const page = await doc.getPage(i + 1)
|
||||||
|
const pageData = await page.getTextContent()
|
||||||
|
const pageText = pageData.items.map((item) => (item as TextItem).str).join('')
|
||||||
|
counts += pageText.length
|
||||||
|
if (counts >= this.MIN_TEXT_LENGTH) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
public async parseFile(sourceId: string, file: FileMetadata): Promise<{ processedFile: FileMetadata }> {
|
||||||
|
Logger.info(`[OCR] Starting OCR process for file: ${file.name}`)
|
||||||
|
if (file.ext === '.pdf') {
|
||||||
|
try {
|
||||||
|
const { pdf } = await import('@cherrystudio/pdf-to-img-napi')
|
||||||
|
const pdfBuffer = await fs.promises.readFile(file.path)
|
||||||
|
const results = await pdf(pdfBuffer, {
|
||||||
|
scale: 2
|
||||||
|
})
|
||||||
|
const totalPages = results.length
|
||||||
|
|
||||||
|
const baseDir = path.dirname(file.path)
|
||||||
|
const baseName = path.basename(file.path, path.extname(file.path))
|
||||||
|
const txtFileName = `${baseName}.txt`
|
||||||
|
const txtFilePath = path.join(baseDir, txtFileName)
|
||||||
|
|
||||||
|
const writeStream = fs.createWriteStream(txtFilePath)
|
||||||
|
await this.processPages(results, totalPages, sourceId, writeStream)
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
writeStream.end(() => {
|
||||||
|
Logger.info(`[OCR] OCR process completed successfully for ${file.origin_name}`)
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
writeStream.on('error', reject)
|
||||||
|
})
|
||||||
|
const movedPaths = this.moveToAttachmentsDir(file.id, [txtFilePath])
|
||||||
|
return {
|
||||||
|
processedFile: {
|
||||||
|
...file,
|
||||||
|
name: txtFileName,
|
||||||
|
path: movedPaths[0],
|
||||||
|
ext: '.txt',
|
||||||
|
size: fs.statSync(movedPaths[0]).size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error('[OCR] Error during OCR process:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { processedFile: file }
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/main/ocr/OcrProvider.ts
Normal file
26
src/main/ocr/OcrProvider.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { FileMetadata, PreprocessProvider as Provider } from '@types'
|
||||||
|
|
||||||
|
import BaseOcrProvider from './BaseOcrProvider'
|
||||||
|
import OcrProviderFactory from './OcrProviderFactory'
|
||||||
|
|
||||||
|
export default class OcrProvider {
|
||||||
|
private sdk: BaseOcrProvider
|
||||||
|
constructor(provider: Provider) {
|
||||||
|
this.sdk = OcrProviderFactory.create(provider)
|
||||||
|
}
|
||||||
|
public async parseFile(
|
||||||
|
sourceId: string,
|
||||||
|
file: FileMetadata
|
||||||
|
): Promise<{ processedFile: FileMetadata; quota?: number }> {
|
||||||
|
return this.sdk.parseFile(sourceId, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查文件是否已经被预处理过
|
||||||
|
* @param file 文件信息
|
||||||
|
* @returns 如果已处理返回处理后的文件信息,否则返回null
|
||||||
|
*/
|
||||||
|
public async checkIfAlreadyProcessed(file: FileMetadata): Promise<FileMetadata | null> {
|
||||||
|
return this.sdk.checkIfAlreadyProcessed(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/main/ocr/OcrProviderFactory.ts
Normal file
20
src/main/ocr/OcrProviderFactory.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { isMac } from '@main/constant'
|
||||||
|
import { OcrProvider } from '@types'
|
||||||
|
import Logger from 'electron-log'
|
||||||
|
|
||||||
|
import BaseOcrProvider from './BaseOcrProvider'
|
||||||
|
import DefaultOcrProvider from './DefaultOcrProvider'
|
||||||
|
import MacSysOcrProvider from './MacSysOcrProvider'
|
||||||
|
export default class OcrProviderFactory {
|
||||||
|
static create(provider: OcrProvider): BaseOcrProvider {
|
||||||
|
switch (provider.id) {
|
||||||
|
case 'system':
|
||||||
|
if (!isMac) {
|
||||||
|
Logger.warn('[OCR] System OCR provider is only available on macOS')
|
||||||
|
}
|
||||||
|
return new MacSysOcrProvider(provider)
|
||||||
|
default:
|
||||||
|
return new DefaultOcrProvider(provider)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
126
src/main/preprocess/BasePreprocessProvider.ts
Normal file
126
src/main/preprocess/BasePreprocessProvider.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
import { windowService } from '@main/services/WindowService'
|
||||||
|
import { getFileExt } from '@main/utils/file'
|
||||||
|
import { FileMetadata, PreprocessProvider } from '@types'
|
||||||
|
import { app } from 'electron'
|
||||||
|
import { TypedArray } from 'pdfjs-dist/types/src/display/api'
|
||||||
|
|
||||||
|
export default abstract class BasePreprocessProvider {
|
||||||
|
protected provider: PreprocessProvider
|
||||||
|
protected userId?: string
|
||||||
|
public storageDir = path.join(app.getPath('userData'), 'Data', 'Files')
|
||||||
|
|
||||||
|
constructor(provider: PreprocessProvider, userId?: string) {
|
||||||
|
if (!provider) {
|
||||||
|
throw new Error('Preprocess provider is not set')
|
||||||
|
}
|
||||||
|
this.provider = provider
|
||||||
|
this.userId = userId
|
||||||
|
}
|
||||||
|
abstract parseFile(sourceId: string, file: FileMetadata): Promise<{ processedFile: FileMetadata; quota?: number }>
|
||||||
|
|
||||||
|
abstract checkQuota(): Promise<number>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查文件是否已经被预处理过
|
||||||
|
* 统一检测方法:如果 Data/Files/{file.id} 是目录,说明已被预处理
|
||||||
|
* @param file 文件信息
|
||||||
|
* @returns 如果已处理返回处理后的文件信息,否则返回null
|
||||||
|
*/
|
||||||
|
public async checkIfAlreadyProcessed(file: FileMetadata): Promise<FileMetadata | null> {
|
||||||
|
try {
|
||||||
|
// 检查 Data/Files/{file.id} 是否是目录
|
||||||
|
const preprocessDirPath = path.join(this.storageDir, file.id)
|
||||||
|
|
||||||
|
if (fs.existsSync(preprocessDirPath)) {
|
||||||
|
const stats = await fs.promises.stat(preprocessDirPath)
|
||||||
|
|
||||||
|
// 如果是目录,说明已经被预处理过
|
||||||
|
if (stats.isDirectory()) {
|
||||||
|
// 查找目录中的处理结果文件
|
||||||
|
const files = await fs.promises.readdir(preprocessDirPath)
|
||||||
|
|
||||||
|
// 查找主要的处理结果文件(.md 或 .txt)
|
||||||
|
const processedFile = files.find((fileName) => fileName.endsWith('.md') || fileName.endsWith('.txt'))
|
||||||
|
|
||||||
|
if (processedFile) {
|
||||||
|
const processedFilePath = path.join(preprocessDirPath, processedFile)
|
||||||
|
const processedStats = await fs.promises.stat(processedFilePath)
|
||||||
|
const ext = getFileExt(processedFile)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...file,
|
||||||
|
name: file.name.replace(file.ext, ext),
|
||||||
|
path: processedFilePath,
|
||||||
|
ext: ext,
|
||||||
|
size: processedStats.size,
|
||||||
|
created_at: processedStats.birthtime.toISOString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
} catch (error) {
|
||||||
|
// 如果检查过程中出现错误,返回null表示未处理
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 辅助方法:延迟执行
|
||||||
|
*/
|
||||||
|
public delay = (ms: number): Promise<void> => {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
|
}
|
||||||
|
|
||||||
|
public async readPdf(
|
||||||
|
source: string | URL | TypedArray,
|
||||||
|
passwordCallback?: (fn: (password: string) => void, reason: string) => string
|
||||||
|
) {
|
||||||
|
const { getDocument } = await import('pdfjs-dist/legacy/build/pdf.mjs')
|
||||||
|
const documentLoadingTask = getDocument(source)
|
||||||
|
if (passwordCallback) {
|
||||||
|
documentLoadingTask.onPassword = passwordCallback
|
||||||
|
}
|
||||||
|
|
||||||
|
const document = await documentLoadingTask.promise
|
||||||
|
return document
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendPreprocessProgress(sourceId: string, progress: number): Promise<void> {
|
||||||
|
const mainWindow = windowService.getMainWindow()
|
||||||
|
mainWindow?.webContents.send('file-preprocess-progress', {
|
||||||
|
itemId: sourceId,
|
||||||
|
progress: progress
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将文件移动到附件目录
|
||||||
|
* @param fileId 文件id
|
||||||
|
* @param filePaths 需要移动的文件路径数组
|
||||||
|
* @returns 移动后的文件路径数组
|
||||||
|
*/
|
||||||
|
public moveToAttachmentsDir(fileId: string, filePaths: string[]): string[] {
|
||||||
|
const attachmentsPath = path.join(this.storageDir, fileId)
|
||||||
|
if (!fs.existsSync(attachmentsPath)) {
|
||||||
|
fs.mkdirSync(attachmentsPath, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const movedPaths: string[] = []
|
||||||
|
|
||||||
|
for (const filePath of filePaths) {
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
const fileName = path.basename(filePath)
|
||||||
|
const destPath = path.join(attachmentsPath, fileName)
|
||||||
|
fs.copyFileSync(filePath, destPath)
|
||||||
|
fs.unlinkSync(filePath) // 删除原文件,实现"移动"
|
||||||
|
movedPaths.push(destPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return movedPaths
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/main/preprocess/DefaultPreprocessProvider.ts
Normal file
16
src/main/preprocess/DefaultPreprocessProvider.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { FileMetadata, PreprocessProvider } from '@types'
|
||||||
|
|
||||||
|
import BasePreprocessProvider from './BasePreprocessProvider'
|
||||||
|
|
||||||
|
export default class DefaultPreprocessProvider extends BasePreprocessProvider {
|
||||||
|
constructor(provider: PreprocessProvider) {
|
||||||
|
super(provider)
|
||||||
|
}
|
||||||
|
public parseFile(): Promise<{ processedFile: FileMetadata }> {
|
||||||
|
throw new Error('Method not implemented.')
|
||||||
|
}
|
||||||
|
|
||||||
|
public checkQuota(): Promise<number> {
|
||||||
|
throw new Error('Method not implemented.')
|
||||||
|
}
|
||||||
|
}
|
||||||
329
src/main/preprocess/Doc2xPreprocessProvider.ts
Normal file
329
src/main/preprocess/Doc2xPreprocessProvider.ts
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
import { FileMetadata, PreprocessProvider } from '@types'
|
||||||
|
import AdmZip from 'adm-zip'
|
||||||
|
import axios, { AxiosRequestConfig } from 'axios'
|
||||||
|
import Logger from 'electron-log'
|
||||||
|
|
||||||
|
import BasePreprocessProvider from './BasePreprocessProvider'
|
||||||
|
|
||||||
|
type ApiResponse<T> = {
|
||||||
|
code: string
|
||||||
|
data: T
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type PreuploadResponse = {
|
||||||
|
uid: string
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type StatusResponse = {
|
||||||
|
status: string
|
||||||
|
progress: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type ParsedFileResponse = {
|
||||||
|
status: string
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class Doc2xPreprocessProvider extends BasePreprocessProvider {
|
||||||
|
constructor(provider: PreprocessProvider) {
|
||||||
|
super(provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async validateFile(filePath: string): Promise<void> {
|
||||||
|
const pdfBuffer = await fs.promises.readFile(filePath)
|
||||||
|
|
||||||
|
const doc = await this.readPdf(new Uint8Array(pdfBuffer))
|
||||||
|
|
||||||
|
// 文件页数小于1000页
|
||||||
|
if (doc.numPages >= 1000) {
|
||||||
|
throw new Error(`PDF page count (${doc.numPages}) exceeds the limit of 1000 pages`)
|
||||||
|
}
|
||||||
|
// 文件大小小于300MB
|
||||||
|
if (pdfBuffer.length >= 300 * 1024 * 1024) {
|
||||||
|
const fileSizeMB = Math.round(pdfBuffer.length / (1024 * 1024))
|
||||||
|
throw new Error(`PDF file size (${fileSizeMB}MB) exceeds the limit of 300MB`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async parseFile(sourceId: string, file: FileMetadata): Promise<{ processedFile: FileMetadata }> {
|
||||||
|
try {
|
||||||
|
Logger.info(`Preprocess processing started: ${file.path}`)
|
||||||
|
|
||||||
|
// 步骤1: 准备上传
|
||||||
|
const { uid, url } = await this.preupload()
|
||||||
|
Logger.info(`Preprocess preupload completed: uid=${uid}`)
|
||||||
|
|
||||||
|
await this.validateFile(file.path)
|
||||||
|
|
||||||
|
// 步骤2: 上传文件
|
||||||
|
await this.putFile(file.path, url)
|
||||||
|
|
||||||
|
// 步骤3: 等待处理完成
|
||||||
|
await this.waitForProcessing(sourceId, uid)
|
||||||
|
Logger.info(`Preprocess parsing completed successfully for: ${file.path}`)
|
||||||
|
|
||||||
|
// 步骤4: 导出文件
|
||||||
|
const { path: outputPath } = await this.exportFile(file, uid)
|
||||||
|
|
||||||
|
// 步骤5: 创建处理后的文件信息
|
||||||
|
return {
|
||||||
|
processedFile: this.createProcessedFileInfo(file, outputPath)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(
|
||||||
|
`Preprocess processing failed for ${file.path}: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private createProcessedFileInfo(file: FileMetadata, outputPath: string): FileMetadata {
|
||||||
|
const outputFilePath = `${outputPath}/${file.name.split('.').slice(0, -1).join('.')}.md`
|
||||||
|
return {
|
||||||
|
...file,
|
||||||
|
name: file.name.replace('.pdf', '.md'),
|
||||||
|
path: outputFilePath,
|
||||||
|
ext: '.md',
|
||||||
|
size: fs.statSync(outputFilePath).size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出文件
|
||||||
|
* @param file 文件信息
|
||||||
|
* @param uid 预上传响应的uid
|
||||||
|
* @returns 导出文件的路径
|
||||||
|
*/
|
||||||
|
public async exportFile(file: FileMetadata, uid: string): Promise<{ path: string }> {
|
||||||
|
Logger.info(`Exporting file: ${file.path}`)
|
||||||
|
|
||||||
|
// 步骤1: 转换文件
|
||||||
|
await this.convertFile(uid, file.path)
|
||||||
|
Logger.info(`File conversion completed for: ${file.path}`)
|
||||||
|
|
||||||
|
// 步骤2: 等待导出并获取URL
|
||||||
|
const exportUrl = await this.waitForExport(uid)
|
||||||
|
|
||||||
|
// 步骤3: 下载并解压文件
|
||||||
|
return this.downloadFile(exportUrl, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 等待处理完成
|
||||||
|
* @param sourceId 源文件ID
|
||||||
|
* @param uid 预上传响应的uid
|
||||||
|
*/
|
||||||
|
private async waitForProcessing(sourceId: string, uid: string): Promise<void> {
|
||||||
|
while (true) {
|
||||||
|
await this.delay(1000)
|
||||||
|
const { status, progress } = await this.getStatus(uid)
|
||||||
|
await this.sendPreprocessProgress(sourceId, progress)
|
||||||
|
Logger.info(`Preprocess processing status: ${status}, progress: ${progress}%`)
|
||||||
|
|
||||||
|
if (status === 'success') {
|
||||||
|
return
|
||||||
|
} else if (status === 'failed') {
|
||||||
|
throw new Error('Preprocess processing failed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 等待导出完成
|
||||||
|
* @param uid 预上传响应的uid
|
||||||
|
* @returns 导出文件的url
|
||||||
|
*/
|
||||||
|
private async waitForExport(uid: string): Promise<string> {
|
||||||
|
while (true) {
|
||||||
|
await this.delay(1000)
|
||||||
|
const { status, url } = await this.getParsedFile(uid)
|
||||||
|
Logger.info(`Export status: ${status}`)
|
||||||
|
|
||||||
|
if (status === 'success' && url) {
|
||||||
|
return url
|
||||||
|
} else if (status === 'failed') {
|
||||||
|
throw new Error('Export failed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预上传文件
|
||||||
|
* @returns 预上传响应的url和uid
|
||||||
|
*/
|
||||||
|
private async preupload(): Promise<PreuploadResponse> {
|
||||||
|
const config = this.createAuthConfig()
|
||||||
|
const endpoint = `${this.provider.apiHost}/api/v2/parse/preupload`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await axios.post<ApiResponse<PreuploadResponse>>(endpoint, null, config)
|
||||||
|
|
||||||
|
if (data.code === 'success' && data.data) {
|
||||||
|
return data.data
|
||||||
|
} else {
|
||||||
|
throw new Error(`API returned error: ${data.message || JSON.stringify(data)}`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`Failed to get preupload URL: ${error instanceof Error ? error.message : String(error)}`)
|
||||||
|
throw new Error('Failed to get preupload URL')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传文件
|
||||||
|
* @param filePath 文件路径
|
||||||
|
* @param url 预上传响应的url
|
||||||
|
*/
|
||||||
|
private async putFile(filePath: string, url: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const fileStream = fs.createReadStream(filePath)
|
||||||
|
const response = await axios.put(url, fileStream)
|
||||||
|
|
||||||
|
if (response.status !== 200) {
|
||||||
|
throw new Error(`HTTP status ${response.status}: ${response.statusText}`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`Failed to upload file ${filePath}: ${error instanceof Error ? error.message : String(error)}`)
|
||||||
|
throw new Error('Failed to upload file')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getStatus(uid: string): Promise<StatusResponse> {
|
||||||
|
const config = this.createAuthConfig()
|
||||||
|
const endpoint = `${this.provider.apiHost}/api/v2/parse/status?uid=${uid}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.get<ApiResponse<StatusResponse>>(endpoint, config)
|
||||||
|
|
||||||
|
if (response.data.code === 'success' && response.data.data) {
|
||||||
|
return response.data.data
|
||||||
|
} else {
|
||||||
|
throw new Error(`API returned error: ${response.data.message || JSON.stringify(response.data)}`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`Failed to get status for uid ${uid}: ${error instanceof Error ? error.message : String(error)}`)
|
||||||
|
throw new Error('Failed to get processing status')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preprocess文件
|
||||||
|
* @param uid 预上传响应的uid
|
||||||
|
* @param filePath 文件路径
|
||||||
|
*/
|
||||||
|
private async convertFile(uid: string, filePath: string): Promise<void> {
|
||||||
|
const fileName = path.basename(filePath).split('.')[0]
|
||||||
|
const config = {
|
||||||
|
...this.createAuthConfig(),
|
||||||
|
headers: {
|
||||||
|
...this.createAuthConfig().headers,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
uid,
|
||||||
|
to: 'md',
|
||||||
|
formula_mode: 'normal',
|
||||||
|
filename: fileName
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpoint = `${this.provider.apiHost}/api/v2/convert/parse`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post<ApiResponse<any>>(endpoint, payload, config)
|
||||||
|
|
||||||
|
if (response.data.code !== 'success') {
|
||||||
|
throw new Error(`API returned error: ${response.data.message || JSON.stringify(response.data)}`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`Failed to convert file ${filePath}: ${error instanceof Error ? error.message : String(error)}`)
|
||||||
|
throw new Error('Failed to convert file')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取解析后的文件信息
|
||||||
|
* @param uid 预上传响应的uid
|
||||||
|
* @returns 解析后的文件信息
|
||||||
|
*/
|
||||||
|
private async getParsedFile(uid: string): Promise<ParsedFileResponse> {
|
||||||
|
const config = this.createAuthConfig()
|
||||||
|
const endpoint = `${this.provider.apiHost}/api/v2/convert/parse/result?uid=${uid}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.get<ApiResponse<ParsedFileResponse>>(endpoint, config)
|
||||||
|
|
||||||
|
if (response.status === 200 && response.data.data) {
|
||||||
|
return response.data.data
|
||||||
|
} else {
|
||||||
|
throw new Error(`HTTP status ${response.status}: ${response.statusText}`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(
|
||||||
|
`Failed to get parsed file for uid ${uid}: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
throw new Error('Failed to get parsed file information')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载文件
|
||||||
|
* @param url 导出文件的url
|
||||||
|
* @param file 文件信息
|
||||||
|
* @returns 下载文件的路径
|
||||||
|
*/
|
||||||
|
private async downloadFile(url: string, file: FileMetadata): Promise<{ path: string }> {
|
||||||
|
const dirPath = this.storageDir
|
||||||
|
// 使用统一的存储路径:Data/Files/{file.id}/
|
||||||
|
const extractPath = path.join(dirPath, file.id)
|
||||||
|
const zipPath = path.join(dirPath, `${file.id}.zip`)
|
||||||
|
|
||||||
|
// 确保目录存在
|
||||||
|
fs.mkdirSync(dirPath, { recursive: true })
|
||||||
|
fs.mkdirSync(extractPath, { recursive: true })
|
||||||
|
|
||||||
|
Logger.info(`Downloading to export path: ${zipPath}`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 下载文件
|
||||||
|
const response = await axios.get(url, { responseType: 'arraybuffer' })
|
||||||
|
fs.writeFileSync(zipPath, response.data)
|
||||||
|
|
||||||
|
// 确保提取目录存在
|
||||||
|
if (!fs.existsSync(extractPath)) {
|
||||||
|
fs.mkdirSync(extractPath, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解压文件
|
||||||
|
const zip = new AdmZip(zipPath)
|
||||||
|
zip.extractAllTo(extractPath, true)
|
||||||
|
Logger.info(`Extracted files to: ${extractPath}`)
|
||||||
|
|
||||||
|
// 删除临时ZIP文件
|
||||||
|
fs.unlinkSync(zipPath)
|
||||||
|
|
||||||
|
return { path: extractPath }
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`Failed to download and extract file: ${error instanceof Error ? error.message : String(error)}`)
|
||||||
|
throw new Error('Failed to download and extract file')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private createAuthConfig(): AxiosRequestConfig {
|
||||||
|
return {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.provider.apiKey}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public checkQuota(): Promise<number> {
|
||||||
|
throw new Error('Method not implemented.')
|
||||||
|
}
|
||||||
|
}
|
||||||
399
src/main/preprocess/MineruPreprocessProvider.ts
Normal file
399
src/main/preprocess/MineruPreprocessProvider.ts
Normal file
@@ -0,0 +1,399 @@
|
|||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
import { FileMetadata, PreprocessProvider } from '@types'
|
||||||
|
import AdmZip from 'adm-zip'
|
||||||
|
import axios from 'axios'
|
||||||
|
import Logger from 'electron-log'
|
||||||
|
|
||||||
|
import BasePreprocessProvider from './BasePreprocessProvider'
|
||||||
|
|
||||||
|
type ApiResponse<T> = {
|
||||||
|
code: number
|
||||||
|
data: T
|
||||||
|
msg?: string
|
||||||
|
trace_id?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type BatchUploadResponse = {
|
||||||
|
batch_id: string
|
||||||
|
file_urls: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExtractProgress = {
|
||||||
|
extracted_pages: number
|
||||||
|
total_pages: number
|
||||||
|
start_time: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExtractFileResult = {
|
||||||
|
file_name: string
|
||||||
|
state: 'done' | 'waiting-file' | 'pending' | 'running' | 'converting' | 'failed'
|
||||||
|
err_msg: string
|
||||||
|
full_zip_url?: string
|
||||||
|
extract_progress?: ExtractProgress
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExtractResultResponse = {
|
||||||
|
batch_id: string
|
||||||
|
extract_result: ExtractFileResult[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type QuotaResponse = {
|
||||||
|
code: number
|
||||||
|
data: {
|
||||||
|
user_left_quota: number
|
||||||
|
total_left_quota: number
|
||||||
|
}
|
||||||
|
msg?: string
|
||||||
|
trace_id?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class MineruPreprocessProvider extends BasePreprocessProvider {
|
||||||
|
constructor(provider: PreprocessProvider, userId?: string) {
|
||||||
|
super(provider, userId)
|
||||||
|
// todo:免费期结束后删除
|
||||||
|
this.provider.apiKey = this.provider.apiKey || import.meta.env.MAIN_VITE_MINERU_API_KEY
|
||||||
|
}
|
||||||
|
|
||||||
|
public async parseFile(
|
||||||
|
sourceId: string,
|
||||||
|
file: FileMetadata
|
||||||
|
): Promise<{ processedFile: FileMetadata; quota: number }> {
|
||||||
|
try {
|
||||||
|
Logger.info(`MinerU preprocess processing started: ${file.path}`)
|
||||||
|
await this.validateFile(file.path)
|
||||||
|
|
||||||
|
// 1. 获取上传URL并上传文件
|
||||||
|
const batchId = await this.uploadFile(file)
|
||||||
|
Logger.info(`MinerU file upload completed: batch_id=${batchId}`)
|
||||||
|
|
||||||
|
// 2. 等待处理完成并获取结果
|
||||||
|
const extractResult = await this.waitForCompletion(sourceId, batchId, file.origin_name)
|
||||||
|
Logger.info(`MinerU processing completed for batch: ${batchId}`)
|
||||||
|
|
||||||
|
// 3. 下载并解压文件
|
||||||
|
const { path: outputPath } = await this.downloadAndExtractFile(extractResult.full_zip_url!, file)
|
||||||
|
|
||||||
|
// 4. check quota
|
||||||
|
const quota = await this.checkQuota()
|
||||||
|
|
||||||
|
// 5. 创建处理后的文件信息
|
||||||
|
return {
|
||||||
|
processedFile: this.createProcessedFileInfo(file, outputPath),
|
||||||
|
quota
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
Logger.error(`MinerU preprocess processing failed for ${file.path}: ${error.message}`)
|
||||||
|
throw new Error(error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async checkQuota() {
|
||||||
|
try {
|
||||||
|
const quota = await fetch(`${this.provider.apiHost}/api/v4/quota`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${this.provider.apiKey}`,
|
||||||
|
token: this.userId ?? ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (!quota.ok) {
|
||||||
|
throw new Error(`HTTP ${quota.status}: ${quota.statusText}`)
|
||||||
|
}
|
||||||
|
const response: QuotaResponse = await quota.json()
|
||||||
|
return response.data.user_left_quota
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking quota:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async validateFile(filePath: string): Promise<void> {
|
||||||
|
const quota = await this.checkQuota()
|
||||||
|
const pdfBuffer = await fs.promises.readFile(filePath)
|
||||||
|
|
||||||
|
const doc = await this.readPdf(new Uint8Array(pdfBuffer))
|
||||||
|
|
||||||
|
// 文件页数小于600页
|
||||||
|
if (doc.numPages >= 600) {
|
||||||
|
throw new Error(`PDF page count (${doc.numPages}) exceeds the limit of 600 pages`)
|
||||||
|
}
|
||||||
|
// 文件大小小于200MB
|
||||||
|
if (pdfBuffer.length >= 200 * 1024 * 1024) {
|
||||||
|
const fileSizeMB = Math.round(pdfBuffer.length / (1024 * 1024))
|
||||||
|
throw new Error(`PDF file size (${fileSizeMB}MB) exceeds the limit of 200MB`)
|
||||||
|
}
|
||||||
|
// 检查配额
|
||||||
|
if (quota <= 0 || quota - doc.numPages <= 0) {
|
||||||
|
throw new Error('MinerU解析配额不足,请申请企业账户或自行部署,剩余额度:' + quota)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private createProcessedFileInfo(file: FileMetadata, outputPath: string): FileMetadata {
|
||||||
|
// 查找解压后的主要文件
|
||||||
|
let finalPath = ''
|
||||||
|
let finalName = file.origin_name.replace('.pdf', '.md')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const files = fs.readdirSync(outputPath)
|
||||||
|
|
||||||
|
const mdFile = files.find((f) => f.endsWith('.md'))
|
||||||
|
if (mdFile) {
|
||||||
|
const originalMdPath = path.join(outputPath, mdFile)
|
||||||
|
const newMdPath = path.join(outputPath, finalName)
|
||||||
|
|
||||||
|
// 重命名文件为原始文件名
|
||||||
|
try {
|
||||||
|
fs.renameSync(originalMdPath, newMdPath)
|
||||||
|
finalPath = newMdPath
|
||||||
|
Logger.info(`Renamed markdown file from ${mdFile} to ${finalName}`)
|
||||||
|
} catch (renameError) {
|
||||||
|
Logger.warn(`Failed to rename file ${mdFile} to ${finalName}: ${renameError}`)
|
||||||
|
// 如果重命名失败,使用原文件
|
||||||
|
finalPath = originalMdPath
|
||||||
|
finalName = mdFile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Logger.warn(`Failed to read output directory ${outputPath}: ${error}`)
|
||||||
|
finalPath = path.join(outputPath, `${file.id}.md`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...file,
|
||||||
|
name: finalName,
|
||||||
|
path: finalPath,
|
||||||
|
ext: '.md',
|
||||||
|
size: fs.existsSync(finalPath) ? fs.statSync(finalPath).size : 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async downloadAndExtractFile(zipUrl: string, file: FileMetadata): Promise<{ path: string }> {
|
||||||
|
const dirPath = this.storageDir
|
||||||
|
|
||||||
|
const zipPath = path.join(dirPath, `${file.id}.zip`)
|
||||||
|
const extractPath = path.join(dirPath, `${file.id}`)
|
||||||
|
|
||||||
|
Logger.info(`Downloading MinerU result to: ${zipPath}`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 下载ZIP文件
|
||||||
|
const response = await axios.get(zipUrl, { responseType: 'arraybuffer' })
|
||||||
|
fs.writeFileSync(zipPath, response.data)
|
||||||
|
Logger.info(`Downloaded ZIP file: ${zipPath}`)
|
||||||
|
|
||||||
|
// 确保提取目录存在
|
||||||
|
if (!fs.existsSync(extractPath)) {
|
||||||
|
fs.mkdirSync(extractPath, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解压文件
|
||||||
|
const zip = new AdmZip(zipPath)
|
||||||
|
zip.extractAllTo(extractPath, true)
|
||||||
|
Logger.info(`Extracted files to: ${extractPath}`)
|
||||||
|
|
||||||
|
// 删除临时ZIP文件
|
||||||
|
fs.unlinkSync(zipPath)
|
||||||
|
|
||||||
|
return { path: extractPath }
|
||||||
|
} catch (error: any) {
|
||||||
|
Logger.error(`Failed to download and extract file: ${error.message}`)
|
||||||
|
throw new Error(error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async uploadFile(file: FileMetadata): Promise<string> {
|
||||||
|
try {
|
||||||
|
// 步骤1: 获取上传URL
|
||||||
|
const { batchId, fileUrls } = await this.getBatchUploadUrls(file)
|
||||||
|
Logger.info(`Got upload URLs for batch: ${batchId}`)
|
||||||
|
|
||||||
|
console.log('batchId:', batchId, 'fileurls:', fileUrls)
|
||||||
|
// 步骤2: 上传文件到获取的URL
|
||||||
|
await this.putFileToUrl(file.path, fileUrls[0])
|
||||||
|
Logger.info(`File uploaded successfully: ${file.path}`)
|
||||||
|
|
||||||
|
return batchId
|
||||||
|
} catch (error: any) {
|
||||||
|
Logger.error(`Failed to upload file ${file.path}: ${error.message}`)
|
||||||
|
throw new Error(error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getBatchUploadUrls(file: FileMetadata): Promise<{ batchId: string; fileUrls: string[] }> {
|
||||||
|
const endpoint = `${this.provider.apiHost}/api/v4/file-urls/batch`
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
language: 'auto',
|
||||||
|
enable_formula: true,
|
||||||
|
enable_table: true,
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
name: file.origin_name,
|
||||||
|
is_ocr: true,
|
||||||
|
data_id: file.id
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${this.provider.apiKey}`,
|
||||||
|
token: this.userId ?? '',
|
||||||
|
Accept: '*/*'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data: ApiResponse<BatchUploadResponse> = await response.json()
|
||||||
|
if (data.code === 0 && data.data) {
|
||||||
|
const { batch_id, file_urls } = data.data
|
||||||
|
return {
|
||||||
|
batchId: batch_id,
|
||||||
|
fileUrls: file_urls
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error(`API returned error: ${data.msg || JSON.stringify(data)}`)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
Logger.error(`Failed to get batch upload URLs: ${error.message}`)
|
||||||
|
throw new Error(error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async putFileToUrl(filePath: string, uploadUrl: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const fileBuffer = await fs.promises.readFile(filePath)
|
||||||
|
|
||||||
|
const response = await fetch(uploadUrl, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: fileBuffer,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/pdf'
|
||||||
|
}
|
||||||
|
// headers: {
|
||||||
|
// 'Content-Length': fileBuffer.length.toString()
|
||||||
|
// }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
// 克隆 response 以避免消费 body stream
|
||||||
|
const responseClone = response.clone()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const responseBody = await responseClone.text()
|
||||||
|
const errorInfo = {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
url: response.url,
|
||||||
|
type: response.type,
|
||||||
|
redirected: response.redirected,
|
||||||
|
headers: Object.fromEntries(response.headers.entries()),
|
||||||
|
body: responseBody
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('Response details:', errorInfo)
|
||||||
|
throw new Error(`Upload failed with status ${response.status}: ${responseBody}`)
|
||||||
|
} catch (parseError) {
|
||||||
|
throw new Error(`Upload failed with status ${response.status}. Could not parse response body.`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.info(`File uploaded successfully to: ${uploadUrl}`)
|
||||||
|
} catch (error: any) {
|
||||||
|
Logger.error(`Failed to upload file to URL ${uploadUrl}: ${error}`)
|
||||||
|
throw new Error(error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getExtractResults(batchId: string): Promise<ExtractResultResponse> {
|
||||||
|
const endpoint = `${this.provider.apiHost}/api/v4/extract-results/batch/${batchId}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${this.provider.apiKey}`,
|
||||||
|
token: this.userId ?? ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data: ApiResponse<ExtractResultResponse> = await response.json()
|
||||||
|
if (data.code === 0 && data.data) {
|
||||||
|
return data.data
|
||||||
|
} else {
|
||||||
|
throw new Error(`API returned error: ${data.msg || JSON.stringify(data)}`)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
Logger.error(`Failed to get extract results for batch ${batchId}: ${error.message}`)
|
||||||
|
throw new Error(error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async waitForCompletion(
|
||||||
|
sourceId: string,
|
||||||
|
batchId: string,
|
||||||
|
fileName: string,
|
||||||
|
maxRetries: number = 60,
|
||||||
|
intervalMs: number = 5000
|
||||||
|
): Promise<ExtractFileResult> {
|
||||||
|
let retries = 0
|
||||||
|
|
||||||
|
while (retries < maxRetries) {
|
||||||
|
try {
|
||||||
|
const result = await this.getExtractResults(batchId)
|
||||||
|
|
||||||
|
// 查找对应文件的处理结果
|
||||||
|
const fileResult = result.extract_result.find((item) => item.file_name === fileName)
|
||||||
|
if (!fileResult) {
|
||||||
|
throw new Error(`File ${fileName} not found in batch results`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查处理状态
|
||||||
|
if (fileResult.state === 'done' && fileResult.full_zip_url) {
|
||||||
|
Logger.info(`Processing completed for file: ${fileName}`)
|
||||||
|
return fileResult
|
||||||
|
} else if (fileResult.state === 'failed') {
|
||||||
|
throw new Error(`Processing failed for file: ${fileName}, error: ${fileResult.err_msg}`)
|
||||||
|
} else if (fileResult.state === 'running') {
|
||||||
|
// 发送进度更新
|
||||||
|
if (fileResult.extract_progress) {
|
||||||
|
const progress = Math.round(
|
||||||
|
(fileResult.extract_progress.extracted_pages / fileResult.extract_progress.total_pages) * 100
|
||||||
|
)
|
||||||
|
await this.sendPreprocessProgress(sourceId, progress)
|
||||||
|
Logger.info(`File ${fileName} processing progress: ${progress}%`)
|
||||||
|
} else {
|
||||||
|
// 如果没有具体进度信息,发送一个通用进度
|
||||||
|
await this.sendPreprocessProgress(sourceId, 50)
|
||||||
|
Logger.info(`File ${fileName} is still processing...`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Logger.warn(`Failed to check status for batch ${batchId}, retry ${retries + 1}/${maxRetries}`)
|
||||||
|
if (retries === maxRetries - 1) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
retries++
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, intervalMs))
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Processing timeout for batch: ${batchId}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
187
src/main/preprocess/MistralPreprocessProvider.ts
Normal file
187
src/main/preprocess/MistralPreprocessProvider.ts
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import fs from 'node:fs'
|
||||||
|
|
||||||
|
import { MistralClientManager } from '@main/services/MistralClientManager'
|
||||||
|
import { MistralService } from '@main/services/remotefile/MistralService'
|
||||||
|
import { Mistral } from '@mistralai/mistralai'
|
||||||
|
import { DocumentURLChunk } from '@mistralai/mistralai/models/components/documenturlchunk'
|
||||||
|
import { ImageURLChunk } from '@mistralai/mistralai/models/components/imageurlchunk'
|
||||||
|
import { OCRResponse } from '@mistralai/mistralai/models/components/ocrresponse'
|
||||||
|
import { FileMetadata, FileTypes, PreprocessProvider, Provider } from '@types'
|
||||||
|
import Logger from 'electron-log'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
import BasePreprocessProvider from './BasePreprocessProvider'
|
||||||
|
|
||||||
|
type PreuploadResponse = DocumentURLChunk | ImageURLChunk
|
||||||
|
|
||||||
|
export default class MistralPreprocessProvider extends BasePreprocessProvider {
|
||||||
|
private sdk: Mistral
|
||||||
|
private fileService: MistralService
|
||||||
|
|
||||||
|
constructor(provider: PreprocessProvider) {
|
||||||
|
super(provider)
|
||||||
|
const clientManager = MistralClientManager.getInstance()
|
||||||
|
const aiProvider: Provider = {
|
||||||
|
id: provider.id,
|
||||||
|
type: 'mistral',
|
||||||
|
name: provider.name,
|
||||||
|
apiKey: provider.apiKey!,
|
||||||
|
apiHost: provider.apiHost!,
|
||||||
|
models: []
|
||||||
|
}
|
||||||
|
clientManager.initializeClient(aiProvider)
|
||||||
|
this.sdk = clientManager.getClient()
|
||||||
|
this.fileService = new MistralService(aiProvider)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async preupload(file: FileMetadata): Promise<PreuploadResponse> {
|
||||||
|
let document: PreuploadResponse
|
||||||
|
Logger.info(`preprocess preupload started for local file: ${file.path}`)
|
||||||
|
|
||||||
|
if (file.ext.toLowerCase() === '.pdf') {
|
||||||
|
const uploadResponse = await this.fileService.uploadFile(file)
|
||||||
|
|
||||||
|
if (uploadResponse.status === 'failed') {
|
||||||
|
Logger.error('File upload failed:', uploadResponse)
|
||||||
|
throw new Error('Failed to upload file: ' + uploadResponse.displayName)
|
||||||
|
}
|
||||||
|
await this.sendPreprocessProgress(file.id, 15)
|
||||||
|
const fileUrl = await this.sdk.files.getSignedUrl({
|
||||||
|
fileId: uploadResponse.fileId
|
||||||
|
})
|
||||||
|
Logger.info('Got signed URL:', fileUrl)
|
||||||
|
await this.sendPreprocessProgress(file.id, 20)
|
||||||
|
document = {
|
||||||
|
type: 'document_url',
|
||||||
|
documentUrl: fileUrl.url
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const base64Image = Buffer.from(fs.readFileSync(file.path)).toString('base64')
|
||||||
|
document = {
|
||||||
|
type: 'image_url',
|
||||||
|
imageUrl: `data:image/png;base64,${base64Image}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!document) {
|
||||||
|
throw new Error('Unsupported file type')
|
||||||
|
}
|
||||||
|
return document
|
||||||
|
}
|
||||||
|
|
||||||
|
public async parseFile(sourceId: string, file: FileMetadata): Promise<{ processedFile: FileMetadata }> {
|
||||||
|
try {
|
||||||
|
const document = await this.preupload(file)
|
||||||
|
const result = await this.sdk.ocr.process({
|
||||||
|
model: this.provider.model!,
|
||||||
|
document: document,
|
||||||
|
includeImageBase64: true
|
||||||
|
})
|
||||||
|
if (result) {
|
||||||
|
await this.sendPreprocessProgress(sourceId, 100)
|
||||||
|
const processedFile = this.convertFile(result, file)
|
||||||
|
return {
|
||||||
|
processedFile
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error('preprocess processing failed: OCR response is empty')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error('preprocess processing failed: ' + error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private convertFile(result: OCRResponse, file: FileMetadata): FileMetadata {
|
||||||
|
// 使用统一的存储路径:Data/Files/{file.id}/
|
||||||
|
const conversionId = file.id
|
||||||
|
const outputPath = path.join(this.storageDir, file.id)
|
||||||
|
// const outputPath = this.storageDir
|
||||||
|
const outputFileName = path.basename(file.path, path.extname(file.path))
|
||||||
|
fs.mkdirSync(outputPath, { recursive: true })
|
||||||
|
|
||||||
|
const markdownParts: string[] = []
|
||||||
|
let counter = 0
|
||||||
|
|
||||||
|
// Process each page
|
||||||
|
result.pages.forEach((page) => {
|
||||||
|
let pageMarkdown = page.markdown
|
||||||
|
|
||||||
|
// Process images from this page
|
||||||
|
page.images.forEach((image) => {
|
||||||
|
if (image.imageBase64) {
|
||||||
|
let imageFormat = 'jpeg' // default format
|
||||||
|
let imageBase64Data = image.imageBase64
|
||||||
|
|
||||||
|
// Check for data URL prefix more efficiently
|
||||||
|
const prefixEnd = image.imageBase64.indexOf(';base64,')
|
||||||
|
if (prefixEnd > 0) {
|
||||||
|
const prefix = image.imageBase64.substring(0, prefixEnd)
|
||||||
|
const formatIndex = prefix.indexOf('image/')
|
||||||
|
if (formatIndex >= 0) {
|
||||||
|
imageFormat = prefix.substring(formatIndex + 6)
|
||||||
|
}
|
||||||
|
imageBase64Data = image.imageBase64.substring(prefixEnd + 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageFileName = `img-${counter}.${imageFormat}`
|
||||||
|
const imagePath = path.join(outputPath, imageFileName)
|
||||||
|
|
||||||
|
// Save image file
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(imagePath, Buffer.from(imageBase64Data, 'base64'))
|
||||||
|
|
||||||
|
// Update image reference in markdown
|
||||||
|
// Use relative path for better portability
|
||||||
|
const relativeImagePath = `./${imageFileName}`
|
||||||
|
|
||||||
|
// Find the start and end of the image markdown
|
||||||
|
const imgStart = pageMarkdown.indexOf(image.imageBase64)
|
||||||
|
if (imgStart >= 0) {
|
||||||
|
// Find the markdown image syntax around this base64
|
||||||
|
const mdStart = pageMarkdown.lastIndexOf('` +
|
||||||
|
pageMarkdown.substring(mdEnd + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
counter++
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`Failed to save image ${imageFileName}:`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
markdownParts.push(pageMarkdown)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Combine all markdown content with double newlines for readability
|
||||||
|
const combinedMarkdown = markdownParts.join('\n\n')
|
||||||
|
|
||||||
|
// Write the markdown content to a file
|
||||||
|
const mdFileName = `${outputFileName}.md`
|
||||||
|
const mdFilePath = path.join(outputPath, mdFileName)
|
||||||
|
fs.writeFileSync(mdFilePath, combinedMarkdown)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: conversionId,
|
||||||
|
name: file.name.replace(/\.[^/.]+$/, '.md'),
|
||||||
|
origin_name: file.origin_name,
|
||||||
|
path: mdFilePath,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
type: FileTypes.DOCUMENT,
|
||||||
|
ext: '.md',
|
||||||
|
size: fs.statSync(mdFilePath).size,
|
||||||
|
count: 1
|
||||||
|
} as FileMetadata
|
||||||
|
}
|
||||||
|
|
||||||
|
public checkQuota(): Promise<number> {
|
||||||
|
throw new Error('Method not implemented.')
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/main/preprocess/PreprocessProvider.ts
Normal file
30
src/main/preprocess/PreprocessProvider.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { FileMetadata, PreprocessProvider as Provider } from '@types'
|
||||||
|
|
||||||
|
import BasePreprocessProvider from './BasePreprocessProvider'
|
||||||
|
import PreprocessProviderFactory from './PreprocessProviderFactory'
|
||||||
|
|
||||||
|
export default class PreprocessProvider {
|
||||||
|
private sdk: BasePreprocessProvider
|
||||||
|
constructor(provider: Provider, userId?: string) {
|
||||||
|
this.sdk = PreprocessProviderFactory.create(provider, userId)
|
||||||
|
}
|
||||||
|
public async parseFile(
|
||||||
|
sourceId: string,
|
||||||
|
file: FileMetadata
|
||||||
|
): Promise<{ processedFile: FileMetadata; quota?: number }> {
|
||||||
|
return this.sdk.parseFile(sourceId, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
public async checkQuota(): Promise<number> {
|
||||||
|
return this.sdk.checkQuota()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查文件是否已经被预处理过
|
||||||
|
* @param file 文件信息
|
||||||
|
* @returns 如果已处理返回处理后的文件信息,否则返回null
|
||||||
|
*/
|
||||||
|
public async checkIfAlreadyProcessed(file: FileMetadata): Promise<FileMetadata | null> {
|
||||||
|
return this.sdk.checkIfAlreadyProcessed(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/main/preprocess/PreprocessProviderFactory.ts
Normal file
21
src/main/preprocess/PreprocessProviderFactory.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { PreprocessProvider } from '@types'
|
||||||
|
|
||||||
|
import BasePreprocessProvider from './BasePreprocessProvider'
|
||||||
|
import DefaultPreprocessProvider from './DefaultPreprocessProvider'
|
||||||
|
import Doc2xPreprocessProvider from './Doc2xPreprocessProvider'
|
||||||
|
import MineruPreprocessProvider from './MineruPreprocessProvider'
|
||||||
|
import MistralPreprocessProvider from './MistralPreprocessProvider'
|
||||||
|
export default class PreprocessProviderFactory {
|
||||||
|
static create(provider: PreprocessProvider, userId?: string): BasePreprocessProvider {
|
||||||
|
switch (provider.id) {
|
||||||
|
case 'doc2x':
|
||||||
|
return new Doc2xPreprocessProvider(provider)
|
||||||
|
case 'mistral':
|
||||||
|
return new MistralPreprocessProvider(provider)
|
||||||
|
case 'mineru':
|
||||||
|
return new MineruPreprocessProvider(provider, userId)
|
||||||
|
default:
|
||||||
|
return new DefaultPreprocessProvider(provider)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { getFilesDir, getFileType, getTempDir } from '@main/utils/file'
|
import { getFilesDir, getFileType, getTempDir } from '@main/utils/file'
|
||||||
import { documentExts, imageExts, MB } from '@shared/config/constant'
|
import { documentExts, imageExts, MB } from '@shared/config/constant'
|
||||||
import { FileType } from '@types'
|
import { FileMetadata } from '@types'
|
||||||
import * as crypto from 'crypto'
|
import * as crypto from 'crypto'
|
||||||
import {
|
import {
|
||||||
dialog,
|
dialog,
|
||||||
@@ -53,8 +53,9 @@ class FileStorage {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
findDuplicateFile = async (filePath: string): Promise<FileType | null> => {
|
findDuplicateFile = async (filePath: string): Promise<FileMetadata | null> => {
|
||||||
const stats = fs.statSync(filePath)
|
const stats = fs.statSync(filePath)
|
||||||
|
console.log('stats', stats, filePath)
|
||||||
const fileSize = stats.size
|
const fileSize = stats.size
|
||||||
|
|
||||||
const files = await fs.promises.readdir(this.storageDir)
|
const files = await fs.promises.readdir(this.storageDir)
|
||||||
@@ -92,7 +93,7 @@ class FileStorage {
|
|||||||
public selectFile = async (
|
public selectFile = async (
|
||||||
_: Electron.IpcMainInvokeEvent,
|
_: Electron.IpcMainInvokeEvent,
|
||||||
options?: OpenDialogOptions
|
options?: OpenDialogOptions
|
||||||
): Promise<FileType[] | null> => {
|
): Promise<FileMetadata[] | null> => {
|
||||||
const defaultOptions: OpenDialogOptions = {
|
const defaultOptions: OpenDialogOptions = {
|
||||||
properties: ['openFile']
|
properties: ['openFile']
|
||||||
}
|
}
|
||||||
@@ -151,7 +152,7 @@ class FileStorage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public uploadFile = async (_: Electron.IpcMainInvokeEvent, file: FileType): Promise<FileType> => {
|
public uploadFile = async (_: Electron.IpcMainInvokeEvent, file: FileMetadata): Promise<FileMetadata> => {
|
||||||
const duplicateFile = await this.findDuplicateFile(file.path)
|
const duplicateFile = await this.findDuplicateFile(file.path)
|
||||||
|
|
||||||
if (duplicateFile) {
|
if (duplicateFile) {
|
||||||
@@ -175,7 +176,7 @@ class FileStorage {
|
|||||||
const stats = await fs.promises.stat(destPath)
|
const stats = await fs.promises.stat(destPath)
|
||||||
const fileType = getFileType(ext)
|
const fileType = getFileType(ext)
|
||||||
|
|
||||||
const fileMetadata: FileType = {
|
const fileMetadata: FileMetadata = {
|
||||||
id: uuid,
|
id: uuid,
|
||||||
origin_name,
|
origin_name,
|
||||||
name: uuid + ext,
|
name: uuid + ext,
|
||||||
@@ -190,7 +191,7 @@ class FileStorage {
|
|||||||
return fileMetadata
|
return fileMetadata
|
||||||
}
|
}
|
||||||
|
|
||||||
public getFile = async (_: Electron.IpcMainInvokeEvent, filePath: string): Promise<FileType | null> => {
|
public getFile = async (_: Electron.IpcMainInvokeEvent, filePath: string): Promise<FileMetadata | null> => {
|
||||||
if (!fs.existsSync(filePath)) {
|
if (!fs.existsSync(filePath)) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -199,7 +200,7 @@ class FileStorage {
|
|||||||
const ext = path.extname(filePath)
|
const ext = path.extname(filePath)
|
||||||
const fileType = getFileType(ext)
|
const fileType = getFileType(ext)
|
||||||
|
|
||||||
const fileInfo: FileType = {
|
const fileInfo: FileMetadata = {
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
origin_name: path.basename(filePath),
|
origin_name: path.basename(filePath),
|
||||||
name: path.basename(filePath),
|
name: path.basename(filePath),
|
||||||
@@ -215,9 +216,19 @@ class FileStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public deleteFile = async (_: Electron.IpcMainInvokeEvent, id: string): Promise<void> => {
|
public deleteFile = async (_: Electron.IpcMainInvokeEvent, id: string): Promise<void> => {
|
||||||
|
if (!fs.existsSync(path.join(this.storageDir, id))) {
|
||||||
|
return
|
||||||
|
}
|
||||||
await fs.promises.unlink(path.join(this.storageDir, id))
|
await fs.promises.unlink(path.join(this.storageDir, id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public deleteDir = async (_: Electron.IpcMainInvokeEvent, id: string): Promise<void> => {
|
||||||
|
if (!fs.existsSync(path.join(this.storageDir, id))) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await fs.promises.rm(path.join(this.storageDir, id), { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
public readFile = async (_: Electron.IpcMainInvokeEvent, id: string): Promise<string> => {
|
public readFile = async (_: Electron.IpcMainInvokeEvent, id: string): Promise<string> => {
|
||||||
const filePath = path.join(this.storageDir, id)
|
const filePath = path.join(this.storageDir, id)
|
||||||
|
|
||||||
@@ -252,8 +263,8 @@ class FileStorage {
|
|||||||
if (!fs.existsSync(this.tempDir)) {
|
if (!fs.existsSync(this.tempDir)) {
|
||||||
fs.mkdirSync(this.tempDir, { recursive: true })
|
fs.mkdirSync(this.tempDir, { recursive: true })
|
||||||
}
|
}
|
||||||
const tempFilePath = path.join(this.tempDir, `temp_file_${uuidv4()}_${fileName}`)
|
|
||||||
return tempFilePath
|
return path.join(this.tempDir, `temp_file_${uuidv4()}_${fileName}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
public writeFile = async (
|
public writeFile = async (
|
||||||
@@ -280,7 +291,7 @@ class FileStorage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public saveBase64Image = async (_: Electron.IpcMainInvokeEvent, base64Data: string): Promise<FileType> => {
|
public saveBase64Image = async (_: Electron.IpcMainInvokeEvent, base64Data: string): Promise<FileMetadata> => {
|
||||||
try {
|
try {
|
||||||
if (!base64Data) {
|
if (!base64Data) {
|
||||||
throw new Error('Base64 data is required')
|
throw new Error('Base64 data is required')
|
||||||
@@ -306,7 +317,7 @@ class FileStorage {
|
|||||||
|
|
||||||
await fs.promises.writeFile(destPath, buffer)
|
await fs.promises.writeFile(destPath, buffer)
|
||||||
|
|
||||||
const fileMetadata: FileType = {
|
const fileMetadata: FileMetadata = {
|
||||||
id: uuid,
|
id: uuid,
|
||||||
origin_name: uuid + ext,
|
origin_name: uuid + ext,
|
||||||
name: uuid + ext,
|
name: uuid + ext,
|
||||||
@@ -465,7 +476,7 @@ class FileStorage {
|
|||||||
_: Electron.IpcMainInvokeEvent,
|
_: Electron.IpcMainInvokeEvent,
|
||||||
url: string,
|
url: string,
|
||||||
isUseContentType?: boolean
|
isUseContentType?: boolean
|
||||||
): Promise<FileType> => {
|
): Promise<FileMetadata> => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url)
|
const response = await fetch(url)
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -507,7 +518,7 @@ class FileStorage {
|
|||||||
const stats = await fs.promises.stat(destPath)
|
const stats = await fs.promises.stat(destPath)
|
||||||
const fileType = getFileType(ext)
|
const fileType = getFileType(ext)
|
||||||
|
|
||||||
const fileMetadata: FileType = {
|
const fileMetadata: FileMetadata = {
|
||||||
id: uuid,
|
id: uuid,
|
||||||
origin_name: filename,
|
origin_name: filename,
|
||||||
name: uuid + ext,
|
name: uuid + ext,
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ import { WebLoader } from '@cherrystudio/embedjs-loader-web'
|
|||||||
import Embeddings from '@main/embeddings/Embeddings'
|
import Embeddings from '@main/embeddings/Embeddings'
|
||||||
import { addFileLoader } from '@main/loader'
|
import { addFileLoader } from '@main/loader'
|
||||||
import { NoteLoader } from '@main/loader/noteLoader'
|
import { NoteLoader } from '@main/loader/noteLoader'
|
||||||
|
import OcrProvider from '@main/ocr/OcrProvider'
|
||||||
|
import PreprocessProvider from '@main/preprocess/PreprocessProvider'
|
||||||
import Reranker from '@main/reranker/Reranker'
|
import Reranker from '@main/reranker/Reranker'
|
||||||
import { windowService } from '@main/services/WindowService'
|
import { windowService } from '@main/services/WindowService'
|
||||||
import { getDataPath } from '@main/utils'
|
import { getDataPath } from '@main/utils'
|
||||||
@@ -31,7 +33,7 @@ import { getAllFiles } from '@main/utils/file'
|
|||||||
import { MB } from '@shared/config/constant'
|
import { MB } from '@shared/config/constant'
|
||||||
import type { LoaderReturn } from '@shared/config/types'
|
import type { LoaderReturn } from '@shared/config/types'
|
||||||
import { IpcChannel } from '@shared/IpcChannel'
|
import { IpcChannel } from '@shared/IpcChannel'
|
||||||
import { FileType, KnowledgeBaseParams, KnowledgeItem } from '@types'
|
import { FileMetadata, KnowledgeBaseParams, KnowledgeItem } from '@types'
|
||||||
import Logger from 'electron-log'
|
import Logger from 'electron-log'
|
||||||
import { v4 as uuidv4 } from 'uuid'
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
|
|
||||||
@@ -39,12 +41,14 @@ export interface KnowledgeBaseAddItemOptions {
|
|||||||
base: KnowledgeBaseParams
|
base: KnowledgeBaseParams
|
||||||
item: KnowledgeItem
|
item: KnowledgeItem
|
||||||
forceReload?: boolean
|
forceReload?: boolean
|
||||||
|
userId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface KnowledgeBaseAddItemOptionsNonNullableAttribute {
|
interface KnowledgeBaseAddItemOptionsNonNullableAttribute {
|
||||||
base: KnowledgeBaseParams
|
base: KnowledgeBaseParams
|
||||||
item: KnowledgeItem
|
item: KnowledgeItem
|
||||||
forceReload: boolean
|
forceReload: boolean
|
||||||
|
userId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EvaluateTaskWorkload {
|
interface EvaluateTaskWorkload {
|
||||||
@@ -96,7 +100,13 @@ class KnowledgeService {
|
|||||||
private knowledgeItemProcessingQueueMappingPromise: Map<LoaderTaskOfSet, () => void> = new Map()
|
private knowledgeItemProcessingQueueMappingPromise: Map<LoaderTaskOfSet, () => void> = new Map()
|
||||||
private static MAXIMUM_WORKLOAD = 80 * MB
|
private static MAXIMUM_WORKLOAD = 80 * MB
|
||||||
private static MAXIMUM_PROCESSING_ITEM_COUNT = 30
|
private static MAXIMUM_PROCESSING_ITEM_COUNT = 30
|
||||||
private static ERROR_LOADER_RETURN: LoaderReturn = { entriesAdded: 0, uniqueId: '', uniqueIds: [''], loaderType: '' }
|
private static ERROR_LOADER_RETURN: LoaderReturn = {
|
||||||
|
entriesAdded: 0,
|
||||||
|
uniqueId: '',
|
||||||
|
uniqueIds: [''],
|
||||||
|
loaderType: '',
|
||||||
|
status: 'failed'
|
||||||
|
}
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.initStorageDir()
|
this.initStorageDir()
|
||||||
@@ -150,6 +160,7 @@ class KnowledgeService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public delete = async (_: Electron.IpcMainInvokeEvent, id: string): Promise<void> => {
|
public delete = async (_: Electron.IpcMainInvokeEvent, id: string): Promise<void> => {
|
||||||
|
console.log('id', id)
|
||||||
const dbPath = path.join(this.storageDir, id)
|
const dbPath = path.join(this.storageDir, id)
|
||||||
if (fs.existsSync(dbPath)) {
|
if (fs.existsSync(dbPath)) {
|
||||||
fs.rmSync(dbPath, { recursive: true })
|
fs.rmSync(dbPath, { recursive: true })
|
||||||
@@ -162,28 +173,49 @@ class KnowledgeService {
|
|||||||
this.workload >= KnowledgeService.MAXIMUM_WORKLOAD
|
this.workload >= KnowledgeService.MAXIMUM_WORKLOAD
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fileTask(
|
private fileTask(
|
||||||
ragApplication: RAGApplication,
|
ragApplication: RAGApplication,
|
||||||
options: KnowledgeBaseAddItemOptionsNonNullableAttribute
|
options: KnowledgeBaseAddItemOptionsNonNullableAttribute
|
||||||
): LoaderTask {
|
): LoaderTask {
|
||||||
const { base, item, forceReload } = options
|
const { base, item, forceReload, userId } = options
|
||||||
const file = item.content as FileType
|
const file = item.content as FileMetadata
|
||||||
|
|
||||||
const loaderTask: LoaderTask = {
|
const loaderTask: LoaderTask = {
|
||||||
loaderTasks: [
|
loaderTasks: [
|
||||||
{
|
{
|
||||||
state: LoaderTaskItemState.PENDING,
|
state: LoaderTaskItemState.PENDING,
|
||||||
task: () =>
|
task: async () => {
|
||||||
addFileLoader(ragApplication, file, base, forceReload)
|
try {
|
||||||
.then((result) => {
|
// 添加预处理逻辑
|
||||||
loaderTask.loaderDoneReturn = result
|
const fileToProcess: FileMetadata = await this.preprocessing(file, base, item, userId)
|
||||||
return result
|
|
||||||
})
|
// 使用处理后的文件进行加载
|
||||||
.catch((err) => {
|
return addFileLoader(ragApplication, fileToProcess, base, forceReload)
|
||||||
Logger.error(err)
|
.then((result) => {
|
||||||
return KnowledgeService.ERROR_LOADER_RETURN
|
loaderTask.loaderDoneReturn = result
|
||||||
}),
|
return result
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
Logger.error(`Error in addFileLoader for ${file.name}: ${e}`)
|
||||||
|
const errorResult: LoaderReturn = {
|
||||||
|
...KnowledgeService.ERROR_LOADER_RETURN,
|
||||||
|
message: e.message,
|
||||||
|
messageSource: 'embedding'
|
||||||
|
}
|
||||||
|
loaderTask.loaderDoneReturn = errorResult
|
||||||
|
return errorResult
|
||||||
|
})
|
||||||
|
} catch (e: any) {
|
||||||
|
Logger.error(`Preprocessing failed for ${file.name}: ${e}`)
|
||||||
|
const errorResult: LoaderReturn = {
|
||||||
|
...KnowledgeService.ERROR_LOADER_RETURN,
|
||||||
|
message: e.message,
|
||||||
|
messageSource: 'preprocess'
|
||||||
|
}
|
||||||
|
loaderTask.loaderDoneReturn = errorResult
|
||||||
|
return errorResult
|
||||||
|
}
|
||||||
|
},
|
||||||
evaluateTaskWorkload: { workload: file.size }
|
evaluateTaskWorkload: { workload: file.size }
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -192,7 +224,6 @@ class KnowledgeService {
|
|||||||
|
|
||||||
return loaderTask
|
return loaderTask
|
||||||
}
|
}
|
||||||
|
|
||||||
private directoryTask(
|
private directoryTask(
|
||||||
ragApplication: RAGApplication,
|
ragApplication: RAGApplication,
|
||||||
options: KnowledgeBaseAddItemOptionsNonNullableAttribute
|
options: KnowledgeBaseAddItemOptionsNonNullableAttribute
|
||||||
@@ -232,7 +263,11 @@ class KnowledgeService {
|
|||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
Logger.error(err)
|
Logger.error(err)
|
||||||
return KnowledgeService.ERROR_LOADER_RETURN
|
return {
|
||||||
|
...KnowledgeService.ERROR_LOADER_RETURN,
|
||||||
|
message: `Failed to add dir loader: ${err.message}`,
|
||||||
|
messageSource: 'embedding'
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
evaluateTaskWorkload: { workload: file.size }
|
evaluateTaskWorkload: { workload: file.size }
|
||||||
})
|
})
|
||||||
@@ -278,7 +313,11 @@ class KnowledgeService {
|
|||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
Logger.error(err)
|
Logger.error(err)
|
||||||
return KnowledgeService.ERROR_LOADER_RETURN
|
return {
|
||||||
|
...KnowledgeService.ERROR_LOADER_RETURN,
|
||||||
|
message: `Failed to add url loader: ${err.message}`,
|
||||||
|
messageSource: 'embedding'
|
||||||
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
evaluateTaskWorkload: { workload: 2 * MB }
|
evaluateTaskWorkload: { workload: 2 * MB }
|
||||||
@@ -318,7 +357,11 @@ class KnowledgeService {
|
|||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
Logger.error(err)
|
Logger.error(err)
|
||||||
return KnowledgeService.ERROR_LOADER_RETURN
|
return {
|
||||||
|
...KnowledgeService.ERROR_LOADER_RETURN,
|
||||||
|
message: `Failed to add sitemap loader: ${err.message}`,
|
||||||
|
messageSource: 'embedding'
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
evaluateTaskWorkload: { workload: 20 * MB }
|
evaluateTaskWorkload: { workload: 20 * MB }
|
||||||
}
|
}
|
||||||
@@ -364,7 +407,11 @@ class KnowledgeService {
|
|||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
Logger.error(err)
|
Logger.error(err)
|
||||||
return KnowledgeService.ERROR_LOADER_RETURN
|
return {
|
||||||
|
...KnowledgeService.ERROR_LOADER_RETURN,
|
||||||
|
message: `Failed to add note loader: ${err.message}`,
|
||||||
|
messageSource: 'embedding'
|
||||||
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
evaluateTaskWorkload: { workload: contentBytes.length }
|
evaluateTaskWorkload: { workload: contentBytes.length }
|
||||||
@@ -430,10 +477,10 @@ class KnowledgeService {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
public add = (_: Electron.IpcMainInvokeEvent, options: KnowledgeBaseAddItemOptions): Promise<LoaderReturn> => {
|
public add = async (_: Electron.IpcMainInvokeEvent, options: KnowledgeBaseAddItemOptions): Promise<LoaderReturn> => {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const { base, item, forceReload = false } = options
|
const { base, item, forceReload = false, userId = '' } = options
|
||||||
const optionsNonNullableAttribute = { base, item, forceReload }
|
const optionsNonNullableAttribute = { base, item, forceReload, userId }
|
||||||
this.getRagApplication(base)
|
this.getRagApplication(base)
|
||||||
.then((ragApplication) => {
|
.then((ragApplication) => {
|
||||||
const task = (() => {
|
const task = (() => {
|
||||||
@@ -459,12 +506,20 @@ class KnowledgeService {
|
|||||||
})
|
})
|
||||||
this.processingQueueHandle()
|
this.processingQueueHandle()
|
||||||
} else {
|
} else {
|
||||||
resolve(KnowledgeService.ERROR_LOADER_RETURN)
|
resolve({
|
||||||
|
...KnowledgeService.ERROR_LOADER_RETURN,
|
||||||
|
message: 'Unsupported item type',
|
||||||
|
messageSource: 'embedding'
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
Logger.error(err)
|
Logger.error(err)
|
||||||
resolve(KnowledgeService.ERROR_LOADER_RETURN)
|
resolve({
|
||||||
|
...KnowledgeService.ERROR_LOADER_RETURN,
|
||||||
|
message: `Failed to add item: ${err.message}`,
|
||||||
|
messageSource: 'embedding'
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -497,6 +552,69 @@ class KnowledgeService {
|
|||||||
}
|
}
|
||||||
return await new Reranker(base).rerank(search, results)
|
return await new Reranker(base).rerank(search, results)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getStorageDir = (): string => {
|
||||||
|
return this.storageDir
|
||||||
|
}
|
||||||
|
|
||||||
|
private preprocessing = async (
|
||||||
|
file: FileMetadata,
|
||||||
|
base: KnowledgeBaseParams,
|
||||||
|
item: KnowledgeItem,
|
||||||
|
userId: string
|
||||||
|
): Promise<FileMetadata> => {
|
||||||
|
let fileToProcess: FileMetadata = file
|
||||||
|
if (base.preprocessOrOcrProvider && file.ext.toLowerCase() === '.pdf') {
|
||||||
|
try {
|
||||||
|
let provider: PreprocessProvider | OcrProvider
|
||||||
|
if (base.preprocessOrOcrProvider.type === 'preprocess') {
|
||||||
|
provider = new PreprocessProvider(base.preprocessOrOcrProvider.provider, userId)
|
||||||
|
} else {
|
||||||
|
provider = new OcrProvider(base.preprocessOrOcrProvider.provider)
|
||||||
|
}
|
||||||
|
// 首先检查文件是否已经被预处理过
|
||||||
|
const alreadyProcessed = await provider.checkIfAlreadyProcessed(file)
|
||||||
|
if (alreadyProcessed) {
|
||||||
|
Logger.info(`File already preprocess processed, using cached result: ${file.path}`)
|
||||||
|
return alreadyProcessed
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行预处理
|
||||||
|
Logger.info(`Starting preprocess processing for scanned PDF: ${file.path}`)
|
||||||
|
const { processedFile, quota } = await provider.parseFile(item.id, file)
|
||||||
|
fileToProcess = processedFile
|
||||||
|
const mainWindow = windowService.getMainWindow()
|
||||||
|
mainWindow?.webContents.send('file-preprocess-finished', {
|
||||||
|
itemId: item.id,
|
||||||
|
quota: quota
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
Logger.error(`Preprocess processing failed: ${err}`)
|
||||||
|
// 如果预处理失败,使用原始文件
|
||||||
|
// fileToProcess = file
|
||||||
|
throw new Error(`Preprocess processing failed: ${err}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fileToProcess
|
||||||
|
}
|
||||||
|
|
||||||
|
public checkQuota = async (
|
||||||
|
_: Electron.IpcMainInvokeEvent,
|
||||||
|
base: KnowledgeBaseParams,
|
||||||
|
userId: string
|
||||||
|
): Promise<number> => {
|
||||||
|
try {
|
||||||
|
if (base.preprocessOrOcrProvider && base.preprocessOrOcrProvider.type === 'preprocess') {
|
||||||
|
const provider = new PreprocessProvider(base.preprocessOrOcrProvider.provider, userId)
|
||||||
|
return await provider.checkQuota()
|
||||||
|
}
|
||||||
|
throw new Error('No preprocess provider configured')
|
||||||
|
} catch (err) {
|
||||||
|
Logger.error(`Failed to check quota: ${err}`)
|
||||||
|
throw new Error(`Failed to check quota: ${err}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new KnowledgeService()
|
export default new KnowledgeService()
|
||||||
|
|||||||
33
src/main/services/MistralClientManager.ts
Normal file
33
src/main/services/MistralClientManager.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { Mistral } from '@mistralai/mistralai'
|
||||||
|
import { Provider } from '@types'
|
||||||
|
|
||||||
|
export class MistralClientManager {
|
||||||
|
private static instance: MistralClientManager
|
||||||
|
private client: Mistral | null = null
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||||
|
private constructor() {}
|
||||||
|
|
||||||
|
public static getInstance(): MistralClientManager {
|
||||||
|
if (!MistralClientManager.instance) {
|
||||||
|
MistralClientManager.instance = new MistralClientManager()
|
||||||
|
}
|
||||||
|
return MistralClientManager.instance
|
||||||
|
}
|
||||||
|
|
||||||
|
public initializeClient(provider: Provider): void {
|
||||||
|
if (!this.client) {
|
||||||
|
this.client = new Mistral({
|
||||||
|
apiKey: provider.apiKey,
|
||||||
|
serverURL: provider.apiHost
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getClient(): Mistral {
|
||||||
|
if (!this.client) {
|
||||||
|
throw new Error('Mistral client not initialized. Call initializeClient first.')
|
||||||
|
}
|
||||||
|
return this.client
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { SELECTION_FINETUNED_LIST, SELECTION_PREDEFINED_BLACKLIST } from '@main/configs/SelectionConfig'
|
import { SELECTION_FINETUNED_LIST, SELECTION_PREDEFINED_BLACKLIST } from '@main/configs/SelectionConfig'
|
||||||
import { isDev, isWin } from '@main/constant'
|
import { isDev, isMac, isWin } from '@main/constant'
|
||||||
import { IpcChannel } from '@shared/IpcChannel'
|
import { IpcChannel } from '@shared/IpcChannel'
|
||||||
import { BrowserWindow, ipcMain, screen } from 'electron'
|
import { BrowserWindow, ipcMain, screen, systemPreferences } from 'electron'
|
||||||
import Logger from 'electron-log'
|
import Logger from 'electron-log'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import type {
|
import type {
|
||||||
@@ -16,9 +16,12 @@ import type { ActionItem } from '../../renderer/src/types/selectionTypes'
|
|||||||
import { ConfigKeys, configManager } from './ConfigManager'
|
import { ConfigKeys, configManager } from './ConfigManager'
|
||||||
import storeSyncService from './StoreSyncService'
|
import storeSyncService from './StoreSyncService'
|
||||||
|
|
||||||
|
const isSupportedOS = isWin || isMac
|
||||||
|
|
||||||
let SelectionHook: SelectionHookConstructor | null = null
|
let SelectionHook: SelectionHookConstructor | null = null
|
||||||
try {
|
try {
|
||||||
if (isWin) {
|
//since selection-hook v1.0.0, it supports macOS
|
||||||
|
if (isSupportedOS) {
|
||||||
SelectionHook = require('selection-hook')
|
SelectionHook = require('selection-hook')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -118,7 +121,7 @@ export class SelectionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static getInstance(): SelectionService | null {
|
public static getInstance(): SelectionService | null {
|
||||||
if (!isWin) return null
|
if (!isSupportedOS) return null
|
||||||
|
|
||||||
if (!SelectionService.instance) {
|
if (!SelectionService.instance) {
|
||||||
SelectionService.instance = new SelectionService()
|
SelectionService.instance = new SelectionService()
|
||||||
@@ -213,6 +216,8 @@ export class SelectionService {
|
|||||||
blacklist: SelectionHook!.FilterMode.EXCLUDE_LIST
|
blacklist: SelectionHook!.FilterMode.EXCLUDE_LIST
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const predefinedBlacklist = isWin ? SELECTION_PREDEFINED_BLACKLIST.WINDOWS : SELECTION_PREDEFINED_BLACKLIST.MAC
|
||||||
|
|
||||||
let combinedList: string[] = list
|
let combinedList: string[] = list
|
||||||
let combinedMode = mode
|
let combinedMode = mode
|
||||||
|
|
||||||
@@ -221,7 +226,7 @@ export class SelectionService {
|
|||||||
switch (mode) {
|
switch (mode) {
|
||||||
case 'blacklist':
|
case 'blacklist':
|
||||||
//combine the predefined blacklist with the user-defined blacklist
|
//combine the predefined blacklist with the user-defined blacklist
|
||||||
combinedList = [...new Set([...list, ...SELECTION_PREDEFINED_BLACKLIST.WINDOWS])]
|
combinedList = [...new Set([...list, ...predefinedBlacklist])]
|
||||||
break
|
break
|
||||||
case 'whitelist':
|
case 'whitelist':
|
||||||
combinedList = [...list]
|
combinedList = [...list]
|
||||||
@@ -229,7 +234,7 @@ export class SelectionService {
|
|||||||
case 'default':
|
case 'default':
|
||||||
default:
|
default:
|
||||||
//use the predefined blacklist as the default filter list
|
//use the predefined blacklist as the default filter list
|
||||||
combinedList = [...SELECTION_PREDEFINED_BLACKLIST.WINDOWS]
|
combinedList = [...predefinedBlacklist]
|
||||||
combinedMode = 'blacklist'
|
combinedMode = 'blacklist'
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -243,14 +248,21 @@ export class SelectionService {
|
|||||||
private setHookFineTunedList() {
|
private setHookFineTunedList() {
|
||||||
if (!this.selectionHook) return
|
if (!this.selectionHook) return
|
||||||
|
|
||||||
|
const excludeClipboardCursorDetectList = isWin
|
||||||
|
? SELECTION_FINETUNED_LIST.EXCLUDE_CLIPBOARD_CURSOR_DETECT.WINDOWS
|
||||||
|
: SELECTION_FINETUNED_LIST.EXCLUDE_CLIPBOARD_CURSOR_DETECT.MAC
|
||||||
|
const includeClipboardDelayReadList = isWin
|
||||||
|
? SELECTION_FINETUNED_LIST.INCLUDE_CLIPBOARD_DELAY_READ.WINDOWS
|
||||||
|
: SELECTION_FINETUNED_LIST.INCLUDE_CLIPBOARD_DELAY_READ.MAC
|
||||||
|
|
||||||
this.selectionHook.setFineTunedList(
|
this.selectionHook.setFineTunedList(
|
||||||
SelectionHook!.FineTunedListType.EXCLUDE_CLIPBOARD_CURSOR_DETECT,
|
SelectionHook!.FineTunedListType.EXCLUDE_CLIPBOARD_CURSOR_DETECT,
|
||||||
SELECTION_FINETUNED_LIST.EXCLUDE_CLIPBOARD_CURSOR_DETECT.WINDOWS
|
excludeClipboardCursorDetectList
|
||||||
)
|
)
|
||||||
|
|
||||||
this.selectionHook.setFineTunedList(
|
this.selectionHook.setFineTunedList(
|
||||||
SelectionHook!.FineTunedListType.INCLUDE_CLIPBOARD_DELAY_READ,
|
SelectionHook!.FineTunedListType.INCLUDE_CLIPBOARD_DELAY_READ,
|
||||||
SELECTION_FINETUNED_LIST.INCLUDE_CLIPBOARD_DELAY_READ.WINDOWS
|
includeClipboardDelayReadList
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,11 +271,28 @@ export class SelectionService {
|
|||||||
* @returns {boolean} Success status of service start
|
* @returns {boolean} Success status of service start
|
||||||
*/
|
*/
|
||||||
public start(): boolean {
|
public start(): boolean {
|
||||||
if (!this.selectionHook || this.started) {
|
if (!this.selectionHook) {
|
||||||
this.logError(new Error('SelectionService start(): instance is null or already started'))
|
this.logError(new Error('SelectionService start(): instance is null'))
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.started) {
|
||||||
|
this.logError(new Error('SelectionService start(): already started'))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
//On macOS, we need to check if the process is trusted
|
||||||
|
if (isMac) {
|
||||||
|
if (!systemPreferences.isTrustedAccessibilityClient(false)) {
|
||||||
|
this.logError(
|
||||||
|
new Error(
|
||||||
|
'SelectionSerice not started: process is not trusted on macOS, please turn on the Accessibility permission'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
//make sure the toolbar window is ready
|
//make sure the toolbar window is ready
|
||||||
this.createToolbarWindow()
|
this.createToolbarWindow()
|
||||||
@@ -306,6 +335,7 @@ export class SelectionService {
|
|||||||
if (!this.selectionHook) return false
|
if (!this.selectionHook) return false
|
||||||
|
|
||||||
this.selectionHook.stop()
|
this.selectionHook.stop()
|
||||||
|
|
||||||
this.selectionHook.cleanup() //already remove all listeners
|
this.selectionHook.cleanup() //already remove all listeners
|
||||||
|
|
||||||
//reset the listener states
|
//reset the listener states
|
||||||
@@ -316,6 +346,7 @@ export class SelectionService {
|
|||||||
this.toolbarWindow.close()
|
this.toolbarWindow.close()
|
||||||
this.toolbarWindow = null
|
this.toolbarWindow = null
|
||||||
}
|
}
|
||||||
|
|
||||||
this.closePreloadedActionWindows()
|
this.closePreloadedActionWindows()
|
||||||
|
|
||||||
this.started = false
|
this.started = false
|
||||||
@@ -366,21 +397,29 @@ export class SelectionService {
|
|||||||
this.toolbarWindow = new BrowserWindow({
|
this.toolbarWindow = new BrowserWindow({
|
||||||
width: toolbarWidth,
|
width: toolbarWidth,
|
||||||
height: toolbarHeight,
|
height: toolbarHeight,
|
||||||
|
show: false,
|
||||||
frame: false,
|
frame: false,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
alwaysOnTop: true,
|
alwaysOnTop: true,
|
||||||
skipTaskbar: true,
|
skipTaskbar: true,
|
||||||
|
autoHideMenuBar: true,
|
||||||
resizable: false,
|
resizable: false,
|
||||||
minimizable: false,
|
minimizable: false,
|
||||||
maximizable: false,
|
maximizable: false,
|
||||||
|
fullscreenable: false, // [macOS] must be false
|
||||||
movable: true,
|
movable: true,
|
||||||
focusable: false,
|
|
||||||
hasShadow: false,
|
hasShadow: false,
|
||||||
thickFrame: false,
|
thickFrame: false,
|
||||||
roundedCorners: true,
|
roundedCorners: true,
|
||||||
backgroundMaterial: 'none',
|
backgroundMaterial: 'none',
|
||||||
type: 'toolbar',
|
|
||||||
show: false,
|
// Platform specific settings
|
||||||
|
// [macOS] DO NOT set type to 'panel', it will not work because it conflicts with other settings
|
||||||
|
// [macOS] DO NOT set focusable to false, it will make other windows bring to front together
|
||||||
|
...(isWin ? { type: 'toolbar', focusable: false } : {}),
|
||||||
|
hiddenInMissionControl: true, // [macOS only]
|
||||||
|
acceptFirstMouse: true, // [macOS only]
|
||||||
|
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: join(__dirname, '../preload/index.js'),
|
preload: join(__dirname, '../preload/index.js'),
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
@@ -406,6 +445,13 @@ export class SelectionService {
|
|||||||
// Add show/hide event listeners
|
// Add show/hide event listeners
|
||||||
this.toolbarWindow.on('show', () => {
|
this.toolbarWindow.on('show', () => {
|
||||||
this.toolbarWindow?.webContents.send(IpcChannel.Selection_ToolbarVisibilityChange, true)
|
this.toolbarWindow?.webContents.send(IpcChannel.Selection_ToolbarVisibilityChange, true)
|
||||||
|
|
||||||
|
// [macOS] force the toolbar window to be visible on current desktop
|
||||||
|
// but it will make docker icon flash. And we found that it's not necessary now.
|
||||||
|
// will remove after testing
|
||||||
|
// if (isMac) {
|
||||||
|
// this.toolbarWindow!.setVisibleOnAllWorkspaces(false)
|
||||||
|
// }
|
||||||
})
|
})
|
||||||
|
|
||||||
this.toolbarWindow.on('hide', () => {
|
this.toolbarWindow.on('hide', () => {
|
||||||
@@ -460,11 +506,22 @@ export class SelectionService {
|
|||||||
//set the window to always on top (highest level)
|
//set the window to always on top (highest level)
|
||||||
//should set every time the window is shown
|
//should set every time the window is shown
|
||||||
this.toolbarWindow!.setAlwaysOnTop(true, 'screen-saver')
|
this.toolbarWindow!.setAlwaysOnTop(true, 'screen-saver')
|
||||||
this.toolbarWindow!.show()
|
|
||||||
|
// [macOS] force the toolbar window to be visible on current desktop
|
||||||
|
// but it will make docker icon flash. And we found that it's not necessary now.
|
||||||
|
// will remove after testing
|
||||||
|
// if (isMac) {
|
||||||
|
// this.toolbarWindow!.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true })
|
||||||
|
// }
|
||||||
|
|
||||||
|
// [macOS] MUST use `showInactive()` to prevent other windows bring to front together
|
||||||
|
// [Windows] is OK for both `show()` and `showInactive()` because of `focusable: false`
|
||||||
|
this.toolbarWindow!.showInactive()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* In Windows 10, setOpacity(1) will make the window completely transparent
|
* [Windows]
|
||||||
* It's a strange behavior, so we don't use it for compatibility
|
* In Windows 10, setOpacity(1) will make the window completely transparent
|
||||||
|
* It's a strange behavior, so we don't use it for compatibility
|
||||||
*/
|
*/
|
||||||
// this.toolbarWindow!.setOpacity(1)
|
// this.toolbarWindow!.setOpacity(1)
|
||||||
|
|
||||||
@@ -520,71 +577,71 @@ export class SelectionService {
|
|||||||
/**
|
/**
|
||||||
* Calculate optimal toolbar position based on selection context
|
* Calculate optimal toolbar position based on selection context
|
||||||
* Ensures toolbar stays within screen boundaries and follows selection direction
|
* Ensures toolbar stays within screen boundaries and follows selection direction
|
||||||
* @param point Reference point for positioning, must be INTEGER
|
* @param refPoint Reference point for positioning, must be INTEGER
|
||||||
* @param orientation Preferred position relative to reference point
|
* @param orientation Preferred position relative to reference point
|
||||||
* @returns Calculated screen coordinates for toolbar, INTEGER
|
* @returns Calculated screen coordinates for toolbar, INTEGER
|
||||||
*/
|
*/
|
||||||
private calculateToolbarPosition(point: Point, orientation: RelativeOrientation): Point {
|
private calculateToolbarPosition(refPoint: Point, orientation: RelativeOrientation): Point {
|
||||||
// Calculate initial position based on the specified anchor
|
// Calculate initial position based on the specified anchor
|
||||||
let posX: number, posY: number
|
const posPoint: Point = { x: 0, y: 0 }
|
||||||
|
|
||||||
const { toolbarWidth, toolbarHeight } = this.getToolbarRealSize()
|
const { toolbarWidth, toolbarHeight } = this.getToolbarRealSize()
|
||||||
|
|
||||||
switch (orientation) {
|
switch (orientation) {
|
||||||
case 'topLeft':
|
case 'topLeft':
|
||||||
posX = point.x - toolbarWidth
|
posPoint.x = refPoint.x - toolbarWidth
|
||||||
posY = point.y - toolbarHeight
|
posPoint.y = refPoint.y - toolbarHeight
|
||||||
break
|
break
|
||||||
case 'topRight':
|
case 'topRight':
|
||||||
posX = point.x
|
posPoint.x = refPoint.x
|
||||||
posY = point.y - toolbarHeight
|
posPoint.y = refPoint.y - toolbarHeight
|
||||||
break
|
break
|
||||||
case 'topMiddle':
|
case 'topMiddle':
|
||||||
posX = point.x - toolbarWidth / 2
|
posPoint.x = refPoint.x - toolbarWidth / 2
|
||||||
posY = point.y - toolbarHeight
|
posPoint.y = refPoint.y - toolbarHeight
|
||||||
break
|
break
|
||||||
case 'bottomLeft':
|
case 'bottomLeft':
|
||||||
posX = point.x - toolbarWidth
|
posPoint.x = refPoint.x - toolbarWidth
|
||||||
posY = point.y
|
posPoint.y = refPoint.y
|
||||||
break
|
break
|
||||||
case 'bottomRight':
|
case 'bottomRight':
|
||||||
posX = point.x
|
posPoint.x = refPoint.x
|
||||||
posY = point.y
|
posPoint.y = refPoint.y
|
||||||
break
|
break
|
||||||
case 'bottomMiddle':
|
case 'bottomMiddle':
|
||||||
posX = point.x - toolbarWidth / 2
|
posPoint.x = refPoint.x - toolbarWidth / 2
|
||||||
posY = point.y
|
posPoint.y = refPoint.y
|
||||||
break
|
break
|
||||||
case 'middleLeft':
|
case 'middleLeft':
|
||||||
posX = point.x - toolbarWidth
|
posPoint.x = refPoint.x - toolbarWidth
|
||||||
posY = point.y - toolbarHeight / 2
|
posPoint.y = refPoint.y - toolbarHeight / 2
|
||||||
break
|
break
|
||||||
case 'middleRight':
|
case 'middleRight':
|
||||||
posX = point.x
|
posPoint.x = refPoint.x
|
||||||
posY = point.y - toolbarHeight / 2
|
posPoint.y = refPoint.y - toolbarHeight / 2
|
||||||
break
|
break
|
||||||
case 'center':
|
case 'center':
|
||||||
posX = point.x - toolbarWidth / 2
|
posPoint.x = refPoint.x - toolbarWidth / 2
|
||||||
posY = point.y - toolbarHeight / 2
|
posPoint.y = refPoint.y - toolbarHeight / 2
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
// Default to 'topMiddle' if invalid position
|
// Default to 'topMiddle' if invalid position
|
||||||
posX = point.x - toolbarWidth / 2
|
posPoint.x = refPoint.x - toolbarWidth / 2
|
||||||
posY = point.y - toolbarHeight / 2
|
posPoint.y = refPoint.y - toolbarHeight / 2
|
||||||
}
|
}
|
||||||
|
|
||||||
//use original point to get the display
|
//use original point to get the display
|
||||||
const display = screen.getDisplayNearestPoint({ x: point.x, y: point.y })
|
const display = screen.getDisplayNearestPoint(refPoint)
|
||||||
|
|
||||||
// Ensure toolbar stays within screen boundaries
|
// Ensure toolbar stays within screen boundaries
|
||||||
posX = Math.round(
|
posPoint.x = Math.round(
|
||||||
Math.max(display.workArea.x, Math.min(posX, display.workArea.x + display.workArea.width - toolbarWidth))
|
Math.max(display.workArea.x, Math.min(posPoint.x, display.workArea.x + display.workArea.width - toolbarWidth))
|
||||||
)
|
)
|
||||||
posY = Math.round(
|
posPoint.y = Math.round(
|
||||||
Math.max(display.workArea.y, Math.min(posY, display.workArea.y + display.workArea.height - toolbarHeight))
|
Math.max(display.workArea.y, Math.min(posPoint.y, display.workArea.y + display.workArea.height - toolbarHeight))
|
||||||
)
|
)
|
||||||
|
|
||||||
return { x: posX, y: posY }
|
return posPoint
|
||||||
}
|
}
|
||||||
|
|
||||||
private isSamePoint(point1: Point, point2: Point): boolean {
|
private isSamePoint(point1: Point, point2: Point): boolean {
|
||||||
@@ -773,8 +830,11 @@ export class SelectionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!isLogical) {
|
if (!isLogical) {
|
||||||
|
// [macOS] don't need to convert by screenToDipPoint
|
||||||
|
if (!isMac) {
|
||||||
|
refPoint = screen.screenToDipPoint(refPoint)
|
||||||
|
}
|
||||||
//screenToDipPoint can be float, so we need to round it
|
//screenToDipPoint can be float, so we need to round it
|
||||||
refPoint = screen.screenToDipPoint(refPoint)
|
|
||||||
refPoint = { x: Math.round(refPoint.x), y: Math.round(refPoint.y) }
|
refPoint = { x: Math.round(refPoint.x), y: Math.round(refPoint.y) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -832,8 +892,8 @@ export class SelectionService {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
//data point is physical coordinates, convert to logical coordinates
|
//data point is physical coordinates, convert to logical coordinates(only for windows/linux)
|
||||||
const mousePoint = screen.screenToDipPoint({ x: data.x, y: data.y })
|
const mousePoint = isMac ? { x: data.x, y: data.y } : screen.screenToDipPoint({ x: data.x, y: data.y })
|
||||||
|
|
||||||
const bounds = this.toolbarWindow!.getBounds()
|
const bounds = this.toolbarWindow!.getBounds()
|
||||||
|
|
||||||
@@ -966,7 +1026,8 @@ export class SelectionService {
|
|||||||
frame: false,
|
frame: false,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
titleBarStyle: 'hidden',
|
titleBarStyle: 'hidden', // [macOS]
|
||||||
|
trafficLightPosition: { x: 12, y: 9 }, // [macOS]
|
||||||
hasShadow: false,
|
hasShadow: false,
|
||||||
thickFrame: false,
|
thickFrame: false,
|
||||||
show: false,
|
show: false,
|
||||||
@@ -1096,6 +1157,7 @@ export class SelectionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
actionWindow.show()
|
actionWindow.show()
|
||||||
|
// actionWindow.focus()
|
||||||
this.hideToolbar()
|
this.hideToolbar()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1214,7 +1276,7 @@ export class SelectionService {
|
|||||||
selectionService?.hideToolbar()
|
selectionService?.hideToolbar()
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle(IpcChannel.Selection_WriteToClipboard, (_, text: string) => {
|
ipcMain.handle(IpcChannel.Selection_WriteToClipboard, (_, text: string): boolean => {
|
||||||
return selectionService?.writeToClipboard(text) ?? false
|
return selectionService?.writeToClipboard(text) ?? false
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1291,7 +1353,7 @@ export class SelectionService {
|
|||||||
* @returns {boolean} Success status of initialization
|
* @returns {boolean} Success status of initialization
|
||||||
*/
|
*/
|
||||||
export function initSelectionService(): boolean {
|
export function initSelectionService(): boolean {
|
||||||
if (!isWin) return false
|
if (!isSupportedOS) return false
|
||||||
|
|
||||||
configManager.subscribe(ConfigKeys.SelectionAssistantEnabled, (enabled: boolean) => {
|
configManager.subscribe(ConfigKeys.SelectionAssistantEnabled, (enabled: boolean) => {
|
||||||
//avoid closure
|
//avoid closure
|
||||||
|
|||||||
@@ -84,10 +84,8 @@ export class TrayService {
|
|||||||
label: trayLocale.show_mini_window,
|
label: trayLocale.show_mini_window,
|
||||||
click: () => windowService.showMiniWindow()
|
click: () => windowService.showMiniWindow()
|
||||||
},
|
},
|
||||||
isWin && {
|
(isWin || isMac) && {
|
||||||
label: selectionLocale.name + (selectionAssistantEnabled ? ' - On' : ' - Off'),
|
label: selectionLocale.name + (selectionAssistantEnabled ? ' - On' : ' - Off'),
|
||||||
// type: 'checkbox',
|
|
||||||
// checked: selectionAssistantEnabled,
|
|
||||||
click: () => {
|
click: () => {
|
||||||
if (selectionService) {
|
if (selectionService) {
|
||||||
selectionService.toggleEnabled()
|
selectionService.toggleEnabled()
|
||||||
|
|||||||
704
src/main/services/memory/MemoryService.ts
Normal file
704
src/main/services/memory/MemoryService.ts
Normal file
@@ -0,0 +1,704 @@
|
|||||||
|
import { Client, createClient } from '@libsql/client'
|
||||||
|
import Embeddings from '@main/embeddings/Embeddings'
|
||||||
|
import type {
|
||||||
|
AddMemoryOptions,
|
||||||
|
AssistantMessage,
|
||||||
|
MemoryConfig,
|
||||||
|
MemoryHistoryItem,
|
||||||
|
MemoryItem,
|
||||||
|
MemoryListOptions,
|
||||||
|
MemorySearchOptions
|
||||||
|
} from '@types'
|
||||||
|
import crypto from 'crypto'
|
||||||
|
import { app } from 'electron'
|
||||||
|
import logger from 'electron-log'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
import { MemoryQueries } from './queries'
|
||||||
|
|
||||||
|
export interface EmbeddingOptions {
|
||||||
|
model: string
|
||||||
|
provider: string
|
||||||
|
apiKey: string
|
||||||
|
apiVersion?: string
|
||||||
|
baseURL: string
|
||||||
|
dimensions?: number
|
||||||
|
batchSize?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VectorSearchOptions {
|
||||||
|
limit?: number
|
||||||
|
threshold?: number
|
||||||
|
userId?: string
|
||||||
|
agentId?: string
|
||||||
|
filters?: Record<string, any>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchResult {
|
||||||
|
memories: MemoryItem[]
|
||||||
|
count: number
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MemoryService {
|
||||||
|
private static instance: MemoryService | null = null
|
||||||
|
private db: Client | null = null
|
||||||
|
private isInitialized = false
|
||||||
|
private embeddings: Embeddings | null = null
|
||||||
|
private config: MemoryConfig | null = null
|
||||||
|
|
||||||
|
private constructor() {
|
||||||
|
// Private constructor to enforce singleton pattern
|
||||||
|
}
|
||||||
|
|
||||||
|
public static getInstance(): MemoryService {
|
||||||
|
if (!MemoryService.instance) {
|
||||||
|
MemoryService.instance = new MemoryService()
|
||||||
|
}
|
||||||
|
return MemoryService.instance
|
||||||
|
}
|
||||||
|
|
||||||
|
public static reload(): MemoryService {
|
||||||
|
if (MemoryService.instance) {
|
||||||
|
MemoryService.instance.close()
|
||||||
|
}
|
||||||
|
MemoryService.instance = new MemoryService()
|
||||||
|
return MemoryService.instance
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the database connection and create tables
|
||||||
|
*/
|
||||||
|
private async init(): Promise<void> {
|
||||||
|
if (this.isInitialized && this.db) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const userDataPath = app.getPath('userData')
|
||||||
|
const dbPath = path.join(userDataPath, 'memories.db')
|
||||||
|
|
||||||
|
this.db = createClient({
|
||||||
|
url: `file:${dbPath}`,
|
||||||
|
intMode: 'number'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create tables
|
||||||
|
await this.createTables()
|
||||||
|
this.isInitialized = true
|
||||||
|
logger.info('Memory database initialized successfully')
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to initialize memory database:', error)
|
||||||
|
throw new Error(
|
||||||
|
`Memory database initialization failed: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createTables(): Promise<void> {
|
||||||
|
if (!this.db) throw new Error('Database not initialized')
|
||||||
|
|
||||||
|
// Create memories table with native vector support
|
||||||
|
await this.db.execute(MemoryQueries.createTables.memories)
|
||||||
|
|
||||||
|
// Create memory history table
|
||||||
|
await this.db.execute(MemoryQueries.createTables.memoryHistory)
|
||||||
|
|
||||||
|
// Create indexes
|
||||||
|
await this.db.execute(MemoryQueries.createIndexes.userId)
|
||||||
|
await this.db.execute(MemoryQueries.createIndexes.agentId)
|
||||||
|
await this.db.execute(MemoryQueries.createIndexes.createdAt)
|
||||||
|
await this.db.execute(MemoryQueries.createIndexes.hash)
|
||||||
|
await this.db.execute(MemoryQueries.createIndexes.memoryHistory)
|
||||||
|
|
||||||
|
// Create vector index for similarity search
|
||||||
|
try {
|
||||||
|
await this.db.execute(MemoryQueries.createIndexes.vector)
|
||||||
|
} catch (error) {
|
||||||
|
// Vector index might not be supported in all versions
|
||||||
|
logger.warn('Failed to create vector index, falling back to non-indexed search:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add new memories from messages
|
||||||
|
*/
|
||||||
|
public async add(messages: string | AssistantMessage[], options: AddMemoryOptions): Promise<SearchResult> {
|
||||||
|
await this.init()
|
||||||
|
if (!this.db) throw new Error('Database not initialized')
|
||||||
|
|
||||||
|
const { userId, agentId, runId, metadata } = options
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Convert messages to memory strings
|
||||||
|
const memoryStrings = Array.isArray(messages)
|
||||||
|
? messages.map((m) => (typeof m === 'string' ? m : m.content))
|
||||||
|
: [messages]
|
||||||
|
const addedMemories: MemoryItem[] = []
|
||||||
|
|
||||||
|
for (const memory of memoryStrings) {
|
||||||
|
const trimmedMemory = memory.trim()
|
||||||
|
if (!trimmedMemory) continue
|
||||||
|
|
||||||
|
// Generate hash for deduplication
|
||||||
|
const hash = crypto.createHash('sha256').update(trimmedMemory).digest('hex')
|
||||||
|
|
||||||
|
// Check if memory already exists
|
||||||
|
const existing = await this.db.execute({
|
||||||
|
sql: MemoryQueries.memory.checkExists,
|
||||||
|
args: [hash]
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existing.rows.length > 0) {
|
||||||
|
logger.info(`Memory already exists with hash: ${hash}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate embedding if model is configured
|
||||||
|
let embedding: number[] | null = null
|
||||||
|
if (this.config?.embedderModel) {
|
||||||
|
try {
|
||||||
|
embedding = await this.generateEmbedding(trimmedMemory)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to generate embedding:', error)
|
||||||
|
// Continue without embedding
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert new memory
|
||||||
|
const id = crypto.randomUUID()
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
|
||||||
|
await this.db.execute({
|
||||||
|
sql: MemoryQueries.memory.insert,
|
||||||
|
args: [
|
||||||
|
id,
|
||||||
|
trimmedMemory,
|
||||||
|
hash,
|
||||||
|
embedding ? this.embeddingToVector(embedding) : null,
|
||||||
|
metadata ? JSON.stringify(metadata) : null,
|
||||||
|
userId || null,
|
||||||
|
agentId || null,
|
||||||
|
runId || null,
|
||||||
|
now,
|
||||||
|
now
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add to history
|
||||||
|
await this.addHistory(id, null, trimmedMemory, 'ADD')
|
||||||
|
|
||||||
|
addedMemories.push({
|
||||||
|
id,
|
||||||
|
memory: trimmedMemory,
|
||||||
|
hash,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
metadata
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
memories: addedMemories,
|
||||||
|
count: addedMemories.length
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to add memories:', error)
|
||||||
|
return {
|
||||||
|
memories: [],
|
||||||
|
count: 0,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search memories using text or vector similarity
|
||||||
|
*/
|
||||||
|
public async search(query: string, options: MemorySearchOptions = {}): Promise<SearchResult> {
|
||||||
|
await this.init()
|
||||||
|
if (!this.db) throw new Error('Database not initialized')
|
||||||
|
|
||||||
|
const { limit = 10, userId, agentId, filters = {} } = options
|
||||||
|
|
||||||
|
try {
|
||||||
|
// If we have an embedder model configured, use vector search
|
||||||
|
if (this.config?.embedderModel) {
|
||||||
|
try {
|
||||||
|
const queryEmbedding = await this.generateEmbedding(query)
|
||||||
|
return await this.hybridSearch(query, queryEmbedding, { limit, userId, agentId, filters })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Vector search failed, falling back to text search:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to text search
|
||||||
|
const conditions: string[] = ['m.is_deleted = 0']
|
||||||
|
const params: any[] = []
|
||||||
|
|
||||||
|
// Add search conditions
|
||||||
|
conditions.push('(m.memory LIKE ? OR m.memory LIKE ?)')
|
||||||
|
params.push(`%${query}%`, `%${query.split(' ').join('%')}%`)
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
conditions.push('m.user_id = ?')
|
||||||
|
params.push(userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (agentId) {
|
||||||
|
conditions.push('m.agent_id = ?')
|
||||||
|
params.push(agentId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add custom filters
|
||||||
|
for (const [key, value] of Object.entries(filters)) {
|
||||||
|
if (value !== undefined && value !== null) {
|
||||||
|
conditions.push(`json_extract(m.metadata, '$.${key}') = ?`)
|
||||||
|
params.push(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = conditions.join(' AND ')
|
||||||
|
params.push(limit)
|
||||||
|
|
||||||
|
const result = await this.db.execute({
|
||||||
|
sql: `${MemoryQueries.memory.list} ${whereClause}
|
||||||
|
ORDER BY m.created_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
`,
|
||||||
|
args: params
|
||||||
|
})
|
||||||
|
|
||||||
|
const memories: MemoryItem[] = result.rows.map((row: any) => ({
|
||||||
|
id: row.id as string,
|
||||||
|
memory: row.memory as string,
|
||||||
|
hash: (row.hash as string) || undefined,
|
||||||
|
metadata: row.metadata ? JSON.parse(row.metadata as string) : undefined,
|
||||||
|
createdAt: row.created_at as string,
|
||||||
|
updatedAt: row.updated_at as string
|
||||||
|
}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
memories,
|
||||||
|
count: memories.length
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Search failed:', error)
|
||||||
|
return {
|
||||||
|
memories: [],
|
||||||
|
count: 0,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all memories with optional filters
|
||||||
|
*/
|
||||||
|
public async list(options: MemoryListOptions = {}): Promise<SearchResult> {
|
||||||
|
await this.init()
|
||||||
|
if (!this.db) throw new Error('Database not initialized')
|
||||||
|
|
||||||
|
const { userId, agentId, limit = 100, offset = 0 } = options
|
||||||
|
|
||||||
|
try {
|
||||||
|
const conditions: string[] = ['m.is_deleted = 0']
|
||||||
|
const params: any[] = []
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
conditions.push('m.user_id = ?')
|
||||||
|
params.push(userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (agentId) {
|
||||||
|
conditions.push('m.agent_id = ?')
|
||||||
|
params.push(agentId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = conditions.join(' AND ')
|
||||||
|
|
||||||
|
// Get total count
|
||||||
|
const countResult = await this.db.execute({
|
||||||
|
sql: `${MemoryQueries.memory.count} ${whereClause}`,
|
||||||
|
args: params
|
||||||
|
})
|
||||||
|
const totalCount = (countResult.rows[0] as any).total as number
|
||||||
|
|
||||||
|
// Get paginated results
|
||||||
|
params.push(limit, offset)
|
||||||
|
const result = await this.db.execute({
|
||||||
|
sql: `${MemoryQueries.memory.list} ${whereClause}
|
||||||
|
ORDER BY m.created_at DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
`,
|
||||||
|
args: params
|
||||||
|
})
|
||||||
|
|
||||||
|
const memories: MemoryItem[] = result.rows.map((row: any) => ({
|
||||||
|
id: row.id as string,
|
||||||
|
memory: row.memory as string,
|
||||||
|
hash: (row.hash as string) || undefined,
|
||||||
|
metadata: row.metadata ? JSON.parse(row.metadata as string) : undefined,
|
||||||
|
createdAt: row.created_at as string,
|
||||||
|
updatedAt: row.updated_at as string
|
||||||
|
}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
memories,
|
||||||
|
count: totalCount
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('List failed:', error)
|
||||||
|
return {
|
||||||
|
memories: [],
|
||||||
|
count: 0,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a memory (soft delete)
|
||||||
|
*/
|
||||||
|
public async delete(id: string): Promise<void> {
|
||||||
|
await this.init()
|
||||||
|
if (!this.db) throw new Error('Database not initialized')
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get current memory value for history
|
||||||
|
const current = await this.db.execute({
|
||||||
|
sql: MemoryQueries.memory.getForDelete,
|
||||||
|
args: [id]
|
||||||
|
})
|
||||||
|
|
||||||
|
if (current.rows.length === 0) {
|
||||||
|
throw new Error('Memory not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentMemory = (current.rows[0] as any).memory as string
|
||||||
|
|
||||||
|
// Soft delete
|
||||||
|
await this.db.execute({
|
||||||
|
sql: MemoryQueries.memory.softDelete,
|
||||||
|
args: [new Date().toISOString(), id]
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add to history
|
||||||
|
await this.addHistory(id, currentMemory, null, 'DELETE')
|
||||||
|
|
||||||
|
logger.info(`Memory deleted: ${id}`)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Delete failed:', error)
|
||||||
|
throw new Error(`Failed to delete memory: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a memory
|
||||||
|
*/
|
||||||
|
public async update(id: string, memory: string, metadata?: Record<string, any>): Promise<void> {
|
||||||
|
await this.init()
|
||||||
|
if (!this.db) throw new Error('Database not initialized')
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get current memory
|
||||||
|
const current = await this.db.execute({
|
||||||
|
sql: MemoryQueries.memory.getForUpdate,
|
||||||
|
args: [id]
|
||||||
|
})
|
||||||
|
|
||||||
|
if (current.rows.length === 0) {
|
||||||
|
throw new Error('Memory not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = current.rows[0] as any
|
||||||
|
const previousMemory = row.memory as string
|
||||||
|
const previousMetadata = row.metadata ? JSON.parse(row.metadata as string) : {}
|
||||||
|
|
||||||
|
// Generate new hash
|
||||||
|
const hash = crypto.createHash('sha256').update(memory.trim()).digest('hex')
|
||||||
|
|
||||||
|
// Generate new embedding if model is configured
|
||||||
|
let embedding: number[] | null = null
|
||||||
|
if (this.config?.embedderModel) {
|
||||||
|
try {
|
||||||
|
embedding = await this.generateEmbedding(memory)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to generate embedding for update:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge metadata
|
||||||
|
const mergedMetadata = { ...previousMetadata, ...metadata }
|
||||||
|
|
||||||
|
// Update memory
|
||||||
|
await this.db.execute({
|
||||||
|
sql: MemoryQueries.memory.update,
|
||||||
|
args: [
|
||||||
|
memory.trim(),
|
||||||
|
hash,
|
||||||
|
embedding ? this.embeddingToVector(embedding) : null,
|
||||||
|
JSON.stringify(mergedMetadata),
|
||||||
|
new Date().toISOString(),
|
||||||
|
id
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add to history
|
||||||
|
await this.addHistory(id, previousMemory, memory, 'UPDATE')
|
||||||
|
|
||||||
|
logger.info(`Memory updated: ${id}`)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Update failed:', error)
|
||||||
|
throw new Error(`Failed to update memory: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get memory history
|
||||||
|
*/
|
||||||
|
public async get(memoryId: string): Promise<MemoryHistoryItem[]> {
|
||||||
|
await this.init()
|
||||||
|
if (!this.db) throw new Error('Database not initialized')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.db.execute({
|
||||||
|
sql: MemoryQueries.history.getByMemoryId,
|
||||||
|
args: [memoryId]
|
||||||
|
})
|
||||||
|
|
||||||
|
return result.rows.map((row: any) => ({
|
||||||
|
id: row.id as number,
|
||||||
|
memoryId: row.memory_id as string,
|
||||||
|
previousValue: row.previous_value as string | undefined,
|
||||||
|
newValue: row.new_value as string,
|
||||||
|
action: row.action as 'ADD' | 'UPDATE' | 'DELETE',
|
||||||
|
createdAt: row.created_at as string,
|
||||||
|
updatedAt: row.updated_at as string,
|
||||||
|
isDeleted: row.is_deleted === 1
|
||||||
|
}))
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Get history failed:', error)
|
||||||
|
throw new Error(`Failed to get memory history: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a user and all their memories (hard delete)
|
||||||
|
*/
|
||||||
|
public async deleteUser(userId: string): Promise<void> {
|
||||||
|
await this.init()
|
||||||
|
if (!this.db) throw new Error('Database not initialized')
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
throw new Error('User ID is required')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userId === 'default-user') {
|
||||||
|
throw new Error('Cannot delete the default user')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get count of memories to be deleted
|
||||||
|
const countResult = await this.db.execute({
|
||||||
|
sql: `SELECT COUNT(*) as total FROM memories WHERE user_id = ?`,
|
||||||
|
args: [userId]
|
||||||
|
})
|
||||||
|
const totalCount = (countResult.rows[0] as any).total as number
|
||||||
|
|
||||||
|
// Delete history entries for this user's memories
|
||||||
|
await this.db.execute({
|
||||||
|
sql: `DELETE FROM memory_history WHERE memory_id IN (SELECT id FROM memories WHERE user_id = ?)`,
|
||||||
|
args: [userId]
|
||||||
|
})
|
||||||
|
|
||||||
|
// Delete all memories for this user (hard delete)
|
||||||
|
await this.db.execute({
|
||||||
|
sql: `DELETE FROM memories WHERE user_id = ?`,
|
||||||
|
args: [userId]
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(`Deleted user ${userId} and ${totalCount} memories`)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Delete user failed:', error)
|
||||||
|
throw new Error(`Failed to delete user: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get list of unique user IDs with their memory counts
|
||||||
|
*/
|
||||||
|
public async getUsersList(): Promise<{ userId: string; memoryCount: number; lastMemoryDate: string }[]> {
|
||||||
|
await this.init()
|
||||||
|
if (!this.db) throw new Error('Database not initialized')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.db.execute({
|
||||||
|
sql: MemoryQueries.users.getUniqueUsers,
|
||||||
|
args: []
|
||||||
|
})
|
||||||
|
|
||||||
|
return result.rows.map((row: any) => ({
|
||||||
|
userId: row.user_id as string,
|
||||||
|
memoryCount: row.memory_count as number,
|
||||||
|
lastMemoryDate: row.last_memory_date as string
|
||||||
|
}))
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Get users list failed:', error)
|
||||||
|
throw new Error(`Failed to get users list: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update configuration
|
||||||
|
*/
|
||||||
|
public setConfig(config: MemoryConfig): void {
|
||||||
|
this.config = config
|
||||||
|
// Reset embeddings instance when config changes
|
||||||
|
this.embeddings = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close database connection
|
||||||
|
*/
|
||||||
|
public async close(): Promise<void> {
|
||||||
|
if (this.db) {
|
||||||
|
await this.db.close()
|
||||||
|
this.db = null
|
||||||
|
this.isInitialized = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== EMBEDDING OPERATIONS (Previously EmbeddingService) ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate embedding for text
|
||||||
|
*/
|
||||||
|
private async generateEmbedding(text: string): Promise<number[]> {
|
||||||
|
if (!this.config?.embedderModel) {
|
||||||
|
throw new Error('Embedder model not configured')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Initialize embeddings instance if needed
|
||||||
|
if (!this.embeddings) {
|
||||||
|
const model = this.config.embedderModel
|
||||||
|
const provider = this.config.embedderProvider
|
||||||
|
|
||||||
|
if (!provider) {
|
||||||
|
throw new Error('Embedder provider not configured')
|
||||||
|
}
|
||||||
|
|
||||||
|
this.embeddings = new Embeddings({
|
||||||
|
id: model.id,
|
||||||
|
model: model.id,
|
||||||
|
provider: provider.id,
|
||||||
|
apiKey: provider.apiKey || '',
|
||||||
|
baseURL: provider.apiHost || '',
|
||||||
|
apiVersion: provider.apiVersion,
|
||||||
|
dimensions: this.config.embedderDimensions
|
||||||
|
})
|
||||||
|
await this.embeddings.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
const embedding = await this.embeddings.embedQuery(text)
|
||||||
|
|
||||||
|
return embedding
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Embedding generation failed:', error)
|
||||||
|
throw new Error(`Failed to generate embedding: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== VECTOR SEARCH OPERATIONS (Previously VectorSearch) ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert embedding array to libsql vector format
|
||||||
|
*/
|
||||||
|
private embeddingToVector(embedding: number[]): string {
|
||||||
|
return `[${embedding.join(',')}]`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hybrid search combining text and vector similarity (currently vector-only)
|
||||||
|
*/
|
||||||
|
private async hybridSearch(
|
||||||
|
_: string,
|
||||||
|
queryEmbedding: number[],
|
||||||
|
options: VectorSearchOptions = {}
|
||||||
|
): Promise<SearchResult> {
|
||||||
|
if (!this.db) throw new Error('Database not initialized')
|
||||||
|
|
||||||
|
const { limit = 10, threshold = 0.5, userId } = options
|
||||||
|
|
||||||
|
try {
|
||||||
|
const queryVector = this.embeddingToVector(queryEmbedding)
|
||||||
|
|
||||||
|
const conditions: string[] = ['m.is_deleted = 0']
|
||||||
|
const params: any[] = []
|
||||||
|
|
||||||
|
// Vector search only - three vector parameters for distance, vector_similarity, and combined_score
|
||||||
|
params.push(queryVector, queryVector, queryVector)
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
conditions.push('m.user_id = ?')
|
||||||
|
params.push(userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = conditions.join(' AND ')
|
||||||
|
|
||||||
|
const hybridQuery = `${MemoryQueries.search.hybridSearch} ${whereClause}
|
||||||
|
) AS results
|
||||||
|
WHERE vector_similarity >= ?
|
||||||
|
ORDER BY vector_similarity DESC
|
||||||
|
LIMIT ?`
|
||||||
|
|
||||||
|
params.push(threshold, limit)
|
||||||
|
|
||||||
|
const result = await this.db.execute({
|
||||||
|
sql: hybridQuery,
|
||||||
|
args: params
|
||||||
|
})
|
||||||
|
|
||||||
|
const memories: MemoryItem[] = result.rows.map((row: any) => ({
|
||||||
|
id: row.id as string,
|
||||||
|
memory: row.memory as string,
|
||||||
|
hash: (row.hash as string) || undefined,
|
||||||
|
metadata: row.metadata ? JSON.parse(row.metadata as string) : undefined,
|
||||||
|
createdAt: row.created_at as string,
|
||||||
|
updatedAt: row.updated_at as string,
|
||||||
|
score: row.vector_similarity as number
|
||||||
|
}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
memories,
|
||||||
|
count: memories.length
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Hybrid search failed:', error)
|
||||||
|
throw new Error(`Hybrid search failed: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== HELPER METHODS ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add entry to memory history
|
||||||
|
*/
|
||||||
|
private async addHistory(
|
||||||
|
memoryId: string,
|
||||||
|
previousValue: string | null,
|
||||||
|
newValue: string | null,
|
||||||
|
action: 'ADD' | 'UPDATE' | 'DELETE'
|
||||||
|
): Promise<void> {
|
||||||
|
if (!this.db) throw new Error('Database not initialized')
|
||||||
|
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
await this.db.execute({
|
||||||
|
sql: MemoryQueries.history.insert,
|
||||||
|
args: [memoryId, previousValue, newValue, action, now, now]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MemoryService
|
||||||
150
src/main/services/memory/queries.ts
Normal file
150
src/main/services/memory/queries.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
/**
|
||||||
|
* SQL queries for MemoryService
|
||||||
|
* All SQL queries are centralized here for better maintainability
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const MemoryQueries = {
|
||||||
|
// Table creation queries
|
||||||
|
createTables: {
|
||||||
|
memories: `
|
||||||
|
CREATE TABLE IF NOT EXISTS memories (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
memory TEXT NOT NULL,
|
||||||
|
hash TEXT UNIQUE,
|
||||||
|
embedding F32_BLOB(1536), -- Native vector column (1536 dimensions for OpenAI embeddings)
|
||||||
|
metadata TEXT, -- JSON string
|
||||||
|
user_id TEXT,
|
||||||
|
agent_id TEXT,
|
||||||
|
run_id TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
is_deleted INTEGER DEFAULT 0
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
|
||||||
|
memoryHistory: `
|
||||||
|
CREATE TABLE IF NOT EXISTS memory_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
memory_id TEXT NOT NULL,
|
||||||
|
previous_value TEXT,
|
||||||
|
new_value TEXT,
|
||||||
|
action TEXT NOT NULL, -- ADD, UPDATE, DELETE
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
is_deleted INTEGER DEFAULT 0,
|
||||||
|
FOREIGN KEY (memory_id) REFERENCES memories (id)
|
||||||
|
)
|
||||||
|
`
|
||||||
|
},
|
||||||
|
|
||||||
|
// Index creation queries
|
||||||
|
createIndexes: {
|
||||||
|
userId: 'CREATE INDEX IF NOT EXISTS idx_memories_user_id ON memories(user_id)',
|
||||||
|
agentId: 'CREATE INDEX IF NOT EXISTS idx_memories_agent_id ON memories(agent_id)',
|
||||||
|
createdAt: 'CREATE INDEX IF NOT EXISTS idx_memories_created_at ON memories(created_at)',
|
||||||
|
hash: 'CREATE INDEX IF NOT EXISTS idx_memories_hash ON memories(hash)',
|
||||||
|
memoryHistory: 'CREATE INDEX IF NOT EXISTS idx_memory_history_memory_id ON memory_history(memory_id)',
|
||||||
|
vector: 'CREATE INDEX IF NOT EXISTS idx_memories_vector ON memories (libsql_vector_idx(embedding))'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Memory operations
|
||||||
|
memory: {
|
||||||
|
checkExists: 'SELECT id FROM memories WHERE hash = ? AND is_deleted = 0',
|
||||||
|
|
||||||
|
insert: `
|
||||||
|
INSERT INTO memories (id, memory, hash, embedding, metadata, user_id, agent_id, run_id, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`,
|
||||||
|
|
||||||
|
getForDelete: 'SELECT memory FROM memories WHERE id = ? AND is_deleted = 0',
|
||||||
|
|
||||||
|
softDelete: 'UPDATE memories SET is_deleted = 1, updated_at = ? WHERE id = ?',
|
||||||
|
|
||||||
|
getForUpdate: 'SELECT memory, metadata FROM memories WHERE id = ? AND is_deleted = 0',
|
||||||
|
|
||||||
|
update: `
|
||||||
|
UPDATE memories
|
||||||
|
SET memory = ?, hash = ?, embedding = ?, metadata = ?, updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`,
|
||||||
|
|
||||||
|
count: 'SELECT COUNT(*) as total FROM memories m WHERE',
|
||||||
|
|
||||||
|
list: `
|
||||||
|
SELECT
|
||||||
|
m.id,
|
||||||
|
m.memory,
|
||||||
|
m.hash,
|
||||||
|
m.metadata,
|
||||||
|
m.user_id,
|
||||||
|
m.agent_id,
|
||||||
|
m.run_id,
|
||||||
|
m.created_at,
|
||||||
|
m.updated_at
|
||||||
|
FROM memories m
|
||||||
|
WHERE
|
||||||
|
`
|
||||||
|
},
|
||||||
|
|
||||||
|
// History operations
|
||||||
|
history: {
|
||||||
|
insert: `
|
||||||
|
INSERT INTO memory_history (memory_id, previous_value, new_value, action, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
`,
|
||||||
|
|
||||||
|
getByMemoryId: `
|
||||||
|
SELECT * FROM memory_history
|
||||||
|
WHERE memory_id = ? AND is_deleted = 0
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`
|
||||||
|
},
|
||||||
|
|
||||||
|
// Search operations
|
||||||
|
search: {
|
||||||
|
hybridSearch: `
|
||||||
|
SELECT * FROM (
|
||||||
|
SELECT
|
||||||
|
m.id,
|
||||||
|
m.memory,
|
||||||
|
m.hash,
|
||||||
|
m.metadata,
|
||||||
|
m.user_id,
|
||||||
|
m.agent_id,
|
||||||
|
m.run_id,
|
||||||
|
m.created_at,
|
||||||
|
m.updated_at,
|
||||||
|
CASE
|
||||||
|
WHEN m.embedding IS NULL THEN 2.0
|
||||||
|
ELSE vector_distance_cos(m.embedding, vector32(?))
|
||||||
|
END as distance,
|
||||||
|
CASE
|
||||||
|
WHEN m.embedding IS NULL THEN 0.0
|
||||||
|
ELSE (1 - vector_distance_cos(m.embedding, vector32(?)))
|
||||||
|
END as vector_similarity,
|
||||||
|
0.0 as text_similarity,
|
||||||
|
(
|
||||||
|
CASE
|
||||||
|
WHEN m.embedding IS NULL THEN 0.0
|
||||||
|
ELSE (1 - vector_distance_cos(m.embedding, vector32(?)))
|
||||||
|
END
|
||||||
|
) as combined_score
|
||||||
|
FROM memories m
|
||||||
|
WHERE
|
||||||
|
`
|
||||||
|
},
|
||||||
|
|
||||||
|
// User operations
|
||||||
|
users: {
|
||||||
|
getUniqueUsers: `
|
||||||
|
SELECT DISTINCT
|
||||||
|
user_id,
|
||||||
|
COUNT(*) as memory_count,
|
||||||
|
MAX(created_at) as last_memory_date
|
||||||
|
FROM memories
|
||||||
|
WHERE user_id IS NOT NULL AND is_deleted = 0
|
||||||
|
GROUP BY user_id
|
||||||
|
ORDER BY last_memory_date DESC
|
||||||
|
`
|
||||||
|
}
|
||||||
|
} as const
|
||||||
13
src/main/services/remotefile/BaseFileService.ts
Normal file
13
src/main/services/remotefile/BaseFileService.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { FileListResponse, FileMetadata, FileUploadResponse, Provider } from '@types'
|
||||||
|
|
||||||
|
export abstract class BaseFileService {
|
||||||
|
protected readonly provider: Provider
|
||||||
|
protected constructor(provider: Provider) {
|
||||||
|
this.provider = provider
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract uploadFile(file: FileMetadata): Promise<FileUploadResponse>
|
||||||
|
abstract deleteFile(fileId: string): Promise<void>
|
||||||
|
abstract listFiles(): Promise<FileListResponse>
|
||||||
|
abstract retrieveFile(fileId: string): Promise<FileUploadResponse>
|
||||||
|
}
|
||||||
41
src/main/services/remotefile/FileServiceManager.ts
Normal file
41
src/main/services/remotefile/FileServiceManager.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { Provider } from '@types'
|
||||||
|
|
||||||
|
import { BaseFileService } from './BaseFileService'
|
||||||
|
import { GeminiService } from './GeminiService'
|
||||||
|
import { MistralService } from './MistralService'
|
||||||
|
|
||||||
|
export class FileServiceManager {
|
||||||
|
private static instance: FileServiceManager
|
||||||
|
private services: Map<string, BaseFileService> = new Map()
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||||
|
private constructor() {}
|
||||||
|
|
||||||
|
static getInstance(): FileServiceManager {
|
||||||
|
if (!this.instance) {
|
||||||
|
this.instance = new FileServiceManager()
|
||||||
|
}
|
||||||
|
return this.instance
|
||||||
|
}
|
||||||
|
|
||||||
|
getService(provider: Provider): BaseFileService {
|
||||||
|
const type = provider.type
|
||||||
|
let service = this.services.get(type)
|
||||||
|
|
||||||
|
if (!service) {
|
||||||
|
switch (type) {
|
||||||
|
case 'gemini':
|
||||||
|
service = new GeminiService(provider)
|
||||||
|
break
|
||||||
|
case 'mistral':
|
||||||
|
service = new MistralService(provider)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported service type: ${type}`)
|
||||||
|
}
|
||||||
|
this.services.set(type, service)
|
||||||
|
}
|
||||||
|
|
||||||
|
return service
|
||||||
|
}
|
||||||
|
}
|
||||||
190
src/main/services/remotefile/GeminiService.ts
Normal file
190
src/main/services/remotefile/GeminiService.ts
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import { File, Files, FileState, GoogleGenAI } from '@google/genai'
|
||||||
|
import { FileListResponse, FileMetadata, FileUploadResponse, Provider } from '@types'
|
||||||
|
import Logger from 'electron-log'
|
||||||
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
|
|
||||||
|
import { CacheService } from '../CacheService'
|
||||||
|
import { BaseFileService } from './BaseFileService'
|
||||||
|
|
||||||
|
export class GeminiService extends BaseFileService {
|
||||||
|
private static readonly FILE_LIST_CACHE_KEY = 'gemini_file_list'
|
||||||
|
private static readonly FILE_CACHE_DURATION = 48 * 60 * 60 * 1000
|
||||||
|
private static readonly LIST_CACHE_DURATION = 3000
|
||||||
|
|
||||||
|
protected readonly fileManager: Files
|
||||||
|
|
||||||
|
constructor(provider: Provider) {
|
||||||
|
super(provider)
|
||||||
|
this.fileManager = new GoogleGenAI({
|
||||||
|
vertexai: false,
|
||||||
|
apiKey: provider.apiKey,
|
||||||
|
httpOptions: {
|
||||||
|
baseUrl: provider.apiHost
|
||||||
|
}
|
||||||
|
}).files
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadFile(file: FileMetadata): Promise<FileUploadResponse> {
|
||||||
|
try {
|
||||||
|
const uploadResult = await this.fileManager.upload({
|
||||||
|
file: file.path,
|
||||||
|
config: {
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
name: file.id,
|
||||||
|
displayName: file.origin_name
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 根据文件状态设置响应状态
|
||||||
|
let status: 'success' | 'processing' | 'failed' | 'unknown'
|
||||||
|
switch (uploadResult.state) {
|
||||||
|
case FileState.ACTIVE:
|
||||||
|
status = 'success'
|
||||||
|
break
|
||||||
|
case FileState.PROCESSING:
|
||||||
|
status = 'processing'
|
||||||
|
break
|
||||||
|
case FileState.FAILED:
|
||||||
|
status = 'failed'
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
status = 'unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
const response: FileUploadResponse = {
|
||||||
|
fileId: uploadResult.name || '',
|
||||||
|
displayName: file.origin_name,
|
||||||
|
status,
|
||||||
|
originalFile: {
|
||||||
|
type: 'gemini',
|
||||||
|
file: uploadResult
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只缓存成功的文件
|
||||||
|
if (status === 'success') {
|
||||||
|
const cacheKey = `${GeminiService.FILE_LIST_CACHE_KEY}_${response.fileId}`
|
||||||
|
CacheService.set<FileUploadResponse>(cacheKey, response, GeminiService.FILE_CACHE_DURATION)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error('Error uploading file to Gemini:', error)
|
||||||
|
return {
|
||||||
|
fileId: '',
|
||||||
|
displayName: file.origin_name,
|
||||||
|
status: 'failed',
|
||||||
|
originalFile: undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async retrieveFile(fileId: string): Promise<FileUploadResponse> {
|
||||||
|
try {
|
||||||
|
const cachedResponse = CacheService.get<FileUploadResponse>(`${GeminiService.FILE_LIST_CACHE_KEY}_${fileId}`)
|
||||||
|
Logger.info('[GeminiService] cachedResponse', cachedResponse)
|
||||||
|
if (cachedResponse) {
|
||||||
|
return cachedResponse
|
||||||
|
}
|
||||||
|
const files: File[] = []
|
||||||
|
|
||||||
|
for await (const f of await this.fileManager.list()) {
|
||||||
|
files.push(f)
|
||||||
|
}
|
||||||
|
Logger.info('[GeminiService] files', files)
|
||||||
|
const file = files
|
||||||
|
.filter((file) => file.state === FileState.ACTIVE)
|
||||||
|
.find((file) => file.name?.substring(6) === fileId) // 去掉 files/ 前缀
|
||||||
|
Logger.info('[GeminiService] file', file)
|
||||||
|
if (file) {
|
||||||
|
return {
|
||||||
|
fileId: fileId,
|
||||||
|
displayName: file.displayName || '',
|
||||||
|
status: 'success',
|
||||||
|
originalFile: {
|
||||||
|
type: 'gemini',
|
||||||
|
file
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
fileId: fileId,
|
||||||
|
displayName: '',
|
||||||
|
status: 'failed',
|
||||||
|
originalFile: undefined
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error('Error retrieving file from Gemini:', error)
|
||||||
|
return {
|
||||||
|
fileId: fileId,
|
||||||
|
displayName: '',
|
||||||
|
status: 'failed',
|
||||||
|
originalFile: undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async listFiles(): Promise<FileListResponse> {
|
||||||
|
try {
|
||||||
|
const cachedList = CacheService.get<FileListResponse>(GeminiService.FILE_LIST_CACHE_KEY)
|
||||||
|
if (cachedList) {
|
||||||
|
return cachedList
|
||||||
|
}
|
||||||
|
const geminiFiles: File[] = []
|
||||||
|
|
||||||
|
for await (const f of await this.fileManager.list()) {
|
||||||
|
geminiFiles.push(f)
|
||||||
|
}
|
||||||
|
const fileList: FileListResponse = {
|
||||||
|
files: geminiFiles
|
||||||
|
.filter((file) => file.state === FileState.ACTIVE)
|
||||||
|
.map((file) => {
|
||||||
|
// 更新单个文件的缓存
|
||||||
|
const fileResponse: FileUploadResponse = {
|
||||||
|
fileId: file.name || uuidv4(),
|
||||||
|
displayName: file.displayName || '',
|
||||||
|
status: 'success',
|
||||||
|
originalFile: {
|
||||||
|
type: 'gemini',
|
||||||
|
file
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CacheService.set(
|
||||||
|
`${GeminiService.FILE_LIST_CACHE_KEY}_${file.name}`,
|
||||||
|
fileResponse,
|
||||||
|
GeminiService.FILE_CACHE_DURATION
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: file.name || uuidv4(),
|
||||||
|
displayName: file.displayName || '',
|
||||||
|
size: Number(file.sizeBytes),
|
||||||
|
status: 'success',
|
||||||
|
originalFile: {
|
||||||
|
type: 'gemini',
|
||||||
|
file
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新文件列表缓存
|
||||||
|
CacheService.set(GeminiService.FILE_LIST_CACHE_KEY, fileList, GeminiService.LIST_CACHE_DURATION)
|
||||||
|
return fileList
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error('Error listing files from Gemini:', error)
|
||||||
|
return { files: [] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteFile(fileId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.fileManager.delete({ name: fileId })
|
||||||
|
Logger.info(`File ${fileId} deleted from Gemini`)
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error('Error deleting file from Gemini:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
104
src/main/services/remotefile/MistralService.ts
Normal file
104
src/main/services/remotefile/MistralService.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import fs from 'node:fs/promises'
|
||||||
|
|
||||||
|
import { Mistral } from '@mistralai/mistralai'
|
||||||
|
import { FileListResponse, FileMetadata, FileUploadResponse, Provider } from '@types'
|
||||||
|
import Logger from 'electron-log'
|
||||||
|
|
||||||
|
import { MistralClientManager } from '../MistralClientManager'
|
||||||
|
import { BaseFileService } from './BaseFileService'
|
||||||
|
|
||||||
|
export class MistralService extends BaseFileService {
|
||||||
|
private readonly client: Mistral
|
||||||
|
|
||||||
|
constructor(provider: Provider) {
|
||||||
|
super(provider)
|
||||||
|
const clientManager = MistralClientManager.getInstance()
|
||||||
|
clientManager.initializeClient(provider)
|
||||||
|
this.client = clientManager.getClient()
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadFile(file: FileMetadata): Promise<FileUploadResponse> {
|
||||||
|
try {
|
||||||
|
const fileBuffer = await fs.readFile(file.path)
|
||||||
|
const response = await this.client.files.upload({
|
||||||
|
file: {
|
||||||
|
fileName: file.origin_name,
|
||||||
|
content: new Uint8Array(fileBuffer)
|
||||||
|
},
|
||||||
|
purpose: 'ocr'
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
fileId: response.id,
|
||||||
|
displayName: file.origin_name,
|
||||||
|
status: 'success',
|
||||||
|
originalFile: {
|
||||||
|
type: 'mistral',
|
||||||
|
file: response
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error('Error uploading file:', error)
|
||||||
|
return {
|
||||||
|
fileId: '',
|
||||||
|
displayName: file.origin_name,
|
||||||
|
status: 'failed'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async listFiles(): Promise<FileListResponse> {
|
||||||
|
try {
|
||||||
|
const response = await this.client.files.list({})
|
||||||
|
return {
|
||||||
|
files: response.data.map((file) => ({
|
||||||
|
id: file.id,
|
||||||
|
displayName: file.filename || '',
|
||||||
|
size: file.sizeBytes,
|
||||||
|
status: 'success', // All listed files are processed,
|
||||||
|
originalFile: {
|
||||||
|
type: 'mistral',
|
||||||
|
file
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error('Error listing files:', error)
|
||||||
|
return { files: [] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteFile(fileId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.client.files.delete({
|
||||||
|
fileId
|
||||||
|
})
|
||||||
|
Logger.info(`File ${fileId} deleted`)
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error('Error deleting file:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async retrieveFile(fileId: string): Promise<FileUploadResponse> {
|
||||||
|
try {
|
||||||
|
const response = await this.client.files.retrieve({
|
||||||
|
fileId
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
fileId: response.id,
|
||||||
|
displayName: response.filename || '',
|
||||||
|
status: 'success' // Retrieved files are always processed
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error('Error retrieving file:', error)
|
||||||
|
return {
|
||||||
|
fileId: fileId,
|
||||||
|
displayName: '',
|
||||||
|
status: 'failed',
|
||||||
|
originalFile: undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import path from 'node:path'
|
|||||||
|
|
||||||
import { isPortable } from '@main/constant'
|
import { isPortable } from '@main/constant'
|
||||||
import { audioExts, documentExts, imageExts, textExts, videoExts } from '@shared/config/constant'
|
import { audioExts, documentExts, imageExts, textExts, videoExts } from '@shared/config/constant'
|
||||||
import { FileType, FileTypes } from '@types'
|
import { FileMetadata, FileTypes } from '@types'
|
||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
import { v4 as uuidv4 } from 'uuid'
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
|
|
||||||
@@ -121,7 +121,19 @@ export function getFileType(ext: string): FileTypes {
|
|||||||
return fileTypeMap.get(ext) || FileTypes.OTHER
|
return fileTypeMap.get(ext) || FileTypes.OTHER
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAllFiles(dirPath: string, arrayOfFiles: FileType[] = []): FileType[] {
|
export function getFileDir(filePath: string) {
|
||||||
|
return path.dirname(filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFileName(filePath: string) {
|
||||||
|
return path.basename(filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFileExt(filePath: string) {
|
||||||
|
return path.extname(filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllFiles(dirPath: string, arrayOfFiles: FileMetadata[] = []): FileMetadata[] {
|
||||||
const files = fs.readdirSync(dirPath)
|
const files = fs.readdirSync(dirPath)
|
||||||
|
|
||||||
files.forEach((file) => {
|
files.forEach((file) => {
|
||||||
@@ -143,7 +155,7 @@ export function getAllFiles(dirPath: string, arrayOfFiles: FileType[] = []): Fil
|
|||||||
const name = path.basename(file)
|
const name = path.basename(file)
|
||||||
const size = fs.statSync(fullPath).size
|
const size = fs.statSync(fullPath).size
|
||||||
|
|
||||||
const fileItem: FileType = {
|
const fileItem: FileMetadata = {
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
name,
|
name,
|
||||||
path: fullPath,
|
path: fullPath,
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export async function getBinaryPath(name?: string): Promise<string> {
|
|||||||
|
|
||||||
const binaryName = await getBinaryName(name)
|
const binaryName = await getBinaryName(name)
|
||||||
const binariesDir = path.join(os.homedir(), '.cherrystudio', 'bin')
|
const binariesDir = path.join(os.homedir(), '.cherrystudio', 'bin')
|
||||||
const binariesDirExists = await fs.existsSync(binariesDir)
|
const binariesDirExists = fs.existsSync(binariesDir)
|
||||||
return binariesDirExists ? path.join(binariesDir, binaryName) : binaryName
|
return binariesDirExists ? path.join(binariesDir, binaryName) : binaryName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,23 @@ import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
|
|||||||
import { electronAPI } from '@electron-toolkit/preload'
|
import { electronAPI } from '@electron-toolkit/preload'
|
||||||
import { UpgradeChannel } from '@shared/config/constant'
|
import { UpgradeChannel } from '@shared/config/constant'
|
||||||
import { IpcChannel } from '@shared/IpcChannel'
|
import { IpcChannel } from '@shared/IpcChannel'
|
||||||
import { FileType, KnowledgeBaseParams, KnowledgeItem, MCPServer, Shortcut, ThemeMode, WebDavConfig } from '@types'
|
import {
|
||||||
|
AddMemoryOptions,
|
||||||
|
AssistantMessage,
|
||||||
|
FileListResponse,
|
||||||
|
FileMetadata,
|
||||||
|
FileUploadResponse,
|
||||||
|
KnowledgeBaseParams,
|
||||||
|
KnowledgeItem,
|
||||||
|
MCPServer,
|
||||||
|
MemoryConfig,
|
||||||
|
MemoryListOptions,
|
||||||
|
MemorySearchOptions,
|
||||||
|
Provider,
|
||||||
|
Shortcut,
|
||||||
|
ThemeMode,
|
||||||
|
WebDavConfig
|
||||||
|
} from '@types'
|
||||||
import { contextBridge, ipcRenderer, OpenDialogOptions, shell, webUtils } from 'electron'
|
import { contextBridge, ipcRenderer, OpenDialogOptions, shell, webUtils } from 'electron'
|
||||||
import { Notification } from 'src/renderer/src/types/notification'
|
import { Notification } from 'src/renderer/src/types/notification'
|
||||||
import { CreateDirectoryOptions } from 'webdav'
|
import { CreateDirectoryOptions } from 'webdav'
|
||||||
@@ -42,6 +58,10 @@ const api = {
|
|||||||
openWebsite: (url: string) => ipcRenderer.invoke(IpcChannel.Open_Website, url),
|
openWebsite: (url: string) => ipcRenderer.invoke(IpcChannel.Open_Website, url),
|
||||||
getCacheSize: () => ipcRenderer.invoke(IpcChannel.App_GetCacheSize),
|
getCacheSize: () => ipcRenderer.invoke(IpcChannel.App_GetCacheSize),
|
||||||
clearCache: () => ipcRenderer.invoke(IpcChannel.App_ClearCache),
|
clearCache: () => ipcRenderer.invoke(IpcChannel.App_ClearCache),
|
||||||
|
mac: {
|
||||||
|
isProcessTrusted: (): Promise<boolean> => ipcRenderer.invoke(IpcChannel.App_MacIsProcessTrusted),
|
||||||
|
requestProcessTrust: (): Promise<boolean> => ipcRenderer.invoke(IpcChannel.App_MacRequestProcessTrust)
|
||||||
|
},
|
||||||
notification: {
|
notification: {
|
||||||
send: (notification: Notification) => ipcRenderer.invoke(IpcChannel.Notification_Send, notification)
|
send: (notification: Notification) => ipcRenderer.invoke(IpcChannel.Notification_Send, notification)
|
||||||
},
|
},
|
||||||
@@ -75,13 +95,25 @@ const api = {
|
|||||||
},
|
},
|
||||||
file: {
|
file: {
|
||||||
select: (options?: OpenDialogOptions) => ipcRenderer.invoke(IpcChannel.File_Select, options),
|
select: (options?: OpenDialogOptions) => ipcRenderer.invoke(IpcChannel.File_Select, options),
|
||||||
upload: (file: FileType) => ipcRenderer.invoke(IpcChannel.File_Upload, file),
|
upload: (file: FileMetadata) => ipcRenderer.invoke(IpcChannel.File_Upload, file),
|
||||||
delete: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Delete, fileId),
|
delete: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Delete, fileId),
|
||||||
|
deleteDir: (dirPath: string) => ipcRenderer.invoke(IpcChannel.File_DeleteDir, dirPath),
|
||||||
read: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Read, fileId),
|
read: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Read, fileId),
|
||||||
clear: () => ipcRenderer.invoke(IpcChannel.File_Clear),
|
clear: () => ipcRenderer.invoke(IpcChannel.File_Clear),
|
||||||
get: (filePath: string) => ipcRenderer.invoke(IpcChannel.File_Get, filePath),
|
get: (filePath: string) => ipcRenderer.invoke(IpcChannel.File_Get, filePath),
|
||||||
create: (fileName: string) => ipcRenderer.invoke(IpcChannel.File_Create, fileName),
|
/**
|
||||||
|
* 创建一个空的临时文件
|
||||||
|
* @param fileName 文件名
|
||||||
|
* @returns 临时文件路径
|
||||||
|
*/
|
||||||
|
createTempFile: (fileName: string): Promise<string> => ipcRenderer.invoke(IpcChannel.File_CreateTempFile, fileName),
|
||||||
|
/**
|
||||||
|
* 写入文件
|
||||||
|
* @param filePath 文件路径
|
||||||
|
* @param data 数据
|
||||||
|
*/
|
||||||
write: (filePath: string, data: Uint8Array | string) => ipcRenderer.invoke(IpcChannel.File_Write, filePath, data),
|
write: (filePath: string, data: Uint8Array | string) => ipcRenderer.invoke(IpcChannel.File_Write, filePath, data),
|
||||||
|
|
||||||
writeWithId: (id: string, content: string) => ipcRenderer.invoke(IpcChannel.File_WriteWithId, id, content),
|
writeWithId: (id: string, content: string) => ipcRenderer.invoke(IpcChannel.File_WriteWithId, id, content),
|
||||||
open: (options?: OpenDialogOptions) => ipcRenderer.invoke(IpcChannel.File_Open, options),
|
open: (options?: OpenDialogOptions) => ipcRenderer.invoke(IpcChannel.File_Open, options),
|
||||||
openPath: (path: string) => ipcRenderer.invoke(IpcChannel.File_OpenPath, path),
|
openPath: (path: string) => ipcRenderer.invoke(IpcChannel.File_OpenPath, path),
|
||||||
@@ -89,12 +121,12 @@ const api = {
|
|||||||
ipcRenderer.invoke(IpcChannel.File_Save, path, content, options),
|
ipcRenderer.invoke(IpcChannel.File_Save, path, content, options),
|
||||||
selectFolder: () => ipcRenderer.invoke(IpcChannel.File_SelectFolder),
|
selectFolder: () => ipcRenderer.invoke(IpcChannel.File_SelectFolder),
|
||||||
saveImage: (name: string, data: string) => ipcRenderer.invoke(IpcChannel.File_SaveImage, name, data),
|
saveImage: (name: string, data: string) => ipcRenderer.invoke(IpcChannel.File_SaveImage, name, data),
|
||||||
|
binaryImage: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_BinaryImage, fileId),
|
||||||
base64Image: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Base64Image, fileId),
|
base64Image: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Base64Image, fileId),
|
||||||
saveBase64Image: (data: string) => ipcRenderer.invoke(IpcChannel.File_SaveBase64Image, data),
|
saveBase64Image: (data: string) => ipcRenderer.invoke(IpcChannel.File_SaveBase64Image, data),
|
||||||
download: (url: string, isUseContentType?: boolean) =>
|
download: (url: string, isUseContentType?: boolean) =>
|
||||||
ipcRenderer.invoke(IpcChannel.File_Download, url, isUseContentType),
|
ipcRenderer.invoke(IpcChannel.File_Download, url, isUseContentType),
|
||||||
copy: (fileId: string, destPath: string) => ipcRenderer.invoke(IpcChannel.File_Copy, fileId, destPath),
|
copy: (fileId: string, destPath: string) => ipcRenderer.invoke(IpcChannel.File_Copy, fileId, destPath),
|
||||||
binaryImage: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_BinaryImage, fileId),
|
|
||||||
base64File: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Base64File, fileId),
|
base64File: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Base64File, fileId),
|
||||||
pdfInfo: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_GetPdfInfo, fileId),
|
pdfInfo: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_GetPdfInfo, fileId),
|
||||||
getPathForFile: (file: File) => webUtils.getPathForFile(file)
|
getPathForFile: (file: File) => webUtils.getPathForFile(file)
|
||||||
@@ -116,31 +148,52 @@ const api = {
|
|||||||
add: ({
|
add: ({
|
||||||
base,
|
base,
|
||||||
item,
|
item,
|
||||||
|
userId,
|
||||||
forceReload = false
|
forceReload = false
|
||||||
}: {
|
}: {
|
||||||
base: KnowledgeBaseParams
|
base: KnowledgeBaseParams
|
||||||
item: KnowledgeItem
|
item: KnowledgeItem
|
||||||
|
userId?: string
|
||||||
forceReload?: boolean
|
forceReload?: boolean
|
||||||
}) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Add, { base, item, forceReload }),
|
}) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Add, { base, item, forceReload, userId }),
|
||||||
remove: ({ uniqueId, uniqueIds, base }: { uniqueId: string; uniqueIds: string[]; base: KnowledgeBaseParams }) =>
|
remove: ({ uniqueId, uniqueIds, base }: { uniqueId: string; uniqueIds: string[]; base: KnowledgeBaseParams }) =>
|
||||||
ipcRenderer.invoke(IpcChannel.KnowledgeBase_Remove, { uniqueId, uniqueIds, base }),
|
ipcRenderer.invoke(IpcChannel.KnowledgeBase_Remove, { uniqueId, uniqueIds, base }),
|
||||||
search: ({ search, base }: { search: string; base: KnowledgeBaseParams }) =>
|
search: ({ search, base }: { search: string; base: KnowledgeBaseParams }) =>
|
||||||
ipcRenderer.invoke(IpcChannel.KnowledgeBase_Search, { search, base }),
|
ipcRenderer.invoke(IpcChannel.KnowledgeBase_Search, { search, base }),
|
||||||
rerank: ({ search, base, results }: { search: string; base: KnowledgeBaseParams; results: ExtractChunkData[] }) =>
|
rerank: ({ search, base, results }: { search: string; base: KnowledgeBaseParams; results: ExtractChunkData[] }) =>
|
||||||
ipcRenderer.invoke(IpcChannel.KnowledgeBase_Rerank, { search, base, results })
|
ipcRenderer.invoke(IpcChannel.KnowledgeBase_Rerank, { search, base, results }),
|
||||||
|
checkQuota: ({ base, userId }: { base: KnowledgeBaseParams; userId: string }) =>
|
||||||
|
ipcRenderer.invoke(IpcChannel.KnowledgeBase_Check_Quota, base, userId)
|
||||||
|
},
|
||||||
|
memory: {
|
||||||
|
add: (messages: string | AssistantMessage[], options?: AddMemoryOptions) =>
|
||||||
|
ipcRenderer.invoke(IpcChannel.Memory_Add, messages, options),
|
||||||
|
search: (query: string, options: MemorySearchOptions) =>
|
||||||
|
ipcRenderer.invoke(IpcChannel.Memory_Search, query, options),
|
||||||
|
list: (options?: MemoryListOptions) => ipcRenderer.invoke(IpcChannel.Memory_List, options),
|
||||||
|
delete: (id: string) => ipcRenderer.invoke(IpcChannel.Memory_Delete, id),
|
||||||
|
update: (id: string, memory: string, metadata?: Record<string, any>) =>
|
||||||
|
ipcRenderer.invoke(IpcChannel.Memory_Update, id, memory, metadata),
|
||||||
|
get: (id: string) => ipcRenderer.invoke(IpcChannel.Memory_Get, id),
|
||||||
|
setConfig: (config: MemoryConfig) => ipcRenderer.invoke(IpcChannel.Memory_SetConfig, config),
|
||||||
|
deleteUser: (userId: string) => ipcRenderer.invoke(IpcChannel.Memory_DeleteUser, userId),
|
||||||
|
getUsersList: () => ipcRenderer.invoke(IpcChannel.Memory_GetUsersList)
|
||||||
},
|
},
|
||||||
window: {
|
window: {
|
||||||
setMinimumSize: (width: number, height: number) =>
|
setMinimumSize: (width: number, height: number) =>
|
||||||
ipcRenderer.invoke(IpcChannel.Windows_SetMinimumSize, width, height),
|
ipcRenderer.invoke(IpcChannel.Windows_SetMinimumSize, width, height),
|
||||||
resetMinimumSize: () => ipcRenderer.invoke(IpcChannel.Windows_ResetMinimumSize)
|
resetMinimumSize: () => ipcRenderer.invoke(IpcChannel.Windows_ResetMinimumSize)
|
||||||
},
|
},
|
||||||
gemini: {
|
fileService: {
|
||||||
uploadFile: (file: FileType, { apiKey, baseURL }: { apiKey: string; baseURL: string }) =>
|
upload: (provider: Provider, file: FileMetadata): Promise<FileUploadResponse> =>
|
||||||
ipcRenderer.invoke(IpcChannel.Gemini_UploadFile, file, { apiKey, baseURL }),
|
ipcRenderer.invoke(IpcChannel.FileService_Upload, provider, file),
|
||||||
base64File: (file: FileType) => ipcRenderer.invoke(IpcChannel.Gemini_Base64File, file),
|
list: (provider: Provider): Promise<FileListResponse> => ipcRenderer.invoke(IpcChannel.FileService_List, provider),
|
||||||
retrieveFile: (file: FileType, apiKey: string) => ipcRenderer.invoke(IpcChannel.Gemini_RetrieveFile, file, apiKey),
|
delete: (provider: Provider, fileId: string) => ipcRenderer.invoke(IpcChannel.FileService_Delete, provider, fileId),
|
||||||
listFiles: (apiKey: string) => ipcRenderer.invoke(IpcChannel.Gemini_ListFiles, apiKey),
|
retrieve: (provider: Provider, fileId: string): Promise<FileUploadResponse> =>
|
||||||
deleteFile: (fileId: string, apiKey: string) => ipcRenderer.invoke(IpcChannel.Gemini_DeleteFile, fileId, apiKey)
|
ipcRenderer.invoke(IpcChannel.FileService_Retrieve, provider, fileId)
|
||||||
|
},
|
||||||
|
selectionMenu: {
|
||||||
|
action: (action: string) => ipcRenderer.invoke('selection-menu:action', action)
|
||||||
},
|
},
|
||||||
|
|
||||||
vertexAI: {
|
vertexAI: {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import AppsPage from './pages/apps/AppsPage'
|
|||||||
import FilesPage from './pages/files/FilesPage'
|
import FilesPage from './pages/files/FilesPage'
|
||||||
import HomePage from './pages/home/HomePage'
|
import HomePage from './pages/home/HomePage'
|
||||||
import KnowledgePage from './pages/knowledge/KnowledgePage'
|
import KnowledgePage from './pages/knowledge/KnowledgePage'
|
||||||
|
import MemoryPage from './pages/memory'
|
||||||
import PaintingsRoutePage from './pages/paintings/PaintingsRoutePage'
|
import PaintingsRoutePage from './pages/paintings/PaintingsRoutePage'
|
||||||
import SettingsPage from './pages/settings/SettingsPage'
|
import SettingsPage from './pages/settings/SettingsPage'
|
||||||
import TranslatePage from './pages/translate/TranslatePage'
|
import TranslatePage from './pages/translate/TranslatePage'
|
||||||
@@ -42,6 +43,7 @@ function App(): React.ReactElement {
|
|||||||
<Route path="/translate" element={<TranslatePage />} />
|
<Route path="/translate" element={<TranslatePage />} />
|
||||||
<Route path="/files" element={<FilesPage />} />
|
<Route path="/files" element={<FilesPage />} />
|
||||||
<Route path="/knowledge" element={<KnowledgePage />} />
|
<Route path="/knowledge" element={<KnowledgePage />} />
|
||||||
|
<Route path="/memory" element={<MemoryPage />} />
|
||||||
<Route path="/apps" element={<AppsPage />} />
|
<Route path="/apps" element={<AppsPage />} />
|
||||||
<Route path="/settings/*" element={<SettingsPage />} />
|
<Route path="/settings/*" element={<SettingsPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
MCPCallToolResponse,
|
MCPCallToolResponse,
|
||||||
MCPTool,
|
MCPTool,
|
||||||
MCPToolResponse,
|
MCPToolResponse,
|
||||||
|
MemoryItem,
|
||||||
Model,
|
Model,
|
||||||
OpenAIServiceTier,
|
OpenAIServiceTier,
|
||||||
Provider,
|
Provider,
|
||||||
@@ -216,6 +217,7 @@ export abstract class BaseApiClient<
|
|||||||
|
|
||||||
const webSearchReferences = await this.getWebSearchReferencesFromCache(message)
|
const webSearchReferences = await this.getWebSearchReferencesFromCache(message)
|
||||||
const knowledgeReferences = await this.getKnowledgeBaseReferencesFromCache(message)
|
const knowledgeReferences = await this.getKnowledgeBaseReferencesFromCache(message)
|
||||||
|
const memoryReferences = this.getMemoryReferencesFromCache(message)
|
||||||
|
|
||||||
// 添加偏移量以避免ID冲突
|
// 添加偏移量以避免ID冲突
|
||||||
const reindexedKnowledgeReferences = knowledgeReferences.map((ref) => ({
|
const reindexedKnowledgeReferences = knowledgeReferences.map((ref) => ({
|
||||||
@@ -223,7 +225,7 @@ export abstract class BaseApiClient<
|
|||||||
id: ref.id + webSearchReferences.length // 为知识库引用的ID添加网络搜索引用的数量作为偏移量
|
id: ref.id + webSearchReferences.length // 为知识库引用的ID添加网络搜索引用的数量作为偏移量
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const allReferences = [...webSearchReferences, ...reindexedKnowledgeReferences]
|
const allReferences = [...webSearchReferences, ...reindexedKnowledgeReferences, ...memoryReferences]
|
||||||
|
|
||||||
Logger.log(`Found ${allReferences.length} references for ID: ${message.id}`, allReferences)
|
Logger.log(`Found ${allReferences.length} references for ID: ${message.id}`, allReferences)
|
||||||
|
|
||||||
@@ -265,6 +267,20 @@ export abstract class BaseApiClient<
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getMemoryReferencesFromCache(message: Message) {
|
||||||
|
const memories = window.keyv.get(`memory-search-${message.id}`) as MemoryItem[] | undefined
|
||||||
|
if (memories) {
|
||||||
|
const memoryReferences: KnowledgeReference[] = memories.map((mem, index) => ({
|
||||||
|
id: index + 1,
|
||||||
|
content: `${mem.memory} -- Created at: ${mem.createdAt}`,
|
||||||
|
sourceUrl: '',
|
||||||
|
type: 'memory'
|
||||||
|
}))
|
||||||
|
return memoryReferences
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
private async getWebSearchReferencesFromCache(message: Message) {
|
private async getWebSearchReferencesFromCache(message: Message) {
|
||||||
const content = getMainTextContent(message)
|
const content = getMainTextContent(message)
|
||||||
if (isEmpty(content)) {
|
if (isEmpty(content)) {
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import { estimateTextTokens } from '@renderer/services/TokenService'
|
|||||||
import {
|
import {
|
||||||
Assistant,
|
Assistant,
|
||||||
EFFORT_RATIO,
|
EFFORT_RATIO,
|
||||||
FileType,
|
FileMetadata,
|
||||||
FileTypes,
|
FileTypes,
|
||||||
GenerateImageParams,
|
GenerateImageParams,
|
||||||
MCPCallToolResponse,
|
MCPCallToolResponse,
|
||||||
@@ -198,7 +198,7 @@ export class GeminiAPIClient extends BaseApiClient<
|
|||||||
* @param file - The file
|
* @param file - The file
|
||||||
* @returns The part
|
* @returns The part
|
||||||
*/
|
*/
|
||||||
private async handlePdfFile(file: FileType): Promise<Part> {
|
private async handlePdfFile(file: FileMetadata): Promise<Part> {
|
||||||
const smallFileSize = 20 * MB
|
const smallFileSize = 20 * MB
|
||||||
const isSmallFile = file.size < smallFileSize
|
const isSmallFile = file.size < smallFileSize
|
||||||
|
|
||||||
@@ -765,7 +765,7 @@ export class GeminiAPIClient extends BaseApiClient<
|
|||||||
return [...(sdkPayload.history || []), messageParam]
|
return [...(sdkPayload.history || []), messageParam]
|
||||||
}
|
}
|
||||||
|
|
||||||
private async uploadFile(file: FileType): Promise<File> {
|
private async uploadFile(file: FileMetadata): Promise<File> {
|
||||||
return await this.sdkInstance!.files.upload({
|
return await this.sdkInstance!.files.upload({
|
||||||
file: file.path,
|
file: file.path,
|
||||||
config: {
|
config: {
|
||||||
@@ -776,7 +776,7 @@ export class GeminiAPIClient extends BaseApiClient<
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private async base64File(file: FileType) {
|
private async base64File(file: FileMetadata) {
|
||||||
const { data } = await window.api.file.base64File(file.id + file.ext)
|
const { data } = await window.api.file.base64File(file.id + file.ext)
|
||||||
return {
|
return {
|
||||||
data,
|
data,
|
||||||
@@ -784,7 +784,7 @@ export class GeminiAPIClient extends BaseApiClient<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async retrieveFile(file: FileType): Promise<File | undefined> {
|
private async retrieveFile(file: FileMetadata): Promise<File | undefined> {
|
||||||
const cachedResponse = CacheService.get<any>('gemini_file_list')
|
const cachedResponse = CacheService.get<any>('gemini_file_list')
|
||||||
|
|
||||||
if (cachedResponse) {
|
if (cachedResponse) {
|
||||||
@@ -797,7 +797,7 @@ export class GeminiAPIClient extends BaseApiClient<
|
|||||||
return this.processResponse(response, file)
|
return this.processResponse(response, file)
|
||||||
}
|
}
|
||||||
|
|
||||||
private async processResponse(response: Pager<File>, file: FileType) {
|
private async processResponse(response: Pager<File>, file: FileMetadata) {
|
||||||
for await (const f of response) {
|
for await (const f of response) {
|
||||||
if (f.state === FileState.ACTIVE) {
|
if (f.state === FileState.ACTIVE) {
|
||||||
if (f.displayName === file.origin_name && Number(f.sizeBytes) === file.size) {
|
if (f.displayName === file.origin_name && Number(f.sizeBytes) === file.size) {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
} from '@renderer/config/models'
|
} from '@renderer/config/models'
|
||||||
import { estimateTextTokens } from '@renderer/services/TokenService'
|
import { estimateTextTokens } from '@renderer/services/TokenService'
|
||||||
import {
|
import {
|
||||||
FileType,
|
FileMetadata,
|
||||||
FileTypes,
|
FileTypes,
|
||||||
MCPCallToolResponse,
|
MCPCallToolResponse,
|
||||||
MCPTool,
|
MCPTool,
|
||||||
@@ -95,7 +95,7 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
|||||||
return await sdk.responses.create(payload, options)
|
return await sdk.responses.create(payload, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handlePdfFile(file: FileType): Promise<OpenAI.Responses.ResponseInputFile | undefined> {
|
private async handlePdfFile(file: FileMetadata): Promise<OpenAI.Responses.ResponseInputFile | undefined> {
|
||||||
if (file.size > 32 * MB) return undefined
|
if (file.size > 32 * MB) return undefined
|
||||||
try {
|
try {
|
||||||
const pageCount = await window.api.file.pdfInfo(file.id + file.ext)
|
const pageCount = await window.api.file.pdfInfo(file.id + file.ext)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'iconfont'; /* Project id 4753420 */
|
font-family: 'iconfont'; /* Project id 4753420 */
|
||||||
src: url('iconfont.woff2?t=1742184675192') format('woff2');
|
src: url('iconfont.woff2?t=1742793497518') format('woff2');
|
||||||
}
|
}
|
||||||
|
|
||||||
.iconfont {
|
.iconfont {
|
||||||
@@ -11,6 +11,18 @@
|
|||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.icon-plugin:before {
|
||||||
|
content: '\e612';
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-tools:before {
|
||||||
|
content: '\e762';
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-OCRshibie:before {
|
||||||
|
content: '\e658';
|
||||||
|
}
|
||||||
|
|
||||||
.icon-obsidian:before {
|
.icon-obsidian:before {
|
||||||
content: '\e677';
|
content: '\e677';
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
BIN
src/renderer/src/assets/images/ocr/doc2x.png
Normal file
BIN
src/renderer/src/assets/images/ocr/doc2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
BIN
src/renderer/src/assets/images/ocr/mineru.jpg
Normal file
BIN
src/renderer/src/assets/images/ocr/mineru.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
7
src/renderer/src/assets/images/providers/macos.svg
Normal file
7
src/renderer/src/assets/images/providers/macos.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg"
|
||||||
|
aria-label="macOS" role="img"
|
||||||
|
viewBox="0 0 512 512"><rect
|
||||||
|
width="512" height="512"
|
||||||
|
rx="15%"
|
||||||
|
fill="#ffffff"/><path d="M282 170v-4c-52 0-5 34 0 4zm24-18c7-21 43-23 47 3h-10c-3-15-28-16-28 11 0 15 23 24 28 6h10c-6 33-59 21-47-20zm-146-16h10v9c5-12 27-13 31 1 7-15 35-14 35 7v37h-11v-34c0-15-22-15-22 1v33h-11v-35c-2.447-9.36-14.915-11.23-20-3l-2 5v33h-10zm23 259c-47 0-76-33-76-86s29-85 76-85 77 33 77 85-29 86-77 86zm88-205c-29 7-33-30-3-31l14-1v-4c1-12-19-13-22-2h-10a14 14 0 012-7c8-14 41-14 41 8v37h-10v-9a18 18 0 01-12 9zm68 205c-36-2-61-19-63-49h24c23 72 146-5 25-30-19-4-33-13-39-24-38-74 109-96 113-20h-23c-7-49-98-22-65 12 14 14 43 13 64 22 50 23 26 91-36 89zM183 245c-32 0-52 25-52 64s20 64 52 64 53-24 53-64-20-64-53-64z"/></svg>
|
||||||
|
After Width: | Height: | Size: 896 B |
@@ -18,25 +18,37 @@ html {
|
|||||||
|
|
||||||
--selection-toolbar-logo-display: flex; // values: flex | none
|
--selection-toolbar-logo-display: flex; // values: flex | none
|
||||||
--selection-toolbar-logo-size: 22px; // default: 22px
|
--selection-toolbar-logo-size: 22px; // default: 22px
|
||||||
--selection-toolbar-logo-margin: 0 0 0 5px; // default: 0 0 05px
|
--selection-toolbar-logo-border-width: 0.5px 0 0.5px 0.5px; // default: none
|
||||||
|
--selection-toolbar-logo-border-style: solid; // default: none
|
||||||
|
--selection-toolbar-logo-border-color: rgba(255, 255, 255, 0.2);
|
||||||
|
--selection-toolbar-logo-margin: 0; // default: 0
|
||||||
|
--selection-toolbar-logo-padding: 0 8px; // default: 0 8px
|
||||||
|
--selection-toolbar-logo-background: transparent; // default: transparent
|
||||||
|
|
||||||
// DO NOT MODIFY THESE VALUES, IF YOU DON'T KNOW WHAT YOU ARE DOING
|
// DO NOT MODIFY THESE VALUES, IF YOU DON'T KNOW WHAT YOU ARE DOING
|
||||||
--selection-toolbar-padding: 2px 4px 2px 2px; // default: 2px 4px 2px 2px
|
--selection-toolbar-padding: 0; // default: 0
|
||||||
--selection-toolbar-margin: 2px 3px 5px 3px; // default: 2px 3px 5px 3px
|
--selection-toolbar-margin: 2px 3px 5px 3px; // default: 2px 3px 5px 3px
|
||||||
// ------------------------------------------------------------
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
--selection-toolbar-border-radius: 6px;
|
--selection-toolbar-border-radius: 10px;
|
||||||
--selection-toolbar-border: 1px solid rgba(55, 55, 55, 0.5);
|
--selection-toolbar-border: none;
|
||||||
--selection-toolbar-box-shadow: 0px 2px 3px rgba(50, 50, 50, 0.3);
|
--selection-toolbar-box-shadow: 0px 2px 3px rgba(50, 50, 50, 0.3);
|
||||||
--selection-toolbar-background: rgba(20, 20, 20, 0.95);
|
--selection-toolbar-background: rgba(20, 20, 20, 0.95);
|
||||||
|
|
||||||
// Buttons
|
// Buttons
|
||||||
|
--selection-toolbar-buttons-border-width: 0.5px 0.5px 0.5px 0;
|
||||||
|
--selection-toolbar-buttons-border-style: solid;
|
||||||
|
--selection-toolbar-buttons-border-color: rgba(255, 255, 255, 0.2);
|
||||||
|
--selection-toolbar-buttons-border-radius: 0 var(--selection-toolbar-border-radius)
|
||||||
|
var(--selection-toolbar-border-radius) 0;
|
||||||
|
|
||||||
--selection-toolbar-button-icon-size: 16px; // default: 16px
|
--selection-toolbar-button-icon-size: 16px; // default: 16px
|
||||||
--selection-toolbar-button-text-margin: 0 0 0 3px; // default: 0 0 0 3px
|
--selection-toolbar-button-direction: row; // default: row | column
|
||||||
--selection-toolbar-button-margin: 0 2px; // default: 0 2px
|
--selection-toolbar-button-text-margin: 0 0 0 0; // default: 0 0 0 0
|
||||||
--selection-toolbar-button-padding: 4px 6px; // default: 4px 6px
|
--selection-toolbar-button-margin: 0; // default: 0
|
||||||
--selection-toolbar-button-border-radius: 4px; // default: 4px
|
--selection-toolbar-button-padding: 0 8px; // default: 0 8px
|
||||||
|
--selection-toolbar-button-last-padding: 0 12px 0 8px;
|
||||||
|
--selection-toolbar-button-border-radius: 0; // default: 0
|
||||||
--selection-toolbar-button-border: none; // default: none
|
--selection-toolbar-button-border: none; // default: none
|
||||||
--selection-toolbar-button-box-shadow: none; // default: none
|
--selection-toolbar-button-box-shadow: none; // default: none
|
||||||
|
|
||||||
@@ -45,14 +57,19 @@ html {
|
|||||||
--selection-toolbar-button-text-color-hover: var(--selection-toolbar-color-primary);
|
--selection-toolbar-button-text-color-hover: var(--selection-toolbar-color-primary);
|
||||||
--selection-toolbar-button-icon-color-hover: var(--selection-toolbar-color-primary);
|
--selection-toolbar-button-icon-color-hover: var(--selection-toolbar-color-primary);
|
||||||
--selection-toolbar-button-bgcolor: transparent; // default: transparent
|
--selection-toolbar-button-bgcolor: transparent; // default: transparent
|
||||||
--selection-toolbar-button-bgcolor-hover: #222222;
|
--selection-toolbar-button-bgcolor-hover: #333333;
|
||||||
}
|
}
|
||||||
|
|
||||||
[theme-mode='light'] {
|
[theme-mode='light'] {
|
||||||
--selection-toolbar-border: 1px solid rgba(200, 200, 200, 0.5);
|
--selection-toolbar-border: none;
|
||||||
--selection-toolbar-box-shadow: 0px 2px 3px rgba(50, 50, 50, 0.3);
|
--selection-toolbar-box-shadow: 0px 2px 3px rgba(50, 50, 50, 0.1);
|
||||||
--selection-toolbar-background: rgba(245, 245, 245, 0.95);
|
--selection-toolbar-background: rgba(245, 245, 245, 0.95);
|
||||||
|
|
||||||
|
// Buttons
|
||||||
|
--selection-toolbar-buttons-border-color: rgba(0, 0, 0, 0.08);
|
||||||
|
|
||||||
|
--selection-toolbar-logo-border-color: rgba(0, 0, 0, 0.08);
|
||||||
|
|
||||||
--selection-toolbar-button-text-color: rgba(0, 0, 0, 1);
|
--selection-toolbar-button-text-color: rgba(0, 0, 0, 1);
|
||||||
--selection-toolbar-button-icon-color: var(--selection-toolbar-button-text-color);
|
--selection-toolbar-button-icon-color: var(--selection-toolbar-button-text-color);
|
||||||
--selection-toolbar-button-text-color-hover: var(--selection-toolbar-color-primary);
|
--selection-toolbar-button-text-color-hover: var(--selection-toolbar-color-primary);
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const Artifacts: FC<Props> = ({ html }) => {
|
|||||||
* 在应用内打开
|
* 在应用内打开
|
||||||
*/
|
*/
|
||||||
const handleOpenInApp = async () => {
|
const handleOpenInApp = async () => {
|
||||||
const path = await window.api.file.create('artifacts-preview.html')
|
const path = await window.api.file.createTempFile('artifacts-preview.html')
|
||||||
await window.api.file.write(path, html)
|
await window.api.file.write(path, html)
|
||||||
const filePath = `file://${path}`
|
const filePath = `file://${path}`
|
||||||
const title = extractTitle(html) || 'Artifacts ' + t('chat.artifacts.button.preview')
|
const title = extractTitle(html) || 'Artifacts ' + t('chat.artifacts.button.preview')
|
||||||
@@ -35,7 +35,7 @@ const Artifacts: FC<Props> = ({ html }) => {
|
|||||||
* 外部链接打开
|
* 外部链接打开
|
||||||
*/
|
*/
|
||||||
const handleOpenExternal = async () => {
|
const handleOpenExternal = async () => {
|
||||||
const path = await window.api.file.create('artifacts-preview.html')
|
const path = await window.api.file.createTempFile('artifacts-preview.html')
|
||||||
await window.api.file.write(path, html)
|
await window.api.file.write(path, html)
|
||||||
const filePath = `file://${path}`
|
const filePath = `file://${path}`
|
||||||
|
|
||||||
|
|||||||
7
src/renderer/src/components/Icons/OcrIcon.tsx
Normal file
7
src/renderer/src/components/Icons/OcrIcon.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { FC } from 'react'
|
||||||
|
|
||||||
|
const OcrIcon: FC<React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>> = (props) => {
|
||||||
|
return <i {...props} className={`iconfont icon-OCRshibie ${props.className}`} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OcrIcon
|
||||||
7
src/renderer/src/components/Icons/ToolIcon.tsx
Normal file
7
src/renderer/src/components/Icons/ToolIcon.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { FC } from 'react'
|
||||||
|
|
||||||
|
const ToolIcon: FC<React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>> = (props) => {
|
||||||
|
return <i {...props} className={`iconfont icon-plugin ${props.className}`} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ToolIcon
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
Folder,
|
Folder,
|
||||||
Languages,
|
Languages,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
|
MemoryStick,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Moon,
|
Moon,
|
||||||
Palette,
|
Palette,
|
||||||
@@ -155,7 +156,8 @@ const MainMenus: FC = () => {
|
|||||||
translate: <Languages size={18} className="icon" />,
|
translate: <Languages size={18} className="icon" />,
|
||||||
minapp: <LayoutGrid size={18} className="icon" />,
|
minapp: <LayoutGrid size={18} className="icon" />,
|
||||||
knowledge: <FileSearch size={18} className="icon" />,
|
knowledge: <FileSearch size={18} className="icon" />,
|
||||||
files: <Folder size={17} className="icon" />
|
files: <Folder size={17} className="icon" />,
|
||||||
|
memory: <MemoryStick size={18} className="icon" />
|
||||||
}
|
}
|
||||||
|
|
||||||
const pathMap = {
|
const pathMap = {
|
||||||
@@ -165,7 +167,8 @@ const MainMenus: FC = () => {
|
|||||||
translate: '/translate',
|
translate: '/translate',
|
||||||
minapp: '/apps',
|
minapp: '/apps',
|
||||||
knowledge: '/knowledge',
|
knowledge: '/knowledge',
|
||||||
files: '/files'
|
files: '/files',
|
||||||
|
memory: '/memory'
|
||||||
}
|
}
|
||||||
|
|
||||||
return sidebarIcons.visible.map((icon) => {
|
return sidebarIcons.visible.map((icon) => {
|
||||||
|
|||||||
12
src/renderer/src/config/ocrProviders.ts
Normal file
12
src/renderer/src/config/ocrProviders.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import MacOSLogo from '@renderer/assets/images/providers/macos.svg'
|
||||||
|
|
||||||
|
export function getOcrProviderLogo(providerId: string) {
|
||||||
|
switch (providerId) {
|
||||||
|
case 'system':
|
||||||
|
return MacOSLogo
|
||||||
|
default:
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OCR_PROVIDER_CONFIG = {}
|
||||||
37
src/renderer/src/config/preprocessProviders.ts
Normal file
37
src/renderer/src/config/preprocessProviders.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import Doc2xLogo from '@renderer/assets/images/ocr/doc2x.png'
|
||||||
|
import MinerULogo from '@renderer/assets/images/ocr/mineru.jpg'
|
||||||
|
import MistralLogo from '@renderer/assets/images/providers/mistral.png'
|
||||||
|
|
||||||
|
export function getPreprocessProviderLogo(providerId: string) {
|
||||||
|
switch (providerId) {
|
||||||
|
case 'doc2x':
|
||||||
|
return Doc2xLogo
|
||||||
|
case 'mistral':
|
||||||
|
return MistralLogo
|
||||||
|
case 'mineru':
|
||||||
|
return MinerULogo
|
||||||
|
default:
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PREPROCESS_PROVIDER_CONFIG = {
|
||||||
|
doc2x: {
|
||||||
|
websites: {
|
||||||
|
official: 'https://doc2x.noedgeai.com',
|
||||||
|
apiKey: 'https://open.noedgeai.com/apiKeys'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mistral: {
|
||||||
|
websites: {
|
||||||
|
official: 'https://mistral.ai',
|
||||||
|
apiKey: 'https://mistral.ai/api-keys'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mineru: {
|
||||||
|
websites: {
|
||||||
|
official: 'https://mineru.net/',
|
||||||
|
apiKey: 'https://mineru.net/apiManage'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { FileType, KnowledgeItem, QuickPhrase, TranslateHistory } from '@renderer/types'
|
import { FileMetadata, KnowledgeItem, QuickPhrase, TranslateHistory } from '@renderer/types'
|
||||||
// Import necessary types for blocks and new message structure
|
// Import necessary types for blocks and new message structure
|
||||||
import type { Message as NewMessage, MessageBlock } from '@renderer/types/newMessage'
|
import type { Message as NewMessage, MessageBlock } from '@renderer/types/newMessage'
|
||||||
import { Dexie, type EntityTable } from 'dexie'
|
import { Dexie, type EntityTable } from 'dexie'
|
||||||
@@ -7,7 +7,7 @@ import { upgradeToV5, upgradeToV7 } from './upgrades'
|
|||||||
|
|
||||||
// Database declaration (move this to its own module also)
|
// Database declaration (move this to its own module also)
|
||||||
export const db = new Dexie('CherryStudio') as Dexie & {
|
export const db = new Dexie('CherryStudio') as Dexie & {
|
||||||
files: EntityTable<FileType, 'id'>
|
files: EntityTable<FileMetadata, 'id'>
|
||||||
topics: EntityTable<{ id: string; messages: NewMessage[] }, 'id'> // Correct type for topics
|
topics: EntityTable<{ id: string; messages: NewMessage[] }, 'id'> // Correct type for topics
|
||||||
settings: EntityTable<{ id: string; value: any }, 'id'>
|
settings: EntityTable<{ id: string; value: any }, 'id'>
|
||||||
knowledge_notes: EntityTable<KnowledgeItem, 'id'>
|
knowledge_notes: EntityTable<KnowledgeItem, 'id'>
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import { useTheme } from '@renderer/context/ThemeProvider'
|
|||||||
import db from '@renderer/databases'
|
import db from '@renderer/databases'
|
||||||
import i18n from '@renderer/i18n'
|
import i18n from '@renderer/i18n'
|
||||||
import KnowledgeQueue from '@renderer/queue/KnowledgeQueue'
|
import KnowledgeQueue from '@renderer/queue/KnowledgeQueue'
|
||||||
|
import MemoryService from '@renderer/services/MemoryService'
|
||||||
import { useAppDispatch } from '@renderer/store'
|
import { useAppDispatch } from '@renderer/store'
|
||||||
|
import { useAppSelector } from '@renderer/store'
|
||||||
|
import { selectMemoryConfig } from '@renderer/store/memory'
|
||||||
import { setAvatar, setFilesPath, setResourcesPath, setUpdateState } from '@renderer/store/runtime'
|
import { setAvatar, setFilesPath, setResourcesPath, setUpdateState } from '@renderer/store/runtime'
|
||||||
import { delay, runAsyncFunction } from '@renderer/utils'
|
import { delay, runAsyncFunction } from '@renderer/utils'
|
||||||
import { defaultLanguage } from '@shared/config/constant'
|
import { defaultLanguage } from '@shared/config/constant'
|
||||||
@@ -24,10 +27,14 @@ export function useAppInit() {
|
|||||||
const { setDefaultModel, setTopicNamingModel, setTranslateModel } = useDefaultModel()
|
const { setDefaultModel, setTopicNamingModel, setTranslateModel } = useDefaultModel()
|
||||||
const avatar = useLiveQuery(() => db.settings.get('image://avatar'))
|
const avatar = useLiveQuery(() => db.settings.get('image://avatar'))
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
|
const memoryConfig = useAppSelector(selectMemoryConfig)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.getElementById('spinner')?.remove()
|
document.getElementById('spinner')?.remove()
|
||||||
console.timeEnd('init')
|
console.timeEnd('init')
|
||||||
|
|
||||||
|
// Initialize MemoryService after app is ready
|
||||||
|
MemoryService.getInstance()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -121,4 +128,12 @@ export function useAppInit() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// TODO: init data collection
|
// TODO: init data collection
|
||||||
}, [enableDataCollection])
|
}, [enableDataCollection])
|
||||||
|
|
||||||
|
// Update memory service configuration when it changes
|
||||||
|
useEffect(() => {
|
||||||
|
const memoryService = MemoryService.getInstance()
|
||||||
|
memoryService.updateConfig().catch((error) => {
|
||||||
|
console.error('Failed to update memory config:', error)
|
||||||
|
})
|
||||||
|
}, [memoryConfig])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
/* eslint-disable react-hooks/rules-of-hooks */
|
|
||||||
import { db } from '@renderer/databases'
|
import { db } from '@renderer/databases'
|
||||||
import KnowledgeQueue from '@renderer/queue/KnowledgeQueue'
|
import KnowledgeQueue from '@renderer/queue/KnowledgeQueue'
|
||||||
import FileManager from '@renderer/services/FileManager'
|
|
||||||
import { getKnowledgeBaseParams } from '@renderer/services/KnowledgeService'
|
import { getKnowledgeBaseParams } from '@renderer/services/KnowledgeService'
|
||||||
import { RootState } from '@renderer/store'
|
import { RootState } from '@renderer/store'
|
||||||
import {
|
import {
|
||||||
@@ -19,10 +17,9 @@ import {
|
|||||||
updateItemProcessingStatus,
|
updateItemProcessingStatus,
|
||||||
updateNotes
|
updateNotes
|
||||||
} from '@renderer/store/knowledge'
|
} from '@renderer/store/knowledge'
|
||||||
import { FileType, KnowledgeBase, KnowledgeItem, ProcessingStatus } from '@renderer/types'
|
import { FileMetadata, KnowledgeBase, KnowledgeItem, ProcessingStatus } from '@renderer/types'
|
||||||
import { runAsyncFunction } from '@renderer/utils'
|
import { runAsyncFunction } from '@renderer/utils'
|
||||||
import { IpcChannel } from '@shared/IpcChannel'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { useDispatch, useSelector } from 'react-redux'
|
import { useDispatch, useSelector } from 'react-redux'
|
||||||
import { v4 as uuidv4 } from 'uuid'
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
|
|
||||||
@@ -44,7 +41,7 @@ export const useKnowledge = (baseId: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 批量添加文件
|
// 批量添加文件
|
||||||
const addFiles = (files: FileType[]) => {
|
const addFiles = (files: FileMetadata[]) => {
|
||||||
const filesItems: KnowledgeItem[] = files.map((file) => ({
|
const filesItems: KnowledgeItem[] = files.map((file) => ({
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
type: 'file' as const,
|
type: 'file' as const,
|
||||||
@@ -56,6 +53,7 @@ export const useKnowledge = (baseId: string) => {
|
|||||||
processingError: '',
|
processingError: '',
|
||||||
retryCount: 0
|
retryCount: 0
|
||||||
}))
|
}))
|
||||||
|
console.log('Adding files:', filesItems)
|
||||||
dispatch(addFilesAction({ baseId, items: filesItems }))
|
dispatch(addFilesAction({ baseId, items: filesItems }))
|
||||||
setTimeout(() => KnowledgeQueue.checkAllBases(), 0)
|
setTimeout(() => KnowledgeQueue.checkAllBases(), 0)
|
||||||
}
|
}
|
||||||
@@ -147,7 +145,7 @@ export const useKnowledge = (baseId: string) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (item.type === 'file' && typeof item.content === 'object') {
|
if (item.type === 'file' && typeof item.content === 'object') {
|
||||||
await FileManager.deleteFile(item.content.id)
|
await window.api.file.deleteDir(item.content.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 刷新项目
|
// 刷新项目
|
||||||
@@ -190,41 +188,18 @@ export const useKnowledge = (baseId: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 获取特定项目的处理状态
|
// 获取特定项目的处理状态
|
||||||
const getProcessingStatus = (itemId: string) => {
|
const getProcessingStatus = useCallback(
|
||||||
return base?.items.find((item) => item.id === itemId)?.processingStatus
|
(itemId: string) => {
|
||||||
}
|
return base?.items.find((item) => item.id === itemId)?.processingStatus
|
||||||
|
},
|
||||||
|
[base?.items]
|
||||||
|
)
|
||||||
|
|
||||||
// 获取特定类型的所有处理项
|
// 获取特定类型的所有处理项
|
||||||
const getProcessingItemsByType = (type: 'file' | 'url' | 'note') => {
|
const getProcessingItemsByType = (type: 'file' | 'url' | 'note') => {
|
||||||
return base?.items.filter((item) => item.type === type && item.processingStatus !== undefined) || []
|
return base?.items.filter((item) => item.type === type && item.processingStatus !== undefined) || []
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取目录处理进度
|
|
||||||
const getDirectoryProcessingPercent = (itemId?: string) => {
|
|
||||||
const [percent, setPercent] = useState<number>(0)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!itemId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const cleanup = window.electron.ipcRenderer.on(
|
|
||||||
IpcChannel.DirectoryProcessingPercent,
|
|
||||||
(_, { itemId: id, percent }: { itemId: string; percent: number }) => {
|
|
||||||
if (itemId === id) {
|
|
||||||
setPercent(percent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cleanup()
|
|
||||||
}
|
|
||||||
}, [itemId])
|
|
||||||
|
|
||||||
return percent
|
|
||||||
}
|
|
||||||
|
|
||||||
// 清除已完成的项目
|
// 清除已完成的项目
|
||||||
const clearCompleted = () => {
|
const clearCompleted = () => {
|
||||||
dispatch(clearCompletedProcessing({ baseId }))
|
dispatch(clearCompletedProcessing({ baseId }))
|
||||||
@@ -307,7 +282,6 @@ export const useKnowledge = (baseId: string) => {
|
|||||||
refreshItem,
|
refreshItem,
|
||||||
getProcessingStatus,
|
getProcessingStatus,
|
||||||
getProcessingItemsByType,
|
getProcessingItemsByType,
|
||||||
getDirectoryProcessingPercent,
|
|
||||||
clearCompleted,
|
clearCompleted,
|
||||||
clearAll,
|
clearAll,
|
||||||
removeItem,
|
removeItem,
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import FileManager from '@renderer/services/FileManager'
|
import FileManager from '@renderer/services/FileManager'
|
||||||
import { FileType } from '@renderer/types'
|
import { FileMetadata } from '@renderer/types'
|
||||||
import { isEmpty } from 'lodash'
|
import { isEmpty } from 'lodash'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
import { useKnowledgeBases } from './useKnowledge'
|
import { useKnowledgeBases } from './useKnowledge'
|
||||||
|
|
||||||
export const useKnowledgeFiles = () => {
|
export const useKnowledgeFiles = () => {
|
||||||
const [knowledgeFiles, setKnowledgeFiles] = useState<FileType[]>([])
|
const [knowledgeFiles, setKnowledgeFiles] = useState<FileMetadata[]>([])
|
||||||
const { bases, updateKnowledgeBases } = useKnowledgeBases()
|
const { bases, updateKnowledgeBases } = useKnowledgeBases()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -16,7 +16,7 @@ export const useKnowledgeFiles = () => {
|
|||||||
.filter((item) => item.type === 'file')
|
.filter((item) => item.type === 'file')
|
||||||
.filter((item) => item.processingStatus === 'completed')
|
.filter((item) => item.processingStatus === 'completed')
|
||||||
|
|
||||||
const files = fileItems.map((item) => item.content as FileType)
|
const files = fileItems.map((item) => item.content as FileMetadata)
|
||||||
|
|
||||||
!isEmpty(files) && setKnowledgeFiles(files)
|
!isEmpty(files) && setKnowledgeFiles(files)
|
||||||
}, [bases])
|
}, [bases])
|
||||||
@@ -31,7 +31,7 @@ export const useKnowledgeFiles = () => {
|
|||||||
? {
|
? {
|
||||||
...item,
|
...item,
|
||||||
content: {
|
content: {
|
||||||
...(item.content as FileType),
|
...(item.content as FileMetadata),
|
||||||
size: 0
|
size: 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
45
src/renderer/src/hooks/useOcr.ts
Normal file
45
src/renderer/src/hooks/useOcr.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { RootState } from '@renderer/store'
|
||||||
|
import {
|
||||||
|
setDefaultOcrProvider as _setDefaultOcrProvider,
|
||||||
|
updateOcrProvider as _updateOcrProvider,
|
||||||
|
updateOcrProviders as _updateOcrProviders
|
||||||
|
} from '@renderer/store/ocr'
|
||||||
|
import { OcrProvider } from '@renderer/types'
|
||||||
|
import { useDispatch, useSelector } from 'react-redux'
|
||||||
|
|
||||||
|
export const useOcrProvider = (id: string) => {
|
||||||
|
const dispatch = useDispatch()
|
||||||
|
const ocrProviders = useSelector((state: RootState) => state.ocr.providers)
|
||||||
|
const provider = ocrProviders.find((provider) => provider.id === id)
|
||||||
|
if (!provider) {
|
||||||
|
throw new Error(`OCR provider with id ${id} not found`)
|
||||||
|
}
|
||||||
|
const updateOcrProvider = (ocrProvider: OcrProvider) => {
|
||||||
|
dispatch(_updateOcrProvider(ocrProvider))
|
||||||
|
}
|
||||||
|
return { provider, updateOcrProvider }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useOcrProviders = () => {
|
||||||
|
const dispatch = useDispatch()
|
||||||
|
const ocrProviders = useSelector((state: RootState) => state.ocr.providers)
|
||||||
|
return {
|
||||||
|
ocrProviders: ocrProviders,
|
||||||
|
updateOcrProviders: (ocrProviders: OcrProvider[]) => dispatch(_updateOcrProviders(ocrProviders))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDefaultOcrProvider = () => {
|
||||||
|
const defaultProviderId = useSelector((state: RootState) => state.ocr.defaultProvider)
|
||||||
|
const { ocrProviders } = useOcrProviders()
|
||||||
|
const dispatch = useDispatch()
|
||||||
|
const provider = defaultProviderId ? ocrProviders.find((provider) => provider.id === defaultProviderId) : undefined
|
||||||
|
|
||||||
|
const setDefaultOcrProvider = (ocrProvider: OcrProvider) => {
|
||||||
|
dispatch(_setDefaultOcrProvider(ocrProvider.id))
|
||||||
|
}
|
||||||
|
const updateDefaultOcrProvider = (ocrProvider: OcrProvider) => {
|
||||||
|
dispatch(_updateOcrProvider(ocrProvider))
|
||||||
|
}
|
||||||
|
return { provider, setDefaultOcrProvider, updateDefaultOcrProvider }
|
||||||
|
}
|
||||||
48
src/renderer/src/hooks/usePreprocess.ts
Normal file
48
src/renderer/src/hooks/usePreprocess.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { RootState } from '@renderer/store'
|
||||||
|
import {
|
||||||
|
setDefaultPreprocessProvider as _setDefaultPreprocessProvider,
|
||||||
|
updatePreprocessProvider as _updatePreprocessProvider,
|
||||||
|
updatePreprocessProviders as _updatePreprocessProviders
|
||||||
|
} from '@renderer/store/preprocess'
|
||||||
|
import { PreprocessProvider } from '@renderer/types'
|
||||||
|
import { useDispatch, useSelector } from 'react-redux'
|
||||||
|
|
||||||
|
export const usePreprocessProvider = (id: string) => {
|
||||||
|
const dispatch = useDispatch()
|
||||||
|
const preprocessProviders = useSelector((state: RootState) => state.preprocess.providers)
|
||||||
|
const provider = preprocessProviders.find((provider) => provider.id === id)
|
||||||
|
if (!provider) {
|
||||||
|
throw new Error(`preprocess provider with id ${id} not found`)
|
||||||
|
}
|
||||||
|
const updatePreprocessProvider = (preprocessProvider: PreprocessProvider) => {
|
||||||
|
dispatch(_updatePreprocessProvider(preprocessProvider))
|
||||||
|
}
|
||||||
|
return { provider, updatePreprocessProvider }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePreprocessProviders = () => {
|
||||||
|
const dispatch = useDispatch()
|
||||||
|
const preprocessProviders = useSelector((state: RootState) => state.preprocess.providers)
|
||||||
|
return {
|
||||||
|
preprocessProviders: preprocessProviders,
|
||||||
|
updatePreprocessProviders: (preprocessProviders: PreprocessProvider[]) =>
|
||||||
|
dispatch(_updatePreprocessProviders(preprocessProviders))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDefaultPreprocessProvider = () => {
|
||||||
|
const defaultProviderId = useSelector((state: RootState) => state.preprocess.defaultProvider)
|
||||||
|
const { preprocessProviders } = usePreprocessProviders()
|
||||||
|
const dispatch = useDispatch()
|
||||||
|
const provider = defaultProviderId
|
||||||
|
? preprocessProviders.find((provider) => provider.id === defaultProviderId)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const setDefaultPreprocessProvider = (preprocessProvider: PreprocessProvider) => {
|
||||||
|
dispatch(_setDefaultPreprocessProvider(preprocessProvider.id))
|
||||||
|
}
|
||||||
|
const updateDefaultPreprocessProvider = (preprocessProvider: PreprocessProvider) => {
|
||||||
|
dispatch(_updatePreprocessProvider(preprocessProvider))
|
||||||
|
}
|
||||||
|
return { provider, setDefaultPreprocessProvider, updateDefaultPreprocessProvider }
|
||||||
|
}
|
||||||
@@ -190,7 +190,7 @@
|
|||||||
"input.translate": "Translate to {{target_language}}",
|
"input.translate": "Translate to {{target_language}}",
|
||||||
"input.upload": "Upload image or document file",
|
"input.upload": "Upload image or document file",
|
||||||
"input.upload.document": "Upload document file (model does not support images)",
|
"input.upload.document": "Upload document file (model does not support images)",
|
||||||
"input.web_search": "Web search",
|
"input.web_search": "Web Search",
|
||||||
"input.web_search.settings": "Web Search Settings",
|
"input.web_search.settings": "Web Search Settings",
|
||||||
"input.web_search.button.ok": "Go to Settings",
|
"input.web_search.button.ok": "Go to Settings",
|
||||||
"input.web_search.enable": "Enable web search",
|
"input.web_search.enable": "Enable web search",
|
||||||
@@ -406,9 +406,11 @@
|
|||||||
"prompt": "Prompt",
|
"prompt": "Prompt",
|
||||||
"provider": "Provider",
|
"provider": "Provider",
|
||||||
"regenerate": "Regenerate",
|
"regenerate": "Regenerate",
|
||||||
|
"refresh": "Refresh",
|
||||||
"rename": "Rename",
|
"rename": "Rename",
|
||||||
"reset": "Reset",
|
"reset": "Reset",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
|
"settings": "Settings",
|
||||||
"search": "Search",
|
"search": "Search",
|
||||||
"select": "Select",
|
"select": "Select",
|
||||||
"selectedMessages": "Selected {{count}} messages",
|
"selectedMessages": "Selected {{count}} messages",
|
||||||
@@ -423,7 +425,9 @@
|
|||||||
"pinyin.asc": "Sort by Pinyin (A-Z)",
|
"pinyin.asc": "Sort by Pinyin (A-Z)",
|
||||||
"pinyin.desc": "Sort by Pinyin (Z-A)"
|
"pinyin.desc": "Sort by Pinyin (Z-A)"
|
||||||
},
|
},
|
||||||
"no_results": "No results"
|
"no_results": "No results",
|
||||||
|
"enabled": "Enabled",
|
||||||
|
"disabled": "Disabled"
|
||||||
},
|
},
|
||||||
"docs": {
|
"docs": {
|
||||||
"title": "Docs"
|
"title": "Docs"
|
||||||
@@ -543,13 +547,21 @@
|
|||||||
"rename": "Rename",
|
"rename": "Rename",
|
||||||
"search": "Search knowledge base",
|
"search": "Search knowledge base",
|
||||||
"search_placeholder": "Enter text to search",
|
"search_placeholder": "Enter text to search",
|
||||||
"settings": "Knowledge Base Settings",
|
"settings": {
|
||||||
|
"title": "Knowledge Base Settings",
|
||||||
|
"preprocessing": "Preprocessing",
|
||||||
|
"preprocessing_tooltip": "Preprocess uploaded files with OCR"
|
||||||
|
},
|
||||||
"sitemap_placeholder": "Enter Website Map URL",
|
"sitemap_placeholder": "Enter Website Map URL",
|
||||||
"sitemaps": "Websites",
|
"sitemaps": "Websites",
|
||||||
"source": "Source",
|
"source": "Source",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
"status_completed": "Completed",
|
"status_completed": "Completed",
|
||||||
|
"status_embedding_completed": "Embedding Completed",
|
||||||
|
"status_preprocess_completed": "Preprocessing Completed",
|
||||||
"status_failed": "Failed",
|
"status_failed": "Failed",
|
||||||
|
"status_embedding_failed": "Embedding Failed",
|
||||||
|
"status_preprocess_failed": "Preprocessing Failed",
|
||||||
"status_new": "Added",
|
"status_new": "Added",
|
||||||
"status_pending": "Pending",
|
"status_pending": "Pending",
|
||||||
"status_processing": "Processing",
|
"status_processing": "Processing",
|
||||||
@@ -572,7 +584,9 @@
|
|||||||
"dimensions_error_invalid": "Please enter embedding dimension size",
|
"dimensions_error_invalid": "Please enter embedding dimension size",
|
||||||
"dimensions_size_too_large": "The embedding dimension cannot exceed the model's context limit ({{max_context}}).",
|
"dimensions_size_too_large": "The embedding dimension cannot exceed the model's context limit ({{max_context}}).",
|
||||||
"dimensions_set_right": "⚠️ Please ensure the model supports the set embedding dimension size",
|
"dimensions_set_right": "⚠️ Please ensure the model supports the set embedding dimension size",
|
||||||
"dimensions_default": "The model will use default embedding dimensions"
|
"dimensions_default": "The model will use default embedding dimensions",
|
||||||
|
"quota": "{{name}} Left Quota: {{quota}}",
|
||||||
|
"quota_infinity": "{{name}} Quota: Unlimited"
|
||||||
},
|
},
|
||||||
"languages": {
|
"languages": {
|
||||||
"arabic": "Arabic",
|
"arabic": "Arabic",
|
||||||
@@ -835,7 +849,7 @@
|
|||||||
"notification": {
|
"notification": {
|
||||||
"assistant": "Assistant Response",
|
"assistant": "Assistant Response",
|
||||||
"knowledge.success": "Successfully added {{type}} to the knowledge base",
|
"knowledge.success": "Successfully added {{type}} to the knowledge base",
|
||||||
"knowledge.error": "Failed to add {{type}} to knowledge base: {{error}}"
|
"knowledge.error": "{{error}}"
|
||||||
},
|
},
|
||||||
"ollama": {
|
"ollama": {
|
||||||
"keep_alive_time.description": "The time in minutes to keep the connection alive, default is 5 minutes.",
|
"keep_alive_time.description": "The time in minutes to keep the connection alive, default is 5 minutes.",
|
||||||
@@ -1819,64 +1833,88 @@
|
|||||||
"tray.onclose": "Minimize to Tray on Close",
|
"tray.onclose": "Minimize to Tray on Close",
|
||||||
"tray.show": "Show Tray Icon",
|
"tray.show": "Show Tray Icon",
|
||||||
"tray.title": "Tray",
|
"tray.title": "Tray",
|
||||||
"websearch": {
|
"tool": {
|
||||||
"blacklist": "Blacklist",
|
"title": "Tools Settings",
|
||||||
"blacklist_description": "Results from the following websites will not appear in search results",
|
"preprocessOrOcr.tooltip": "In Settings -> Tools, set a document preprocessing service provider or OCR. Document preprocessing can effectively improve the retrieval performance of complex format documents and scanned documents. OCR can only recognize text within images in documents or scanned PDF text.",
|
||||||
"blacklist_tooltip": "Please use the following format (separated by newlines)\nPattern matching: *://*.example.com/*\nRegular expression: /example\\.(net|org)/",
|
"preprocess": {
|
||||||
"check": "Check",
|
"title": "Pre Process",
|
||||||
"check_failed": "Verification failed",
|
"provider": "Pre Process Provider",
|
||||||
"check_success": "Verification successful",
|
"provider_placeholder": "Choose a Pre Process provider"
|
||||||
"get_api_key": "Get API Key",
|
|
||||||
"no_provider_selected": "Please select a search service provider before checking.",
|
|
||||||
"search_max_result": "Number of search results",
|
|
||||||
"search_provider": "Search service provider",
|
|
||||||
"search_provider_placeholder": "Choose a search service provider.",
|
|
||||||
"search_result_default": "Default",
|
|
||||||
"search_with_time": "Search with dates included",
|
|
||||||
"tavily": {
|
|
||||||
"api_key": "Tavily API Key",
|
|
||||||
"api_key.placeholder": "Enter Tavily API Key",
|
|
||||||
"description": "Tavily is a search engine tailored for AI agents, delivering real-time, accurate results, intelligent query suggestions, and in-depth research capabilities.",
|
|
||||||
"title": "Tavily"
|
|
||||||
},
|
},
|
||||||
"title": "Web Search",
|
"ocr": {
|
||||||
"subscribe": "Blacklist Subscription",
|
"title": "OCR",
|
||||||
"subscribe_update": "Update",
|
"provider": "OCR Provider",
|
||||||
"subscribe_add": "Add Subscription",
|
"provider_placeholder": "Choose an OCR provider",
|
||||||
"subscribe_url": "Subscription Url",
|
"mac_system_ocr_options": {
|
||||||
"subscribe_name": "Alternative name",
|
"mode": {
|
||||||
"subscribe_name.placeholder": "Alternative name used when the downloaded subscription feed has no name.",
|
"title": "Recognition Mode",
|
||||||
"subscribe_add_success": "Subscription feed added successfully!",
|
"accurate": "Accurate",
|
||||||
"subscribe_delete": "Delete",
|
"fast": "Fast"
|
||||||
"overwrite": "Override search service",
|
},
|
||||||
"overwrite_tooltip": "Force use search service instead of LLM",
|
"min_confidence": "Minimum Confidence"
|
||||||
"apikey": "API key",
|
}
|
||||||
"free": "Free",
|
},
|
||||||
"compression": {
|
"websearch": {
|
||||||
"title": "Search Result Compression",
|
"blacklist": "Blacklist",
|
||||||
"method": "Compression Method",
|
"blacklist_description": "Results from the following websites will not appear in search results",
|
||||||
"method.none": "None",
|
"blacklist_tooltip": "Please use the following format (separated by newlines)\nPattern matching: *://*.example.com/*\nRegular expression: /example\\.(net|org)/",
|
||||||
"method.cutoff": "Cutoff",
|
"check": "Check",
|
||||||
"cutoff.limit": "Cutoff Limit",
|
"check_failed": "Verification failed",
|
||||||
"cutoff.limit.placeholder": "Enter length",
|
"check_success": "Verification successful",
|
||||||
"cutoff.limit.tooltip": "Limit the content length of search results, content exceeding the limit will be truncated (e.g., 2000 characters)",
|
"get_api_key": "Get API Key",
|
||||||
"cutoff.unit.char": "Char",
|
"no_provider_selected": "Please select a search service provider before checking.",
|
||||||
"cutoff.unit.token": "Token",
|
"search_max_result": "Number of search results",
|
||||||
"method.rag": "RAG",
|
"search_provider": "Search service provider",
|
||||||
"rag.document_count": "Document Count",
|
"search_provider_placeholder": "Choose a search service provider.",
|
||||||
"rag.document_count.default": "Default",
|
"search_result_default": "Default",
|
||||||
"rag.document_count.tooltip": "Expected number of documents to extract from each search result, the actual total number of extracted documents is this value multiplied by the number of search results.",
|
"search_with_time": "Search with dates included",
|
||||||
"rag.embedding_dimensions.auto_get": "Auto Get Dimensions",
|
"tavily": {
|
||||||
"rag.embedding_dimensions.placeholder": "Leave empty",
|
"api_key": "Tavily API Key",
|
||||||
"rag.embedding_dimensions.tooltip": "If left blank, the dimensions parameter will not be passed",
|
"api_key.placeholder": "Enter Tavily API Key",
|
||||||
"info": {
|
"description": "Tavily is a search engine tailored for AI agents, delivering real-time, accurate results, intelligent query suggestions, and in-depth research capabilities.",
|
||||||
"dimensions_auto_success": "Dimensions auto-obtained successfully, dimensions: {{dimensions}}"
|
"title": "Tavily"
|
||||||
},
|
},
|
||||||
"error": {
|
"content_limit": "Content length limit",
|
||||||
"embedding_model_required": "Please select an embedding model first",
|
"content_limit_tooltip": "Limit the content length of the search results; content that exceeds the limit will be truncated.",
|
||||||
"dimensions_auto_failed": "Failed to auto-obtain dimensions",
|
"title": "Web Search",
|
||||||
"provider_not_found": "Provider not found",
|
"subscribe": "Blacklist Subscription",
|
||||||
"rag_failed": "RAG failed"
|
"subscribe_update": "Update",
|
||||||
|
"subscribe_add": "Add Subscription",
|
||||||
|
"subscribe_url": "Subscription Url",
|
||||||
|
"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",
|
||||||
|
"overwrite": "Override search service",
|
||||||
|
"overwrite_tooltip": "Force use search service instead of LLM",
|
||||||
|
"apikey": "API key",
|
||||||
|
"free": "Free",
|
||||||
|
"compression": {
|
||||||
|
"title": "Search Result Compression",
|
||||||
|
"method": "Compression Method",
|
||||||
|
"method.none": "None",
|
||||||
|
"method.cutoff": "Cutoff",
|
||||||
|
"cutoff.limit": "Cutoff Limit",
|
||||||
|
"cutoff.limit.placeholder": "Enter length",
|
||||||
|
"cutoff.limit.tooltip": "Limit the content length of search results, content exceeding the limit will be truncated (e.g., 2000 characters)",
|
||||||
|
"cutoff.unit.char": "Char",
|
||||||
|
"cutoff.unit.token": "Token",
|
||||||
|
"method.rag": "RAG",
|
||||||
|
"rag.document_count": "Document Count",
|
||||||
|
"rag.document_count.default": "Default",
|
||||||
|
"rag.document_count.tooltip": "Expected number of documents to extract from each search result, the actual total number of extracted documents is this value multiplied by the number of search results.",
|
||||||
|
"rag.embedding_dimensions.auto_get": "Auto Get Dimensions",
|
||||||
|
"rag.embedding_dimensions.placeholder": "Leave empty",
|
||||||
|
"rag.embedding_dimensions.tooltip": "If left blank, the dimensions parameter will not be passed",
|
||||||
|
"info": {
|
||||||
|
"dimensions_auto_success": "Dimensions auto-obtained successfully, dimensions: {{dimensions}}"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"embedding_model_required": "Please select an embedding model first",
|
||||||
|
"dimensions_auto_failed": "Failed to auto-obtain dimensions",
|
||||||
|
"provider_not_found": "Provider not found",
|
||||||
|
"rag_failed": "RAG failed"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1925,7 +1963,8 @@
|
|||||||
"service_tier.auto": "auto",
|
"service_tier.auto": "auto",
|
||||||
"service_tier.default": "default",
|
"service_tier.default": "default",
|
||||||
"service_tier.flex": "flex"
|
"service_tier.flex": "flex"
|
||||||
}
|
},
|
||||||
|
"mineru.api_key": "Mineru now offers a daily free quota of 500 pages, and you do not need to enter a key."
|
||||||
},
|
},
|
||||||
"translate": {
|
"translate": {
|
||||||
"any.language": "Any language",
|
"any.language": "Any language",
|
||||||
@@ -2021,14 +2060,29 @@
|
|||||||
"experimental": "Experimental Features",
|
"experimental": "Experimental Features",
|
||||||
"enable": {
|
"enable": {
|
||||||
"title": "Enable",
|
"title": "Enable",
|
||||||
"description": "Currently only supported on Windows systems"
|
"description": "Currently only supported on Windows & macOS",
|
||||||
|
"mac_process_trust_hint": {
|
||||||
|
"title": "Accessibility Permission",
|
||||||
|
"description": [
|
||||||
|
"Selection Assistant requires <strong>Accessibility Permission</strong> to work properly.",
|
||||||
|
"Please click \"<strong>Go to Settings</strong>\" and click the \"<strong>Open System Settings</strong>\" button in the permission request popup that appears later. Then find \"<strong>Cherry Studio</strong>\" in the application list that appears later and turn on the permission switch.",
|
||||||
|
"After completing the settings, please reopen the selection assistant."
|
||||||
|
],
|
||||||
|
"button": {
|
||||||
|
"open_accessibility_settings": "Open Accessibility Settings",
|
||||||
|
"go_to_settings": "Go to Settings"
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"toolbar": {
|
"toolbar": {
|
||||||
"title": "Toolbar",
|
"title": "Toolbar",
|
||||||
"trigger_mode": {
|
"trigger_mode": {
|
||||||
"title": "Trigger Mode",
|
"title": "Trigger Mode",
|
||||||
"description": "The way to trigger the selection assistant and show the toolbar",
|
"description": "The way to trigger the selection assistant and show the toolbar",
|
||||||
"description_note": "Some applications do not support selecting text with the Ctrl key. If you have remapped the Ctrl key using tools like AHK, it may cause some applications to fail to select text.",
|
"description_note": {
|
||||||
|
"windows": "Some applications do not support selecting text with the Ctrl key. If you have remapped the Ctrl key using tools like AHK, it may cause some applications to fail to select text.",
|
||||||
|
"mac": "If you have remapped the ⌘ key using shortcuts or keyboard mapping tools, it may cause some applications to fail to select text."
|
||||||
|
},
|
||||||
"selected": "Selection",
|
"selected": "Selection",
|
||||||
"selected_note": "Show toolbar immediately when text is selected",
|
"selected_note": "Show toolbar immediately when text is selected",
|
||||||
"ctrlkey": "Ctrl Key",
|
"ctrlkey": "Ctrl Key",
|
||||||
@@ -2153,9 +2207,96 @@
|
|||||||
},
|
},
|
||||||
"filter_modal": {
|
"filter_modal": {
|
||||||
"title": "Application Filter List",
|
"title": "Application Filter List",
|
||||||
"user_tips": "Please enter the executable file name of the application, one per line, case insensitive, can be fuzzy matched. For example: chrome.exe, weixin.exe, Cherry Studio.exe, etc."
|
"user_tips": {
|
||||||
|
"windows": "Please enter the executable file name of the application, one per line, case insensitive, can be fuzzy matched. For example: chrome.exe, weixin.exe, Cherry Studio.exe, etc.",
|
||||||
|
"mac": "Please enter the Bundle ID of the application, one per line, case insensitive, can be fuzzy matched. For example: com.google.Chrome, com.apple.mail, etc."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"memory": {
|
||||||
|
"title": "Memories",
|
||||||
|
"description": "Memory allows you to store and manage information about your interactions with the assistant. You can add, edit, and delete memories, as well as filter and search through them.",
|
||||||
|
"add_memory": "Add Memory",
|
||||||
|
"edit_memory": "Edit Memory",
|
||||||
|
"memory_content": "Memory Content",
|
||||||
|
"please_enter_memory": "Please enter memory content",
|
||||||
|
"memory_placeholder": "Enter memory content...",
|
||||||
|
"user_id": "User ID",
|
||||||
|
"user_id_placeholder": "Enter user ID (optional)",
|
||||||
|
"load_failed": "Failed to load memories",
|
||||||
|
"add_success": "Memory added successfully",
|
||||||
|
"add_failed": "Failed to add memory",
|
||||||
|
"update_success": "Memory updated successfully",
|
||||||
|
"update_failed": "Failed to update memory",
|
||||||
|
"delete_success": "Memory deleted successfully",
|
||||||
|
"delete_failed": "Failed to delete memory",
|
||||||
|
"delete_confirm_title": "Delete Memories",
|
||||||
|
"delete_confirm_content": "Are you sure you want to delete {{count}} memories?",
|
||||||
|
"delete_confirm": "Are you sure you want to delete this memory?",
|
||||||
|
"time": "Time",
|
||||||
|
"user": "User",
|
||||||
|
"content": "Content",
|
||||||
|
"score": "Score",
|
||||||
|
"memories_description": "Showing {{count}} of {{total}} memories",
|
||||||
|
"search_placeholder": "Search memories...",
|
||||||
|
"start_date": "Start Date",
|
||||||
|
"end_date": "End Date",
|
||||||
|
"all_users": "All Users",
|
||||||
|
"users": "users",
|
||||||
|
"delete_selected": "Delete Selected",
|
||||||
|
"reset_filters": "Reset Filters",
|
||||||
|
"pagination_total": "{{start}}-{{end}} of {{total}} items",
|
||||||
|
"current_user": "Current User",
|
||||||
|
"select_user": "Select User",
|
||||||
|
"default_user": "Default User",
|
||||||
|
"switch_user": "Switch User",
|
||||||
|
"user_switched": "User context switched to {{user}}",
|
||||||
|
"switch_user_confirm": "Switch user context to {{user}}?",
|
||||||
|
"add_user": "Add User",
|
||||||
|
"add_new_user": "Add New User",
|
||||||
|
"new_user_id": "New User ID",
|
||||||
|
"new_user_id_placeholder": "Enter a unique user ID",
|
||||||
|
"user_id_required": "User ID is required",
|
||||||
|
"user_id_reserved": "'default-user' is reserved, please use a different ID",
|
||||||
|
"user_id_exists": "This user ID already exists",
|
||||||
|
"user_id_too_long": "User ID cannot exceed 50 characters",
|
||||||
|
"user_id_invalid_chars": "User ID can only contain letters, numbers, hyphens and underscores",
|
||||||
|
"user_id_rules": "User ID must be unique and contain only letters, numbers, hyphens (-) and underscores (_)",
|
||||||
|
"user_created": "User {{user}} created and switched successfully",
|
||||||
|
"add_user_failed": "Failed to add user",
|
||||||
|
"memory": "memory",
|
||||||
|
"reset_user_memories": "Reset User Memories",
|
||||||
|
"delete_user": "Delete User",
|
||||||
|
"loading_memories": "Loading memories...",
|
||||||
|
"no_memories": "No memories yet",
|
||||||
|
"no_matching_memories": "No matching memories found",
|
||||||
|
"no_memories_description": "Start by adding your first memory to get started",
|
||||||
|
"try_different_filters": "Try adjusting your search criteria",
|
||||||
|
"add_first_memory": "Add Your First Memory",
|
||||||
|
"user_switch_failed": "Failed to switch user",
|
||||||
|
"cannot_delete_default_user": "Cannot delete the default user",
|
||||||
|
"delete_user_confirm_title": "Delete User",
|
||||||
|
"delete_user_confirm_content": "Are you sure you want to delete user {{user}} and all their memories?",
|
||||||
|
"user_deleted": "User {{user}} deleted successfully",
|
||||||
|
"delete_user_failed": "Failed to delete user",
|
||||||
|
"reset_user_memories_confirm_title": "Reset User Memories",
|
||||||
|
"reset_user_memories_confirm_content": "Are you sure you want to reset all memories for {{user}}?",
|
||||||
|
"user_memories_reset": "All memories for {{user}} have been reset",
|
||||||
|
"reset_user_memories_failed": "Failed to reset user memories",
|
||||||
|
"delete_confirm_single": "Are you sure you want to delete this memory?",
|
||||||
|
"total_memories": "total memories",
|
||||||
|
"default": "Default",
|
||||||
|
"custom": "Custom",
|
||||||
|
"global_memory_enabled": "Global memory enabled",
|
||||||
|
"global_memory": "Global Memory",
|
||||||
|
"enable_global_memory_first": "Please enable global memory first",
|
||||||
|
"configure_memory_first": "Please configure memory settings first",
|
||||||
|
"global_memory_disabled_title": "Global Memory Disabled",
|
||||||
|
"global_memory_disabled_desc": "To use memory features, please enable global memory in assistant settings first.",
|
||||||
|
"not_configured_title": "Memory Not Configured",
|
||||||
|
"not_configured_desc": "Please configure embedding and LLM models in memory settings to enable memory functionality.",
|
||||||
|
"go_to_memory_page": "Go to Memory Page"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -406,9 +406,11 @@
|
|||||||
"prompt": "プロンプト",
|
"prompt": "プロンプト",
|
||||||
"provider": "プロバイダー",
|
"provider": "プロバイダー",
|
||||||
"regenerate": "再生成",
|
"regenerate": "再生成",
|
||||||
|
"refresh": "更新",
|
||||||
"rename": "名前を変更",
|
"rename": "名前を変更",
|
||||||
"reset": "リセット",
|
"reset": "リセット",
|
||||||
"save": "保存",
|
"save": "保存",
|
||||||
|
"settings": "設定",
|
||||||
"search": "検索",
|
"search": "検索",
|
||||||
"select": "選択",
|
"select": "選択",
|
||||||
"selectedMessages": "{{count}}件のメッセージを選択しました",
|
"selectedMessages": "{{count}}件のメッセージを選択しました",
|
||||||
@@ -423,7 +425,9 @@
|
|||||||
"pinyin.asc": "ピンインで昇順ソート",
|
"pinyin.asc": "ピンインで昇順ソート",
|
||||||
"pinyin.desc": "ピンインで降順ソート"
|
"pinyin.desc": "ピンインで降順ソート"
|
||||||
},
|
},
|
||||||
"no_results": "検索結果なし"
|
"no_results": "検索結果なし",
|
||||||
|
"enabled": "有効",
|
||||||
|
"disabled": "無効"
|
||||||
},
|
},
|
||||||
"docs": {
|
"docs": {
|
||||||
"title": "ドキュメント"
|
"title": "ドキュメント"
|
||||||
@@ -543,7 +547,11 @@
|
|||||||
"rename": "名前を変更",
|
"rename": "名前を変更",
|
||||||
"search": "ナレッジベースを検索",
|
"search": "ナレッジベースを検索",
|
||||||
"search_placeholder": "検索するテキストを入力",
|
"search_placeholder": "検索するテキストを入力",
|
||||||
"settings": "ナレッジベース設定",
|
"settings": {
|
||||||
|
"title": "ナレッジベース設定",
|
||||||
|
"preprocessing": "預処理",
|
||||||
|
"preprocessing_tooltip": "アップロードされたファイルのOCR預処理"
|
||||||
|
},
|
||||||
"sitemap_placeholder": "サイトマップURLを入力",
|
"sitemap_placeholder": "サイトマップURLを入力",
|
||||||
"sitemaps": "サイトマップ",
|
"sitemaps": "サイトマップ",
|
||||||
"source": "ソース",
|
"source": "ソース",
|
||||||
@@ -567,12 +575,18 @@
|
|||||||
"urls": "URL",
|
"urls": "URL",
|
||||||
"dimensions": "埋め込み次元",
|
"dimensions": "埋め込み次元",
|
||||||
"dimensions_size_tooltip": "埋め込み次元のサイズは、数値が大きいほど埋め込み次元も大きくなりますが、消費するトークンも増えます。",
|
"dimensions_size_tooltip": "埋め込み次元のサイズは、数値が大きいほど埋め込み次元も大きくなりますが、消費するトークンも増えます。",
|
||||||
|
"status_embedding_completed": "埋め込み完了",
|
||||||
|
"status_preprocess_completed": "前処理完了",
|
||||||
|
"status_embedding_failed": "埋め込み失敗",
|
||||||
|
"status_preprocess_failed": "前処理に失敗しました",
|
||||||
"dimensions_size_placeholder": " 埋め込み次元のサイズ(例:1024)",
|
"dimensions_size_placeholder": " 埋め込み次元のサイズ(例:1024)",
|
||||||
"dimensions_auto_set": "埋め込み次元を自動設定",
|
"dimensions_auto_set": "埋め込み次元を自動設定",
|
||||||
"dimensions_error_invalid": "埋め込み次元のサイズを入力してください",
|
"dimensions_error_invalid": "埋め込み次元のサイズを入力してください",
|
||||||
"dimensions_size_too_large": "埋め込み次元はモデルのコンテキスト制限({{max_context}})を超えてはなりません。",
|
"dimensions_size_too_large": "埋め込み次元はモデルのコンテキスト制限({{max_context}})を超えてはなりません。",
|
||||||
"dimensions_set_right": "⚠️ モデルが設定した埋め込み次元のサイズをサポートしていることを確認してください",
|
"dimensions_set_right": "⚠️ モデルが設定した埋め込み次元のサイズをサポートしていることを確認してください",
|
||||||
"dimensions_default": "モデルはデフォルトの埋め込み次元を使用します"
|
"dimensions_default": "モデルはデフォルトの埋め込み次元を使用します",
|
||||||
|
"quota": "{{name}} 残りクォータ: {{quota}}",
|
||||||
|
"quota_infinity": "{{name}} クォータ: 無制限"
|
||||||
},
|
},
|
||||||
"languages": {
|
"languages": {
|
||||||
"arabic": "アラビア語",
|
"arabic": "アラビア語",
|
||||||
@@ -835,7 +849,7 @@
|
|||||||
"notification": {
|
"notification": {
|
||||||
"assistant": "助手回應",
|
"assistant": "助手回應",
|
||||||
"knowledge.success": "ナレッジベースに{{type}}を正常に追加しました",
|
"knowledge.success": "ナレッジベースに{{type}}を正常に追加しました",
|
||||||
"knowledge.error": "ナレッジベースへの{{type}}の追加に失敗しました: {{error}}"
|
"knowledge.error": "{{error}}"
|
||||||
},
|
},
|
||||||
"ollama": {
|
"ollama": {
|
||||||
"keep_alive_time.description": "モデルがメモリに保持される時間(デフォルト:5分)",
|
"keep_alive_time.description": "モデルがメモリに保持される時間(デフォルト:5分)",
|
||||||
@@ -1793,6 +1807,91 @@
|
|||||||
"theme.window.style.title": "ウィンドウスタイル",
|
"theme.window.style.title": "ウィンドウスタイル",
|
||||||
"theme.window.style.transparent": "透明ウィンドウ",
|
"theme.window.style.transparent": "透明ウィンドウ",
|
||||||
"title": "設定",
|
"title": "設定",
|
||||||
|
"tool": {
|
||||||
|
"title": "ツール設定",
|
||||||
|
"websearch": {
|
||||||
|
"blacklist": "ブラックリスト",
|
||||||
|
"blacklist_description": "以下のウェブサイトの結果は検索結果に表示されません",
|
||||||
|
"blacklist_tooltip": "以下の形式を使用してください(改行区切り)\nexample.com\nhttps://www.example.com\nhttps://example.com\n*://*.example.com",
|
||||||
|
"check": "チェック",
|
||||||
|
"check_failed": "検証に失敗しました",
|
||||||
|
"check_success": "検証に成功しました",
|
||||||
|
"get_api_key": "APIキーを取得",
|
||||||
|
"no_provider_selected": "検索サービスプロバイダーを選択してから再確認してください。",
|
||||||
|
"search_max_result": "検索結果の数",
|
||||||
|
"search_provider": "検索サービスプロバイダー",
|
||||||
|
"search_provider_placeholder": "検索サービスプロバイダーを選択する",
|
||||||
|
"search_result_default": "デフォルト",
|
||||||
|
"search_with_time": "日付を含む検索",
|
||||||
|
"tavily": {
|
||||||
|
"api_key": "Tavily API キー",
|
||||||
|
"api_key.placeholder": "Tavily API キーを入力してください",
|
||||||
|
"description": "Tavily は、AI エージェントのために特別に開発された検索エンジンで、最新の結果、インテリジェントな検索提案、そして深い研究能力を提供します",
|
||||||
|
"title": "Tavily"
|
||||||
|
},
|
||||||
|
"title": "ウェブ検索",
|
||||||
|
"subscribe": "ブラックリスト購読",
|
||||||
|
"subscribe_update": "更新",
|
||||||
|
"subscribe_add": "購読を追加",
|
||||||
|
"subscribe_url": "購読URL",
|
||||||
|
"subscribe_name": "代替名",
|
||||||
|
"subscribe_name.placeholder": "ダウンロードした購読フィードに名前がない場合に使用される代替名。",
|
||||||
|
"subscribe_add_success": "購読フィードが正常に追加されました!",
|
||||||
|
"subscribe_delete": "削除",
|
||||||
|
"overwrite": "検索サービスを上書き",
|
||||||
|
"overwrite_tooltip": "LLMの代わりに検索サービスを強制的に使用する",
|
||||||
|
"apikey": "APIキー",
|
||||||
|
"free": "無料",
|
||||||
|
"content_limit": "コンテンツ制限",
|
||||||
|
"content_limit_tooltip": "検索結果のコンテンツの長さを制限します。制限を超えるコンテンツは切り捨てられます。",
|
||||||
|
"compression": {
|
||||||
|
"title": "検索結果の圧縮",
|
||||||
|
"method": "圧縮方法",
|
||||||
|
"method.none": "圧縮しない",
|
||||||
|
"method.cutoff": "切り捨て",
|
||||||
|
"cutoff.limit": "切り捨て長",
|
||||||
|
"cutoff.limit.placeholder": "長さを入力",
|
||||||
|
"cutoff.limit.tooltip": "検索結果の内容長を制限し、制限を超える内容は切り捨てられます(例:2000文字)",
|
||||||
|
"cutoff.unit.char": "文字",
|
||||||
|
"cutoff.unit.token": "トークン",
|
||||||
|
"method.rag": "RAG",
|
||||||
|
"rag.document_count": "文書数",
|
||||||
|
"rag.document_count.default": "デフォルト",
|
||||||
|
"rag.document_count.tooltip": "単一の検索結果から抽出する文書数。実際に抽出される文書数は、この値に検索結果数を乗じたものです。",
|
||||||
|
"rag.embedding_dimensions.auto_get": "次元を自動取得",
|
||||||
|
"rag.embedding_dimensions.placeholder": "次元を設定しない",
|
||||||
|
"rag.embedding_dimensions.tooltip": "空の場合、dimensions パラメーターは渡されません",
|
||||||
|
"info": {
|
||||||
|
"dimensions_auto_success": "次元が自動取得されました。次元: {{dimensions}}"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"embedding_model_required": "まず埋め込みモデルを選択してください",
|
||||||
|
"dimensions_auto_failed": "次元の自動取得に失敗しました",
|
||||||
|
"provider_not_found": "プロバイダーが見つかりません",
|
||||||
|
"rag_failed": "RAG に失敗しました"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"preprocess": {
|
||||||
|
"title": "前処理",
|
||||||
|
"provider": "プレプロセスプロバイダー",
|
||||||
|
"provider_placeholder": "前処理プロバイダーを選択してください"
|
||||||
|
},
|
||||||
|
"preprocessOrOcr.tooltip": "設定 → ツールで、ドキュメント前処理サービスプロバイダーまたはOCRを設定します。ドキュメント前処理は、複雑な形式のドキュメントやスキャンされたドキュメントの検索性能を効果的に向上させます。OCRは、ドキュメント内の画像内のテキストまたはスキャンされたPDFテキストのみを認識できます。",
|
||||||
|
"ocr": {
|
||||||
|
"title": "OCR(オーシーアール)",
|
||||||
|
"provider": "OCRプロバイダー",
|
||||||
|
"provider_placeholder": "OCRプロバイダーを選択",
|
||||||
|
"mac_system_ocr_options": {
|
||||||
|
"mode": {
|
||||||
|
"title": "認識モード",
|
||||||
|
"accurate": "正確",
|
||||||
|
"fast": "速い"
|
||||||
|
},
|
||||||
|
"min_confidence": "最小信頼度"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"topic.position": "トピックの位置",
|
"topic.position": "トピックの位置",
|
||||||
"topic.position.left": "左",
|
"topic.position.left": "左",
|
||||||
"topic.position.right": "右",
|
"topic.position.right": "右",
|
||||||
@@ -1801,67 +1900,6 @@
|
|||||||
"tray.onclose": "閉じるときにトレイに最小化",
|
"tray.onclose": "閉じるときにトレイに最小化",
|
||||||
"tray.show": "トレイアイコンを表示",
|
"tray.show": "トレイアイコンを表示",
|
||||||
"tray.title": "トレイ",
|
"tray.title": "トレイ",
|
||||||
"websearch": {
|
|
||||||
"blacklist": "ブラックリスト",
|
|
||||||
"blacklist_description": "以下のウェブサイトの結果は検索結果に表示されません",
|
|
||||||
"check": "チェック",
|
|
||||||
"check_failed": "検証に失敗しました",
|
|
||||||
"check_success": "検証に成功しました",
|
|
||||||
"get_api_key": "APIキーを取得",
|
|
||||||
"no_provider_selected": "検索サービスプロバイダーを選択してから再確認してください。",
|
|
||||||
"search_max_result": "検索結果の数",
|
|
||||||
"search_provider": "検索サービスプロバイダー",
|
|
||||||
"search_provider_placeholder": "検索サービスプロバイダーを選択する",
|
|
||||||
"search_result_default": "デフォルト",
|
|
||||||
"search_with_time": "日付を含む検索",
|
|
||||||
"tavily": {
|
|
||||||
"api_key": "Tavily API キー",
|
|
||||||
"api_key.placeholder": "Tavily API キーを入力してください",
|
|
||||||
"description": "Tavily は、AI エージェントのために特別に開発された検索エンジンで、最新の結果、インテリジェントな検索提案、そして深い研究能力を提供します",
|
|
||||||
"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": "大規模言語モデルではなく、サービス検索を使用する",
|
|
||||||
"apikey": "API キー",
|
|
||||||
"free": "無料",
|
|
||||||
"compression": {
|
|
||||||
"title": "検索結果の圧縮",
|
|
||||||
"method": "圧縮方法",
|
|
||||||
"method.none": "圧縮しない",
|
|
||||||
"method.cutoff": "切り捨て",
|
|
||||||
"cutoff.limit": "切り捨て長",
|
|
||||||
"cutoff.limit.placeholder": "長さを入力",
|
|
||||||
"cutoff.limit.tooltip": "検索結果の内容長を制限し、制限を超える内容は切り捨てられます(例:2000文字)",
|
|
||||||
"cutoff.unit.char": "文字",
|
|
||||||
"cutoff.unit.token": "トークン",
|
|
||||||
"method.rag": "RAG",
|
|
||||||
"rag.document_count": "文書数",
|
|
||||||
"rag.document_count.default": "デフォルト",
|
|
||||||
"rag.document_count.tooltip": "単一の検索結果から抽出する文書数。実際に抽出される文書数は、この値に検索結果数を乗じたものです。",
|
|
||||||
"rag.embedding_dimensions.auto_get": "次元を自動取得",
|
|
||||||
"rag.embedding_dimensions.placeholder": "次元を設定しない",
|
|
||||||
"rag.embedding_dimensions.tooltip": "空の場合、dimensions パラメーターは渡されません",
|
|
||||||
"info": {
|
|
||||||
"dimensions_auto_success": "次元が自動取得されました。次元: {{dimensions}}"
|
|
||||||
},
|
|
||||||
"error": {
|
|
||||||
"embedding_model_required": "まず埋め込みモデルを選択してください",
|
|
||||||
"dimensions_auto_failed": "次元の自動取得に失敗しました",
|
|
||||||
"provider_not_found": "プロバイダーが見つかりません",
|
|
||||||
"rag_failed": "RAG に失敗しました"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"general.auto_check_update.title": "自動更新",
|
"general.auto_check_update.title": "自動更新",
|
||||||
"general.test_plan.title": "テストプラン",
|
"general.test_plan.title": "テストプラン",
|
||||||
"general.test_plan.tooltip": "テストプランに参加すると、最新の機能をより早く体験できますが、同時により多くのリスクが伴います。データを事前にバックアップしてください。",
|
"general.test_plan.tooltip": "テストプランに参加すると、最新の機能をより早く体験できますが、同時により多くのリスクが伴います。データを事前にバックアップしてください。",
|
||||||
@@ -1925,7 +1963,8 @@
|
|||||||
"assistant": "アシスタントメッセージ",
|
"assistant": "アシスタントメッセージ",
|
||||||
"backup": "バックアップメッセージ",
|
"backup": "バックアップメッセージ",
|
||||||
"knowledge_embed": "ナレッジベースメッセージ"
|
"knowledge_embed": "ナレッジベースメッセージ"
|
||||||
}
|
},
|
||||||
|
"mineru.api_key": "Mineruでは現在、1日500ページの無料クォータを提供しており、キーを入力する必要はありません。"
|
||||||
},
|
},
|
||||||
"translate": {
|
"translate": {
|
||||||
"any.language": "任意の言語",
|
"any.language": "任意の言語",
|
||||||
@@ -2021,14 +2060,29 @@
|
|||||||
"experimental": "実験的機能",
|
"experimental": "実験的機能",
|
||||||
"enable": {
|
"enable": {
|
||||||
"title": "有効化",
|
"title": "有効化",
|
||||||
"description": "現在Windowsのみ対応"
|
"description": "現在Windows & macOSのみ対応",
|
||||||
|
"mac_process_trust_hint": {
|
||||||
|
"title": "アクセシビリティー権限",
|
||||||
|
"description": [
|
||||||
|
"テキスト選択ツールは、<strong>アクセシビリティー権限</strong>が必要です。",
|
||||||
|
"「<strong>設定に移動</strong>」をクリックし、後で表示される権限要求ポップアップで「<strong>システム設定を開く</strong>」ボタンをクリックします。その後、表示されるアプリケーションリストで「<strong>Cherry Studio</strong>」を見つけ、権限スイッチをオンにしてください。",
|
||||||
|
"設定が完了したら、テキスト選択ツールを再起動してください。"
|
||||||
|
],
|
||||||
|
"button": {
|
||||||
|
"open_accessibility_settings": "アクセシビリティー設定を開く",
|
||||||
|
"go_to_settings": "設定に移動"
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"toolbar": {
|
"toolbar": {
|
||||||
"title": "ツールバー",
|
"title": "ツールバー",
|
||||||
"trigger_mode": {
|
"trigger_mode": {
|
||||||
"title": "単語の取り出し方",
|
"title": "単語の取り出し方",
|
||||||
"description": "テキスト選択後、取詞ツールバーを表示する方法",
|
"description": "テキスト選択後、取詞ツールバーを表示する方法",
|
||||||
"description_note": "一部のアプリケーションでは、Ctrl キーでテキストを選択できません。AHK などのツールを使用して Ctrl キーを再マップした場合、一部のアプリケーションでテキスト選択が失敗する可能性があります。",
|
"description_note": {
|
||||||
|
"windows": "一部のアプリケーションでは、Ctrl キーでテキストを選択できません。AHK などのツールを使用して Ctrl キーを再マップした場合、一部のアプリケーションでテキスト選択が失敗する可能性があります。",
|
||||||
|
"mac": "一部のアプリケーションでは、⌘ キーでテキストを選択できません。ショートカットキーまたはキーボードマッピングツールを使用して ⌘ キーを再マップした場合、一部のアプリケーションでテキスト選択が失敗する可能性があります。"
|
||||||
|
},
|
||||||
"selected": "選択時",
|
"selected": "選択時",
|
||||||
"selected_note": "テキスト選択時に即時表示",
|
"selected_note": "テキスト選択時に即時表示",
|
||||||
"ctrlkey": "Ctrlキー",
|
"ctrlkey": "Ctrlキー",
|
||||||
@@ -2153,9 +2207,96 @@
|
|||||||
},
|
},
|
||||||
"filter_modal": {
|
"filter_modal": {
|
||||||
"title": "アプリケーションフィルターリスト",
|
"title": "アプリケーションフィルターリスト",
|
||||||
"user_tips": "アプリケーションの実行ファイル名を1行ずつ入力してください。大文字小文字は区別しません。例: chrome.exe, weixin.exe, Cherry Studio.exe, など。"
|
"user_tips": {
|
||||||
|
"windows": "アプリケーションの実行ファイル名を1行ずつ入力してください。大文字小文字は区別しません。例: chrome.exe, weixin.exe, Cherry Studio.exe, など。",
|
||||||
|
"mac": "アプリケーションのBundle IDを1行ずつ入力してください。大文字小文字は区別しません。例: com.google.Chrome, com.apple.mail, など。"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"memory": {
|
||||||
|
"add_memory": "メモリーを追加",
|
||||||
|
"edit_memory": "メモリーを編集",
|
||||||
|
"memory_content": "メモリー内容",
|
||||||
|
"please_enter_memory": "メモリー内容を入力してください",
|
||||||
|
"memory_placeholder": "メモリー内容を入力...",
|
||||||
|
"user_id": "ユーザーID",
|
||||||
|
"user_id_placeholder": "ユーザーIDを入力(オプション)",
|
||||||
|
"load_failed": "メモリーの読み込みに失敗しました",
|
||||||
|
"add_success": "メモリーが正常に追加されました",
|
||||||
|
"add_failed": "メモリーの追加に失敗しました",
|
||||||
|
"update_success": "メモリーが正常に更新されました",
|
||||||
|
"update_failed": "メモリーの更新に失敗しました",
|
||||||
|
"delete_success": "メモリーが正常に削除されました",
|
||||||
|
"delete_failed": "メモリーの削除に失敗しました",
|
||||||
|
"delete_confirm_title": "メモリーを削除",
|
||||||
|
"delete_confirm_content": "{{count}}件のメモリーを削除してもよろしいですか?",
|
||||||
|
"delete_confirm": "このメモリーを削除してもよろしいですか?",
|
||||||
|
"time": "時間",
|
||||||
|
"user": "ユーザー",
|
||||||
|
"content": "内容",
|
||||||
|
"score": "スコア",
|
||||||
|
"title": "メモリー",
|
||||||
|
"memories_description": "{{total}}件中{{count}}件のメモリーを表示",
|
||||||
|
"search_placeholder": "メモリーを検索...",
|
||||||
|
"start_date": "開始日",
|
||||||
|
"end_date": "終了日",
|
||||||
|
"all_users": "すべてのユーザー",
|
||||||
|
"users": "ユーザー",
|
||||||
|
"delete_selected": "選択したものを削除",
|
||||||
|
"reset_filters": "フィルターをリセット",
|
||||||
|
"pagination_total": "{{total}}件中{{start}}-{{end}}件",
|
||||||
|
"current_user": "現在のユーザー",
|
||||||
|
"select_user": "ユーザーを選択",
|
||||||
|
"default_user": "デフォルトユーザー",
|
||||||
|
"switch_user": "ユーザーを切り替え",
|
||||||
|
"user_switched": "ユーザーコンテキストが{{user}}に切り替わりました",
|
||||||
|
"switch_user_confirm": "ユーザーコンテキストを{{user}}に切り替えますか?",
|
||||||
|
"add_user": "ユーザーを追加",
|
||||||
|
"add_new_user": "新しいユーザーを追加",
|
||||||
|
"new_user_id": "新しいユーザーID",
|
||||||
|
"new_user_id_placeholder": "一意のユーザーIDを入力",
|
||||||
|
"user_id_required": "ユーザーIDは必須です",
|
||||||
|
"user_id_reserved": "'default-user'は予約済みです。別のIDを使用してください",
|
||||||
|
"user_id_exists": "このユーザーIDはすでに存在します",
|
||||||
|
"user_id_too_long": "ユーザーIDは50文字を超えられません",
|
||||||
|
"user_id_invalid_chars": "ユーザーIDには文字、数字、ハイフン、アンダースコアのみ使用できます",
|
||||||
|
"user_id_rules": "ユーザーIDは一意であり、文字、数字、ハイフン(-)、アンダースコア(_)のみ含む必要があります",
|
||||||
|
"user_created": "ユーザー{{user}}が作成され、切り替えが成功しました",
|
||||||
|
"add_user_failed": "ユーザーの追加に失敗しました",
|
||||||
|
"memory": "個のメモリ",
|
||||||
|
"reset_user_memories": "ユーザーメモリをリセット",
|
||||||
|
"delete_user": "ユーザーを削除",
|
||||||
|
"loading_memories": "メモリを読み込み中...",
|
||||||
|
"no_memories": "メモリがありません",
|
||||||
|
"no_matching_memories": "一致するメモリが見つかりません",
|
||||||
|
"no_memories_description": "最初のメモリを追加してください",
|
||||||
|
"try_different_filters": "検索条件を調整してください",
|
||||||
|
"add_first_memory": "最初のメモリを追加",
|
||||||
|
"user_switch_failed": "ユーザーの切り替えに失敗しました",
|
||||||
|
"cannot_delete_default_user": "デフォルトユーザーは削除できません",
|
||||||
|
"delete_user_confirm_title": "ユーザーを削除",
|
||||||
|
"delete_user_confirm_content": "ユーザー{{user}}とそのすべてのメモリを削除してもよろしいですか?",
|
||||||
|
"user_deleted": "ユーザー{{user}}が正常に削除されました",
|
||||||
|
"delete_user_failed": "ユーザーの削除に失敗しました",
|
||||||
|
"reset_user_memories_confirm_title": "ユーザーメモリをリセット",
|
||||||
|
"reset_user_memories_confirm_content": "{{user}}のすべてのメモリをリセットしてもよろしいですか?",
|
||||||
|
"user_memories_reset": "{{user}}のすべてのメモリがリセットされました",
|
||||||
|
"reset_user_memories_failed": "ユーザーメモリのリセットに失敗しました",
|
||||||
|
"delete_confirm_single": "このメモリを削除してもよろしいですか?",
|
||||||
|
"total_memories": "個のメモリ",
|
||||||
|
"default": "デフォルト",
|
||||||
|
"custom": "カスタム",
|
||||||
|
"description": "メモリは、アシスタントとのやりとりに関する情報を保存・管理する機能です。メモリの追加、編集、削除のほか、フィルタリングや検索を行うことができます。",
|
||||||
|
"global_memory_enabled": "グローバルメモリが有効化されました",
|
||||||
|
"global_memory": "グローバルメモリ",
|
||||||
|
"enable_global_memory_first": "最初にグローバルメモリを有効にしてください",
|
||||||
|
"configure_memory_first": "最初にメモリ設定を構成してください",
|
||||||
|
"global_memory_disabled_title": "グローバルメモリが無効です",
|
||||||
|
"global_memory_disabled_desc": "メモリ機能を使用するには、まずアシスタント設定でグローバルメモリを有効にしてください。",
|
||||||
|
"not_configured_title": "メモリが設定されていません",
|
||||||
|
"not_configured_desc": "メモリ機能を有効にするには、メモリ設定で埋め込みとLLMモデルを設定してください。",
|
||||||
|
"go_to_memory_page": "メモリページに移動"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -406,9 +406,11 @@
|
|||||||
"prompt": "Промпт",
|
"prompt": "Промпт",
|
||||||
"provider": "Провайдер",
|
"provider": "Провайдер",
|
||||||
"regenerate": "Пересоздать",
|
"regenerate": "Пересоздать",
|
||||||
|
"refresh": "Обновить",
|
||||||
"rename": "Переименовать",
|
"rename": "Переименовать",
|
||||||
"reset": "Сбросить",
|
"reset": "Сбросить",
|
||||||
"save": "Сохранить",
|
"save": "Сохранить",
|
||||||
|
"settings": "Настройки",
|
||||||
"search": "Поиск",
|
"search": "Поиск",
|
||||||
"select": "Выбрать",
|
"select": "Выбрать",
|
||||||
"selectedMessages": "Выбрано {{count}} сообщений",
|
"selectedMessages": "Выбрано {{count}} сообщений",
|
||||||
@@ -423,7 +425,9 @@
|
|||||||
"pinyin.asc": "Сортировать по пиньинь (А-Я)",
|
"pinyin.asc": "Сортировать по пиньинь (А-Я)",
|
||||||
"pinyin.desc": "Сортировать по пиньинь (Я-А)"
|
"pinyin.desc": "Сортировать по пиньинь (Я-А)"
|
||||||
},
|
},
|
||||||
"no_results": "Результатов не найдено"
|
"no_results": "Результатов не найдено",
|
||||||
|
"enabled": "Включено",
|
||||||
|
"disabled": "Отключено"
|
||||||
},
|
},
|
||||||
"docs": {
|
"docs": {
|
||||||
"title": "Документация"
|
"title": "Документация"
|
||||||
@@ -543,7 +547,11 @@
|
|||||||
"rename": "Переименовать",
|
"rename": "Переименовать",
|
||||||
"search": "Поиск в базе знаний",
|
"search": "Поиск в базе знаний",
|
||||||
"search_placeholder": "Введите текст для поиска",
|
"search_placeholder": "Введите текст для поиска",
|
||||||
"settings": "Настройки базы знаний",
|
"settings": {
|
||||||
|
"title": "Настройки базы знаний",
|
||||||
|
"preprocessing": "Предварительная обработка",
|
||||||
|
"preprocessing_tooltip": "Предварительная обработка изображений с помощью OCR"
|
||||||
|
},
|
||||||
"sitemap_placeholder": "Введите URL карты сайта",
|
"sitemap_placeholder": "Введите URL карты сайта",
|
||||||
"sitemaps": "Сайты",
|
"sitemaps": "Сайты",
|
||||||
"source": "Источник",
|
"source": "Источник",
|
||||||
@@ -567,12 +575,18 @@
|
|||||||
"urls": "URL-адреса",
|
"urls": "URL-адреса",
|
||||||
"dimensions": "векторное пространство",
|
"dimensions": "векторное пространство",
|
||||||
"dimensions_size_tooltip": "Размерность вложения, чем больше значение, тем больше размерность вложения, но и потребляемых токенов также становится больше.",
|
"dimensions_size_tooltip": "Размерность вложения, чем больше значение, тем больше размерность вложения, но и потребляемых токенов также становится больше.",
|
||||||
|
"status_embedding_completed": "Вложение завершено",
|
||||||
|
"status_preprocess_completed": "Предварительная обработка завершена",
|
||||||
|
"status_embedding_failed": "Не удалось встроить",
|
||||||
|
"status_preprocess_failed": "Предварительная обработка не удалась",
|
||||||
"dimensions_size_placeholder": " Размерность эмбеддинга, например 1024",
|
"dimensions_size_placeholder": " Размерность эмбеддинга, например 1024",
|
||||||
"dimensions_auto_set": "Автоматическая установка размерности эмбеддинга",
|
"dimensions_auto_set": "Автоматическая установка размерности эмбеддинга",
|
||||||
"dimensions_error_invalid": "Пожалуйста, введите размерность эмбеддинга",
|
"dimensions_error_invalid": "Пожалуйста, введите размерность эмбеддинга",
|
||||||
"dimensions_size_too_large": "Размерность вложения не может превышать ограничение контекста модели ({{max_context}})",
|
"dimensions_size_too_large": "Размерность вложения не может превышать ограничение контекста модели ({{max_context}})",
|
||||||
"dimensions_set_right": "⚠️ Убедитесь, что модель поддерживает заданный размер эмбеддинга",
|
"dimensions_set_right": "⚠️ Убедитесь, что модель поддерживает заданный размер эмбеддинга",
|
||||||
"dimensions_default": "Модель будет использовать размер эмбеддинга по умолчанию"
|
"dimensions_default": "Модель будет использовать размер эмбеддинга по умолчанию",
|
||||||
|
"quota": "{{name}} Остаток квоты: {{quota}}",
|
||||||
|
"quota_infinity": "{{name}} Квота: Не ограничена"
|
||||||
},
|
},
|
||||||
"languages": {
|
"languages": {
|
||||||
"arabic": "Арабский",
|
"arabic": "Арабский",
|
||||||
@@ -835,7 +849,7 @@
|
|||||||
"notification": {
|
"notification": {
|
||||||
"assistant": "Ответ ассистента",
|
"assistant": "Ответ ассистента",
|
||||||
"knowledge.success": "Успешно добавлено {{type}} в базу знаний",
|
"knowledge.success": "Успешно добавлено {{type}} в базу знаний",
|
||||||
"knowledge.error": "Не удалось добавить {{type}} в базу знаний: {{error}}"
|
"knowledge.error": "{{error}}"
|
||||||
},
|
},
|
||||||
"ollama": {
|
"ollama": {
|
||||||
"keep_alive_time.description": "Время в минутах, в течение которого модель остается активной, по умолчанию 5 минут.",
|
"keep_alive_time.description": "Время в минутах, в течение которого модель остается активной, по умолчанию 5 минут.",
|
||||||
@@ -984,7 +998,7 @@
|
|||||||
"per_images": "за изображения",
|
"per_images": "за изображения",
|
||||||
"required_field": "Обязательное поле",
|
"required_field": "Обязательное поле",
|
||||||
"uploaded_input": "Загруженный ввод",
|
"uploaded_input": "Загруженный ввод",
|
||||||
"prompt_placeholder_en": "[to be translated]:Enter your image description, currently Imagen only supports English prompts"
|
"prompt_placeholder_en": "Введите описание изображения, в настоящее время Imagen поддерживает только английские подсказки"
|
||||||
},
|
},
|
||||||
"prompts": {
|
"prompts": {
|
||||||
"explanation": "Объясните мне этот концепт",
|
"explanation": "Объясните мне этот концепт",
|
||||||
@@ -1793,6 +1807,91 @@
|
|||||||
"theme.window.style.title": "Стиль окна",
|
"theme.window.style.title": "Стиль окна",
|
||||||
"theme.window.style.transparent": "Прозрачное окно",
|
"theme.window.style.transparent": "Прозрачное окно",
|
||||||
"title": "Настройки",
|
"title": "Настройки",
|
||||||
|
"tool": {
|
||||||
|
"title": "Настройки инструментов",
|
||||||
|
"websearch": {
|
||||||
|
"blacklist": "Черный список",
|
||||||
|
"blacklist_description": "Результаты из следующих веб-сайтов не будут отображаться в результатах поиска",
|
||||||
|
"blacklist_tooltip": "Пожалуйста, используйте следующий формат (разделенный переносами строк)\nexample.com\nhttps://www.example.com\nhttps://example.com\n*://*.example.com",
|
||||||
|
"check": "проверка",
|
||||||
|
"check_failed": "Проверка не прошла",
|
||||||
|
"check_success": "Проверка успешна",
|
||||||
|
"get_api_key": "Получить ключ API",
|
||||||
|
"no_provider_selected": "Пожалуйста, выберите поставщика поисковых услуг, затем проверьте.",
|
||||||
|
"search_max_result": "Количество результатов поиска",
|
||||||
|
"search_provider": "поиск сервисного провайдера",
|
||||||
|
"search_provider_placeholder": "Выберите поставщика поисковых услуг",
|
||||||
|
"search_result_default": "По умолчанию",
|
||||||
|
"search_with_time": "Поиск, содержащий дату",
|
||||||
|
"tavily": {
|
||||||
|
"api_key": "Ключ API Tavily",
|
||||||
|
"api_key.placeholder": "Введите ключ API Tavily",
|
||||||
|
"description": "Tavily — это поисковая система, специально разработанная для ИИ-агентов, предоставляющая актуальные результаты, умные предложения по запросам и глубокие исследовательские возможности",
|
||||||
|
"title": "Tavily"
|
||||||
|
},
|
||||||
|
"title": "Поиск в Интернете",
|
||||||
|
"subscribe": "Подписка на черный список",
|
||||||
|
"subscribe_update": "Обновить",
|
||||||
|
"subscribe_add": "Добавить подписку",
|
||||||
|
"subscribe_url": "URL подписки",
|
||||||
|
"subscribe_name": "Альтернативное имя",
|
||||||
|
"subscribe_name.placeholder": "Альтернативное имя, используемое, когда в загруженной ленте подписки нет имени.",
|
||||||
|
"subscribe_add_success": "Лента подписки успешно добавлена!",
|
||||||
|
"subscribe_delete": "Удалить",
|
||||||
|
"overwrite": "Переопределить поисковый сервис",
|
||||||
|
"overwrite_tooltip": "Принудительно использовать поисковый сервис вместо LLM",
|
||||||
|
"apikey": "API ключ",
|
||||||
|
"free": "Бесплатно",
|
||||||
|
"content_limit": "Ограничение длины контента",
|
||||||
|
"content_limit_tooltip": "Ограничить длину контента в результатах поиска; контент, превышающий лимит, будет усечен.",
|
||||||
|
"compression": {
|
||||||
|
"title": "Сжатие результатов поиска",
|
||||||
|
"method": "Метод сжатия",
|
||||||
|
"method.none": "Не сжимать",
|
||||||
|
"method.cutoff": "Обрезка",
|
||||||
|
"cutoff.limit": "Лимит обрезки",
|
||||||
|
"cutoff.limit.placeholder": "Введите длину",
|
||||||
|
"cutoff.limit.tooltip": "Ограничьте длину содержимого результатов поиска, контент, превышающий ограничение, будет обрезан (например, 2000 символов)",
|
||||||
|
"cutoff.unit.char": "Символы",
|
||||||
|
"cutoff.unit.token": "Токены",
|
||||||
|
"method.rag": "RAG",
|
||||||
|
"rag.document_count": "Количество документов",
|
||||||
|
"rag.document_count.default": "По умолчанию",
|
||||||
|
"rag.document_count.tooltip": "Ожидаемое количество документов, которые будут извлечены из каждого результата поиска. Фактическое количество извлеченных документов равно этому значению, умноженному на количество результатов поиска.",
|
||||||
|
"rag.embedding_dimensions.auto_get": "Автоматически получить размерности",
|
||||||
|
"rag.embedding_dimensions.placeholder": "Не устанавливать размерности",
|
||||||
|
"rag.embedding_dimensions.tooltip": "Если оставить пустым, параметр dimensions не будет передан",
|
||||||
|
"info": {
|
||||||
|
"dimensions_auto_success": "Размерности успешно получены, размерности: {{dimensions}}"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"embedding_model_required": "Пожалуйста, сначала выберите модель встраивания",
|
||||||
|
"dimensions_auto_failed": "Не удалось получить размерности",
|
||||||
|
"provider_not_found": "Поставщик не найден",
|
||||||
|
"rag_failed": "RAG не удалось"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"preprocess": {
|
||||||
|
"title": "Предварительная обработка",
|
||||||
|
"provider": "Предварительная обработка Поставщик",
|
||||||
|
"provider_placeholder": "Выберите поставщика услуг предварительной обработки"
|
||||||
|
},
|
||||||
|
"preprocessOrOcr.tooltip": "В настройках (Настройки -> Инструменты) укажите поставщика услуги предварительной обработки документов или OCR. Предварительная обработка документов может значительно повысить эффективность поиска для документов сложных форматов и отсканированных документов. OCR способен распознавать только текст внутри изображений в документах или текст в отсканированных PDF.",
|
||||||
|
"ocr": {
|
||||||
|
"title": "OCR (оптическое распознавание символов)",
|
||||||
|
"provider": "Поставщик OCR",
|
||||||
|
"provider_placeholder": "Выберите провайдера OCR",
|
||||||
|
"mac_system_ocr_options": {
|
||||||
|
"mode": {
|
||||||
|
"title": "Режим распознавания",
|
||||||
|
"accurate": "Точный",
|
||||||
|
"fast": "Быстро"
|
||||||
|
},
|
||||||
|
"min_confidence": "Минимальная достоверность"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"topic.position": "Позиция топиков",
|
"topic.position": "Позиция топиков",
|
||||||
"topic.position.left": "Слева",
|
"topic.position.left": "Слева",
|
||||||
"topic.position.right": "Справа",
|
"topic.position.right": "Справа",
|
||||||
@@ -1801,67 +1900,6 @@
|
|||||||
"tray.onclose": "Свернуть в трей при закрытии",
|
"tray.onclose": "Свернуть в трей при закрытии",
|
||||||
"tray.show": "Показать значок в трее",
|
"tray.show": "Показать значок в трее",
|
||||||
"tray.title": "Трей",
|
"tray.title": "Трей",
|
||||||
"websearch": {
|
|
||||||
"blacklist": "Черный список",
|
|
||||||
"blacklist_description": "Результаты из следующих веб-сайтов не будут отображаться в результатах поиска",
|
|
||||||
"check": "проверка",
|
|
||||||
"check_failed": "Проверка не прошла",
|
|
||||||
"check_success": "Проверка успешна",
|
|
||||||
"get_api_key": "Получить ключ API",
|
|
||||||
"no_provider_selected": "Пожалуйста, выберите поставщика поисковых услуг, затем проверьте.",
|
|
||||||
"search_max_result": "Количество результатов поиска",
|
|
||||||
"search_provider": "поиск сервисного провайдера",
|
|
||||||
"search_provider_placeholder": "Выберите поставщика поисковых услуг",
|
|
||||||
"search_result_default": "По умолчанию",
|
|
||||||
"search_with_time": "Поиск, содержащий дату",
|
|
||||||
"tavily": {
|
|
||||||
"api_key": "Ключ API Tavily",
|
|
||||||
"api_key.placeholder": "Введите ключ API Tavily",
|
|
||||||
"description": "Tavily — это поисковая система, специально разработанная для ИИ-агентов, предоставляющая актуальные результаты, умные предложения по запросам и глубокие исследовательские возможности",
|
|
||||||
"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": "Использовать провайдера поиска вместо LLM",
|
|
||||||
"apikey": "API ключ",
|
|
||||||
"free": "Бесплатно",
|
|
||||||
"compression": {
|
|
||||||
"title": "Сжатие результатов поиска",
|
|
||||||
"method": "Метод сжатия",
|
|
||||||
"method.none": "Не сжимать",
|
|
||||||
"method.cutoff": "Обрезка",
|
|
||||||
"cutoff.limit": "Лимит обрезки",
|
|
||||||
"cutoff.limit.placeholder": "Введите длину",
|
|
||||||
"cutoff.limit.tooltip": "Ограничьте длину содержимого результатов поиска, контент, превышающий ограничение, будет обрезан (например, 2000 символов)",
|
|
||||||
"cutoff.unit.char": "Символы",
|
|
||||||
"cutoff.unit.token": "Токены",
|
|
||||||
"method.rag": "RAG",
|
|
||||||
"rag.document_count": "Количество документов",
|
|
||||||
"rag.document_count.default": "По умолчанию",
|
|
||||||
"rag.document_count.tooltip": "Ожидаемое количество документов, которые будут извлечены из каждого результата поиска. Фактическое количество извлеченных документов равно этому значению, умноженному на количество результатов поиска.",
|
|
||||||
"rag.embedding_dimensions.auto_get": "Автоматически получить размерности",
|
|
||||||
"rag.embedding_dimensions.placeholder": "Не устанавливать размерности",
|
|
||||||
"rag.embedding_dimensions.tooltip": "Если оставить пустым, параметр dimensions не будет передан",
|
|
||||||
"info": {
|
|
||||||
"dimensions_auto_success": "Размерности успешно получены, размерности: {{dimensions}}"
|
|
||||||
},
|
|
||||||
"error": {
|
|
||||||
"embedding_model_required": "Пожалуйста, сначала выберите модель встраивания",
|
|
||||||
"dimensions_auto_failed": "Не удалось получить размерности",
|
|
||||||
"provider_not_found": "Поставщик не найден",
|
|
||||||
"rag_failed": "RAG не удалось"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"general.auto_check_update.title": "Автоматическое обновление",
|
"general.auto_check_update.title": "Автоматическое обновление",
|
||||||
"general.test_plan.title": "Тестовый план",
|
"general.test_plan.title": "Тестовый план",
|
||||||
"general.test_plan.tooltip": "Участвовать в тестовом плане, чтобы быстрее получать новые функции, но при этом возникает больше рисков, пожалуйста, сделайте резервную копию данных заранее",
|
"general.test_plan.tooltip": "Участвовать в тестовом плане, чтобы быстрее получать новые функции, но при этом возникает больше рисков, пожалуйста, сделайте резервную копию данных заранее",
|
||||||
@@ -1925,7 +1963,8 @@
|
|||||||
"assistant": "Сообщение ассистента",
|
"assistant": "Сообщение ассистента",
|
||||||
"backup": "Резервное сообщение",
|
"backup": "Резервное сообщение",
|
||||||
"knowledge_embed": "Сообщение базы знаний"
|
"knowledge_embed": "Сообщение базы знаний"
|
||||||
}
|
},
|
||||||
|
"mineru.api_key": "Mineru теперь предлагает ежедневную бесплатную квоту в 500 страниц, и вам не нужно вводить ключ."
|
||||||
},
|
},
|
||||||
"translate": {
|
"translate": {
|
||||||
"any.language": "Любой язык",
|
"any.language": "Любой язык",
|
||||||
@@ -2021,14 +2060,29 @@
|
|||||||
"experimental": "Экспериментальные функции",
|
"experimental": "Экспериментальные функции",
|
||||||
"enable": {
|
"enable": {
|
||||||
"title": "Включить",
|
"title": "Включить",
|
||||||
"description": "Поддерживается только в Windows"
|
"description": "Поддерживается только в Windows & macOS",
|
||||||
|
"mac_process_trust_hint": {
|
||||||
|
"title": "Права доступа",
|
||||||
|
"description": [
|
||||||
|
"Помощник выбора требует <strong>Права доступа</strong> для правильной работы.",
|
||||||
|
"Пожалуйста, перейдите в \"<strong>Настройки</strong>\" и нажмите \"<strong>Открыть системные настройки</strong>\" в запросе разрешения, который появится позже. Затем найдите \"<strong>Cherry Studio</strong>\" в списке приложений, который появится позже, и включите переключатель разрешения.",
|
||||||
|
"После завершения настроек, пожалуйста, перезапустите помощник выбора."
|
||||||
|
],
|
||||||
|
"button": {
|
||||||
|
"open_accessibility_settings": "Открыть системные настройки",
|
||||||
|
"go_to_settings": "Настройки"
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"toolbar": {
|
"toolbar": {
|
||||||
"title": "Панель инструментов",
|
"title": "Панель инструментов",
|
||||||
"trigger_mode": {
|
"trigger_mode": {
|
||||||
"title": "Режим активации",
|
"title": "Режим активации",
|
||||||
"description": "Показывать панель сразу при выделении, или только при удержании Ctrl, или только при нажатии на сочетание клавиш",
|
"description": "Показывать панель сразу при выделении, или только при удержании Ctrl, или только при нажатии на сочетание клавиш",
|
||||||
"description_note": "В некоторых приложениях Ctrl может не работать. Если вы используете AHK или другие инструменты для переназначения Ctrl, это может привести к тому, что некоторые приложения не смогут выделить текст.",
|
"description_note": {
|
||||||
|
"windows": "В некоторых приложениях Ctrl может не работать. Если вы используете AHK или другие инструменты для переназначения Ctrl, это может привести к тому, что некоторые приложения не смогут выделить текст.",
|
||||||
|
"mac": "В некоторых приложениях ⌘ может не работать. Если вы используете сочетания клавиш или инструменты для переназначения ⌘, это может привести к тому, что некоторые приложения не смогут выделить текст."
|
||||||
|
},
|
||||||
"selected": "При выделении",
|
"selected": "При выделении",
|
||||||
"selected_note": "После выделения",
|
"selected_note": "После выделения",
|
||||||
"ctrlkey": "По Ctrl",
|
"ctrlkey": "По Ctrl",
|
||||||
@@ -2153,9 +2207,96 @@
|
|||||||
},
|
},
|
||||||
"filter_modal": {
|
"filter_modal": {
|
||||||
"title": "Список фильтрации",
|
"title": "Список фильтрации",
|
||||||
"user_tips": "Введите имя исполняемого файла приложения, один на строку, не учитывая регистр, можно использовать подстановку *"
|
"user_tips": {
|
||||||
|
"windows": "Введите имя исполняемого файла приложения, один на строку, не учитывая регистр, можно использовать подстановку *",
|
||||||
|
"mac": "Введите Bundle ID приложения, один на строку, не учитывая регистр, можно использовать подстановку *"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"memory": {
|
||||||
|
"add_memory": "Добавить память",
|
||||||
|
"edit_memory": "Редактировать память",
|
||||||
|
"memory_content": "Содержимое памяти",
|
||||||
|
"please_enter_memory": "Пожалуйста, введите содержимое памяти",
|
||||||
|
"memory_placeholder": "Введите содержимое памяти...",
|
||||||
|
"user_id": "ID пользователя",
|
||||||
|
"user_id_placeholder": "Введите ID пользователя (необязательно)",
|
||||||
|
"load_failed": "Не удалось загрузить память",
|
||||||
|
"add_success": "Память успешно добавлена",
|
||||||
|
"add_failed": "Не удалось добавить память",
|
||||||
|
"update_success": "Память успешно обновлена",
|
||||||
|
"update_failed": "Не удалось обновить память",
|
||||||
|
"delete_success": "Память успешно удалена",
|
||||||
|
"delete_failed": "Не удалось удалить память",
|
||||||
|
"delete_confirm_title": "Удалить память",
|
||||||
|
"delete_confirm_content": "Вы уверены, что хотите удалить {{count}} записей памяти?",
|
||||||
|
"delete_confirm": "Вы уверены, что хотите удалить эту запись памяти?",
|
||||||
|
"time": "Время",
|
||||||
|
"user": "Пользователь",
|
||||||
|
"content": "Содержимое",
|
||||||
|
"score": "Оценка",
|
||||||
|
"memories_description": "Показано {{count}} из {{total}} записей памяти",
|
||||||
|
"search_placeholder": "Поиск памяти...",
|
||||||
|
"start_date": "Дата начала",
|
||||||
|
"end_date": "Дата окончания",
|
||||||
|
"all_users": "Все пользователи",
|
||||||
|
"users": "пользователи",
|
||||||
|
"delete_selected": "Удалить выбранные",
|
||||||
|
"reset_filters": "Сбросить фильтры",
|
||||||
|
"pagination_total": "{{start}}-{{end}} из {{total}} элементов",
|
||||||
|
"current_user": "Текущий пользователь",
|
||||||
|
"select_user": "Выбрать пользователя",
|
||||||
|
"default_user": "Пользователь по умолчанию",
|
||||||
|
"switch_user": "Переключить пользователя",
|
||||||
|
"user_switched": "Контекст пользователя переключен на {{user}}",
|
||||||
|
"switch_user_confirm": "Переключить контекст пользователя на {{user}}?",
|
||||||
|
"add_user": "Добавить пользователя",
|
||||||
|
"add_new_user": "Добавить нового пользователя",
|
||||||
|
"new_user_id": "Новый ID пользователя",
|
||||||
|
"new_user_id_placeholder": "Введите уникальный ID пользователя",
|
||||||
|
"user_id_required": "ID пользователя обязателен",
|
||||||
|
"user_id_reserved": "'default-user' зарезервирован, используйте другой ID",
|
||||||
|
"user_id_exists": "Этот ID пользователя уже существует",
|
||||||
|
"user_id_too_long": "ID пользователя не может превышать 50 символов",
|
||||||
|
"user_id_invalid_chars": "ID пользователя может содержать только буквы, цифры, дефисы и подчёркивания",
|
||||||
|
"user_id_rules": "ID пользователя должен быть уникальным и содержать только буквы, цифры, дефисы (-) и подчёркивания (_)",
|
||||||
|
"user_created": "Пользователь {{user}} создан и переключен успешно",
|
||||||
|
"add_user_failed": "Не удалось добавить пользователя",
|
||||||
|
"memory": "воспоминаний",
|
||||||
|
"reset_user_memories": "Сбросить воспоминания пользователя",
|
||||||
|
"delete_user": "Удалить пользователя",
|
||||||
|
"loading_memories": "Загрузка воспоминаний...",
|
||||||
|
"no_memories": "Нет воспоминаний",
|
||||||
|
"no_matching_memories": "Подходящие воспоминания не найдены",
|
||||||
|
"no_memories_description": "Начните с добавления вашего первого воспоминания",
|
||||||
|
"try_different_filters": "Попробуйте изменить критерии поиска",
|
||||||
|
"add_first_memory": "Добавить первое воспоминание",
|
||||||
|
"user_switch_failed": "Не удалось переключить пользователя",
|
||||||
|
"cannot_delete_default_user": "Нельзя удалить пользователя по умолчанию",
|
||||||
|
"delete_user_confirm_title": "Удалить пользователя",
|
||||||
|
"delete_user_confirm_content": "Вы уверены, что хотите удалить пользователя {{user}} и все его воспоминания?",
|
||||||
|
"user_deleted": "Пользователь {{user}} успешно удален",
|
||||||
|
"delete_user_failed": "Не удалось удалить пользователя",
|
||||||
|
"reset_user_memories_confirm_title": "Сбросить воспоминания пользователя",
|
||||||
|
"reset_user_memories_confirm_content": "Вы уверены, что хотите сбросить все воспоминания пользователя {{user}}?",
|
||||||
|
"user_memories_reset": "Все воспоминания пользователя {{user}} сброшены",
|
||||||
|
"reset_user_memories_failed": "Не удалось сбросить воспоминания пользователя",
|
||||||
|
"delete_confirm_single": "Вы уверены, что хотите удалить это воспоминание?",
|
||||||
|
"total_memories": "всего воспоминаний",
|
||||||
|
"default": "По умолчанию",
|
||||||
|
"custom": "Пользовательский",
|
||||||
|
"title": "Воспоминания",
|
||||||
|
"description": "Память позволяет хранить и управлять информацией о ваших взаимодействиях с ассистентом. Вы можете добавлять, редактировать и удалять воспоминания, а также фильтровать и искать их.",
|
||||||
|
"global_memory_enabled": "Глобальная память включена",
|
||||||
|
"global_memory": "Глобальная память",
|
||||||
|
"enable_global_memory_first": "Сначала включите глобальную память",
|
||||||
|
"configure_memory_first": "Сначала настройте параметры памяти",
|
||||||
|
"global_memory_disabled_title": "Глобальная память отключена",
|
||||||
|
"global_memory_disabled_desc": "Чтобы использовать функции памяти, сначала включите глобальную память в настройках ассистента.",
|
||||||
|
"not_configured_title": "Память не настроена",
|
||||||
|
"not_configured_desc": "Пожалуйста, настройте модели встраивания и LLM в настройках памяти, чтобы включить функциональность памяти.",
|
||||||
|
"go_to_memory_page": "Перейти на страницу памяти"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -406,9 +406,11 @@
|
|||||||
"prompt": "提示词",
|
"prompt": "提示词",
|
||||||
"provider": "提供商",
|
"provider": "提供商",
|
||||||
"regenerate": "重新生成",
|
"regenerate": "重新生成",
|
||||||
|
"refresh": "刷新",
|
||||||
"rename": "重命名",
|
"rename": "重命名",
|
||||||
"reset": "重置",
|
"reset": "重置",
|
||||||
"save": "保存",
|
"save": "保存",
|
||||||
|
"settings": "设置",
|
||||||
"search": "搜索",
|
"search": "搜索",
|
||||||
"select": "选择",
|
"select": "选择",
|
||||||
"selectedMessages": "选中 {{count}} 条消息",
|
"selectedMessages": "选中 {{count}} 条消息",
|
||||||
@@ -423,7 +425,9 @@
|
|||||||
"pinyin.asc": "按拼音升序",
|
"pinyin.asc": "按拼音升序",
|
||||||
"pinyin.desc": "按拼音降序"
|
"pinyin.desc": "按拼音降序"
|
||||||
},
|
},
|
||||||
"no_results": "无结果"
|
"no_results": "无结果",
|
||||||
|
"enabled": "已启用",
|
||||||
|
"disabled": "已禁用"
|
||||||
},
|
},
|
||||||
"docs": {
|
"docs": {
|
||||||
"title": "帮助文档"
|
"title": "帮助文档"
|
||||||
@@ -551,7 +555,11 @@
|
|||||||
"rename": "重命名",
|
"rename": "重命名",
|
||||||
"search": "搜索知识库",
|
"search": "搜索知识库",
|
||||||
"search_placeholder": "输入查询内容",
|
"search_placeholder": "输入查询内容",
|
||||||
"settings": "知识库设置",
|
"settings": {
|
||||||
|
"title": "知识库设置",
|
||||||
|
"preprocessing": "预处理",
|
||||||
|
"preprocessing_tooltip": "使用 OCR 预处理上传的文件"
|
||||||
|
},
|
||||||
"sitemap_placeholder": "请输入站点地图 URL",
|
"sitemap_placeholder": "请输入站点地图 URL",
|
||||||
"sitemaps": "网站",
|
"sitemaps": "网站",
|
||||||
"source": "来源",
|
"source": "来源",
|
||||||
@@ -572,7 +580,13 @@
|
|||||||
"topN_tooltip": "返回的匹配结果数量,数值越大,匹配结果越多,但消耗的 Token 也越多",
|
"topN_tooltip": "返回的匹配结果数量,数值越大,匹配结果越多,但消耗的 Token 也越多",
|
||||||
"url_added": "网址已添加",
|
"url_added": "网址已添加",
|
||||||
"url_placeholder": "请输入网址, 多个网址用回车分隔",
|
"url_placeholder": "请输入网址, 多个网址用回车分隔",
|
||||||
"urls": "网址"
|
"urls": "网址",
|
||||||
|
"status_embedding_completed": "嵌入完成",
|
||||||
|
"status_preprocess_completed": "预处理完成",
|
||||||
|
"status_embedding_failed": "嵌入失败",
|
||||||
|
"status_preprocess_failed": "预处理失败",
|
||||||
|
"quota": "{{name}} 剩余额度:{{quota}}",
|
||||||
|
"quota_infinity": "{{name}} 剩余额度:无限制"
|
||||||
},
|
},
|
||||||
"languages": {
|
"languages": {
|
||||||
"arabic": "阿拉伯文",
|
"arabic": "阿拉伯文",
|
||||||
@@ -835,7 +849,7 @@
|
|||||||
"notification": {
|
"notification": {
|
||||||
"assistant": "助手响应",
|
"assistant": "助手响应",
|
||||||
"knowledge.success": "成功添加 {{type}} 到知识库",
|
"knowledge.success": "成功添加 {{type}} 到知识库",
|
||||||
"knowledge.error": "添加 {{type}} 到知识库失败: {{error}}"
|
"knowledge.error": "{{error}}"
|
||||||
},
|
},
|
||||||
"ollama": {
|
"ollama": {
|
||||||
"keep_alive_time.description": "对话后模型在内存中保持的时间(默认:5分钟)",
|
"keep_alive_time.description": "对话后模型在内存中保持的时间(默认:5分钟)",
|
||||||
@@ -1658,7 +1672,7 @@
|
|||||||
"title": "通知设置",
|
"title": "通知设置",
|
||||||
"assistant": "助手消息",
|
"assistant": "助手消息",
|
||||||
"backup": "备份",
|
"backup": "备份",
|
||||||
"knowledge_embed": "知识嵌入"
|
"knowledge_embed": "知识库"
|
||||||
},
|
},
|
||||||
"provider": {
|
"provider": {
|
||||||
"add.name": "提供商名称",
|
"add.name": "提供商名称",
|
||||||
@@ -1819,67 +1833,6 @@
|
|||||||
"tray.onclose": "关闭时最小化到托盘",
|
"tray.onclose": "关闭时最小化到托盘",
|
||||||
"tray.show": "显示托盘图标",
|
"tray.show": "显示托盘图标",
|
||||||
"tray.title": "托盘",
|
"tray.title": "托盘",
|
||||||
"websearch": {
|
|
||||||
"blacklist": "黑名单",
|
|
||||||
"blacklist_description": "在搜索结果中不会出现以下网站的结果",
|
|
||||||
"blacklist_tooltip": "请使用以下格式(换行分隔)\n匹配模式: *://*.example.com/*\n正则表达式: /example\\.(net|org)/",
|
|
||||||
"check": "检测",
|
|
||||||
"check_failed": "验证失败",
|
|
||||||
"check_success": "验证成功",
|
|
||||||
"overwrite": "覆盖服务商搜索",
|
|
||||||
"overwrite_tooltip": "强制使用搜索服务商而不是大语言模型进行搜索",
|
|
||||||
"get_api_key": "点击这里获取密钥",
|
|
||||||
"no_provider_selected": "请选择搜索服务商后再检测",
|
|
||||||
"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": {
|
|
||||||
"api_key": "Tavily API 密钥",
|
|
||||||
"api_key.placeholder": "请输入 Tavily API 密钥",
|
|
||||||
"description": "Tavily 是一个为 AI 代理量身定制的搜索引擎,提供实时、准确的结果、智能查询建议和深入的研究能力",
|
|
||||||
"title": "Tavily"
|
|
||||||
},
|
|
||||||
"title": "网络搜索",
|
|
||||||
"apikey": "API 密钥",
|
|
||||||
"free": "免费",
|
|
||||||
"compression": {
|
|
||||||
"title": "搜索结果压缩",
|
|
||||||
"method": "压缩方法",
|
|
||||||
"method.none": "不压缩",
|
|
||||||
"method.cutoff": "截断",
|
|
||||||
"cutoff.limit": "截断长度",
|
|
||||||
"cutoff.limit.placeholder": "输入长度",
|
|
||||||
"cutoff.limit.tooltip": "限制搜索结果的内容长度, 超过限制的内容将被截断(例如 2000 字符)",
|
|
||||||
"cutoff.unit.char": "字符",
|
|
||||||
"cutoff.unit.token": "Token",
|
|
||||||
"method.rag": "RAG",
|
|
||||||
"rag.document_count": "文档数量",
|
|
||||||
"rag.document_count.default": "默认",
|
|
||||||
"rag.document_count.tooltip": "预期从单个搜索结果中提取的文档数量,实际提取的总数量是这个值乘以搜索结果数量。",
|
|
||||||
"rag.embedding_dimensions.auto_get": "自动获取维度",
|
|
||||||
"rag.embedding_dimensions.placeholder": "不设置维度",
|
|
||||||
"rag.embedding_dimensions.tooltip": "留空则不传递 dimensions 参数",
|
|
||||||
"info": {
|
|
||||||
"dimensions_auto_success": "维度自动获取成功,维度为 {{dimensions}}"
|
|
||||||
},
|
|
||||||
"error": {
|
|
||||||
"embedding_model_required": "请先选择嵌入模型",
|
|
||||||
"dimensions_auto_failed": "维度自动获取失败",
|
|
||||||
"provider_not_found": "未找到服务商",
|
|
||||||
"rag_failed": "RAG 失败"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"quickPhrase": {
|
"quickPhrase": {
|
||||||
"title": "快捷短语",
|
"title": "快捷短语",
|
||||||
"add": "添加短语",
|
"add": "添加短语",
|
||||||
@@ -1925,7 +1878,93 @@
|
|||||||
"service_tier.auto": "自动",
|
"service_tier.auto": "自动",
|
||||||
"service_tier.default": "默认",
|
"service_tier.default": "默认",
|
||||||
"service_tier.flex": "灵活"
|
"service_tier.flex": "灵活"
|
||||||
}
|
},
|
||||||
|
"tool": {
|
||||||
|
"title": "工具设置",
|
||||||
|
"preprocess": {
|
||||||
|
"title": "文档预处理",
|
||||||
|
"provider": "文档预处理服务商",
|
||||||
|
"provider_placeholder": "选择一个文档预处理服务商"
|
||||||
|
},
|
||||||
|
"ocr": {
|
||||||
|
"title": "OCR",
|
||||||
|
"provider": "OCR 服务商",
|
||||||
|
"provider_placeholder": "选择一个 OCR 服务商",
|
||||||
|
"mac_system_ocr_options": {
|
||||||
|
"mode": {
|
||||||
|
"title": "识别模式",
|
||||||
|
"accurate": "准确",
|
||||||
|
"fast": "快速"
|
||||||
|
},
|
||||||
|
"min_confidence": "最低置信度"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"websearch": {
|
||||||
|
"blacklist": "黑名单",
|
||||||
|
"blacklist_description": "在搜索结果中不会出现以下网站的结果",
|
||||||
|
"blacklist_tooltip": "请使用以下格式(换行分隔)\n匹配模式: *://*.example.com/*\n正则表达式: /example\\.(net|org)/",
|
||||||
|
"check": "检测",
|
||||||
|
"check_failed": "验证失败",
|
||||||
|
"check_success": "验证成功",
|
||||||
|
"overwrite": "覆盖服务商搜索",
|
||||||
|
"overwrite_tooltip": "强制使用搜索服务商而不是大语言模型进行搜索",
|
||||||
|
"get_api_key": "点击这里获取密钥",
|
||||||
|
"no_provider_selected": "请选择搜索服务商后再检测",
|
||||||
|
"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": {
|
||||||
|
"api_key": "Tavily API 密钥",
|
||||||
|
"api_key.placeholder": "请输入 Tavily API 密钥",
|
||||||
|
"description": "Tavily 是一个为 AI 代理量身定制的搜索引擎,提供实时、准确的结果、智能查询建议和深入的研究能力",
|
||||||
|
"title": "Tavily"
|
||||||
|
},
|
||||||
|
"title": "网络搜索",
|
||||||
|
"apikey": "API 密钥",
|
||||||
|
"free": "免费",
|
||||||
|
"content_limit": "内容长度限制",
|
||||||
|
"content_limit_tooltip": "限制搜索结果的内容长度, 超过限制的内容将被截断",
|
||||||
|
"compression": {
|
||||||
|
"title": "搜索结果压缩",
|
||||||
|
"method": "压缩方法",
|
||||||
|
"method.none": "不压缩",
|
||||||
|
"method.cutoff": "截断",
|
||||||
|
"cutoff.limit": "截断长度",
|
||||||
|
"cutoff.limit.placeholder": "输入长度",
|
||||||
|
"cutoff.limit.tooltip": "限制搜索结果的内容长度, 超过限制的内容将被截断(例如 2000 字符)",
|
||||||
|
"cutoff.unit.char": "字符",
|
||||||
|
"cutoff.unit.token": "Token",
|
||||||
|
"method.rag": "RAG",
|
||||||
|
"rag.document_count": "文档数量",
|
||||||
|
"rag.document_count.default": "默认",
|
||||||
|
"rag.document_count.tooltip": "预期从单个搜索结果中提取的文档数量,实际提取的总数量是这个值乘以搜索结果数量。",
|
||||||
|
"rag.embedding_dimensions.auto_get": "自动获取维度",
|
||||||
|
"rag.embedding_dimensions.placeholder": "不设置维度",
|
||||||
|
"rag.embedding_dimensions.tooltip": "留空则不传递 dimensions 参数",
|
||||||
|
"info": {
|
||||||
|
"dimensions_auto_success": "维度自动获取成功,维度为 {{dimensions}}"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"embedding_model_required": "请先选择嵌入模型",
|
||||||
|
"dimensions_auto_failed": "维度自动获取失败",
|
||||||
|
"provider_not_found": "未找到服务商",
|
||||||
|
"rag_failed": "RAG 失败"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"preprocessOrOcr.tooltip": "在设置 -> 工具中设置文档预处理服务商或OCR,文档预处理可以有效提升复杂格式文档与扫描版文档的检索效果,OCR仅可识别文档内图片或扫描版PDF的文本"
|
||||||
|
},
|
||||||
|
"mineru.api_key": "MinerU现在提供每日500页的免费额度,您不需要填写密钥。"
|
||||||
},
|
},
|
||||||
"translate": {
|
"translate": {
|
||||||
"any.language": "任意语言",
|
"any.language": "任意语言",
|
||||||
@@ -2021,14 +2060,29 @@
|
|||||||
"experimental": "实验性功能",
|
"experimental": "实验性功能",
|
||||||
"enable": {
|
"enable": {
|
||||||
"title": "启用",
|
"title": "启用",
|
||||||
"description": "当前仅支持 Windows 系统"
|
"description": "当前仅支持 Windows & macOS",
|
||||||
|
"mac_process_trust_hint": {
|
||||||
|
"title": "辅助功能权限",
|
||||||
|
"description": [
|
||||||
|
"划词助手需「<strong>辅助功能权限</strong>」才能正常工作。",
|
||||||
|
"请点击「<strong>去设置</strong>」,并在稍后弹出的权限请求弹窗中点击 「<strong>打开系统设置</strong>」 按钮,然后在之后的应用列表中找到 「<strong>Cherry Studio</strong>」,并打开权限开关。",
|
||||||
|
"完成设置后,请再次开启划词助手。"
|
||||||
|
],
|
||||||
|
"button": {
|
||||||
|
"open_accessibility_settings": "打开辅助功能设置",
|
||||||
|
"go_to_settings": "去设置"
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"toolbar": {
|
"toolbar": {
|
||||||
"title": "工具栏",
|
"title": "工具栏",
|
||||||
"trigger_mode": {
|
"trigger_mode": {
|
||||||
"title": "取词方式",
|
"title": "取词方式",
|
||||||
"description": "划词后,触发取词并显示工具栏的方式",
|
"description": "划词后,触发取词并显示工具栏的方式",
|
||||||
"description_note": "少数应用不支持通过 Ctrl 键划词。若使用了AHK等工具对 Ctrl 键进行了重映射,可能导致部分应用无法划词。",
|
"description_note": {
|
||||||
|
"windows": "少数应用不支持通过 Ctrl 键划词。若使用了AHK等按键映射工具对 Ctrl 键进行了重映射,可能导致部分应用无法划词。",
|
||||||
|
"mac": "若使用了快捷键或键盘映射工具对 ⌘ 键进行了重映射,可能导致部分应用无法划词。"
|
||||||
|
},
|
||||||
"selected": "划词",
|
"selected": "划词",
|
||||||
"selected_note": "划词后立即显示工具栏",
|
"selected_note": "划词后立即显示工具栏",
|
||||||
"ctrlkey": "Ctrl 键",
|
"ctrlkey": "Ctrl 键",
|
||||||
@@ -2153,9 +2207,96 @@
|
|||||||
},
|
},
|
||||||
"filter_modal": {
|
"filter_modal": {
|
||||||
"title": "应用筛选名单",
|
"title": "应用筛选名单",
|
||||||
"user_tips": "请输入应用的执行文件名,每行一个,不区分大小写,可以模糊匹配。例如:chrome.exe、weixin.exe、Cherry Studio.exe等"
|
"user_tips": {
|
||||||
|
"windows": "请输入应用的执行文件名,每行一个,不区分大小写,可以模糊匹配。例如:chrome.exe、weixin.exe、Cherry Studio.exe等",
|
||||||
|
"mac": "请输入应用的Bundle ID,每行一个,不区分大小写,可以模糊匹配。例如:com.google.Chrome、com.apple.mail等"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"memory": {
|
||||||
|
"add_memory": "添加记忆",
|
||||||
|
"edit_memory": "编辑记忆",
|
||||||
|
"memory_content": "记忆内容",
|
||||||
|
"please_enter_memory": "请输入记忆内容",
|
||||||
|
"memory_placeholder": "输入记忆内容...",
|
||||||
|
"user_id": "用户ID",
|
||||||
|
"user_id_placeholder": "输入用户ID(可选)",
|
||||||
|
"load_failed": "加载记忆失败",
|
||||||
|
"add_success": "记忆添加成功",
|
||||||
|
"add_failed": "添加记忆失败",
|
||||||
|
"update_success": "记忆更新成功",
|
||||||
|
"update_failed": "更新记忆失败",
|
||||||
|
"delete_success": "记忆删除成功",
|
||||||
|
"delete_failed": "删除记忆失败",
|
||||||
|
"delete_confirm_title": "删除记忆",
|
||||||
|
"delete_confirm_content": "确定要删除 {{count}} 条记忆吗?",
|
||||||
|
"delete_confirm": "确定要删除这条记忆吗?",
|
||||||
|
"time": "时间",
|
||||||
|
"user": "用户",
|
||||||
|
"content": "内容",
|
||||||
|
"score": "分数",
|
||||||
|
"title": "记忆",
|
||||||
|
"memories_description": "显示 {{count}} / {{total}} 条记忆",
|
||||||
|
"search_placeholder": "搜索记忆...",
|
||||||
|
"start_date": "开始日期",
|
||||||
|
"end_date": "结束日期",
|
||||||
|
"all_users": "所有用户",
|
||||||
|
"users": "用户",
|
||||||
|
"delete_selected": "删除选中",
|
||||||
|
"reset_filters": "重置筛选",
|
||||||
|
"pagination_total": "第 {{start}}-{{end}} 项,共 {{total}} 项",
|
||||||
|
"current_user": "当前用户",
|
||||||
|
"select_user": "选择用户",
|
||||||
|
"default_user": "默认用户",
|
||||||
|
"switch_user": "切换用户",
|
||||||
|
"user_switched": "用户上下文已切换到 {{user}}",
|
||||||
|
"switch_user_confirm": "将用户上下文切换到 {{user}}?",
|
||||||
|
"add_user": "添加用户",
|
||||||
|
"add_new_user": "添加新用户",
|
||||||
|
"new_user_id": "新用户ID",
|
||||||
|
"new_user_id_placeholder": "输入唯一的用户ID",
|
||||||
|
"user_id_required": "用户ID为必填项",
|
||||||
|
"user_id_reserved": "'default-user' 为保留字,请使用其他ID",
|
||||||
|
"user_id_exists": "该用户ID已存在",
|
||||||
|
"user_id_too_long": "用户ID不能超过50个字符",
|
||||||
|
"user_id_invalid_chars": "用户ID只能包含字母、数字、连字符和下划线",
|
||||||
|
"user_id_rules": "用户ID必须唯一,只能包含字母、数字、连字符(-)和下划线(_)",
|
||||||
|
"user_created": "用户 {{user}} 创建并切换成功",
|
||||||
|
"add_user_failed": "添加用户失败",
|
||||||
|
"memory": "条记忆",
|
||||||
|
"reset_user_memories": "重置用户记忆",
|
||||||
|
"delete_user": "删除用户",
|
||||||
|
"loading_memories": "正在加载记忆...",
|
||||||
|
"no_memories": "暂无记忆",
|
||||||
|
"no_matching_memories": "未找到匹配的记忆",
|
||||||
|
"no_memories_description": "开始添加您的第一条记忆吧",
|
||||||
|
"try_different_filters": "尝试调整搜索条件",
|
||||||
|
"add_first_memory": "添加您的第一条记忆",
|
||||||
|
"user_switch_failed": "切换用户失败",
|
||||||
|
"cannot_delete_default_user": "不能删除默认用户",
|
||||||
|
"delete_user_confirm_title": "删除用户",
|
||||||
|
"delete_user_confirm_content": "确定要删除用户 {{user}} 及其所有记忆吗?",
|
||||||
|
"user_deleted": "用户 {{user}} 删除成功",
|
||||||
|
"delete_user_failed": "删除用户失败",
|
||||||
|
"reset_user_memories_confirm_title": "重置用户记忆",
|
||||||
|
"reset_user_memories_confirm_content": "确定要重置 {{user}} 的所有记忆吗?",
|
||||||
|
"user_memories_reset": "{{user}} 的所有记忆已重置",
|
||||||
|
"reset_user_memories_failed": "重置用户记忆失败",
|
||||||
|
"delete_confirm_single": "确定要删除这条记忆吗?",
|
||||||
|
"total_memories": "条记忆",
|
||||||
|
"default": "默认",
|
||||||
|
"custom": "自定义",
|
||||||
|
"description": "记忆功能允许您存储和管理与助手交互的信息。您可以添加、编辑和删除记忆,也可以对它们进行过滤和搜索。",
|
||||||
|
"global_memory_enabled": "全局记忆已启用",
|
||||||
|
"global_memory": "全局记忆",
|
||||||
|
"enable_global_memory_first": "请先启用全局记忆",
|
||||||
|
"configure_memory_first": "请先配置记忆设置",
|
||||||
|
"global_memory_disabled_title": "全局记忆已禁用",
|
||||||
|
"global_memory_disabled_desc": "要使用记忆功能,请先在助手设置中启用全局记忆。",
|
||||||
|
"not_configured_title": "记忆未配置",
|
||||||
|
"not_configured_desc": "请在记忆设置中配置嵌入和LLM模型以启用记忆功能。",
|
||||||
|
"go_to_memory_page": "前往记忆页面"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -406,9 +406,11 @@
|
|||||||
"prompt": "提示詞",
|
"prompt": "提示詞",
|
||||||
"provider": "供應商",
|
"provider": "供應商",
|
||||||
"regenerate": "重新生成",
|
"regenerate": "重新生成",
|
||||||
|
"refresh": "重新整理",
|
||||||
"rename": "重新命名",
|
"rename": "重新命名",
|
||||||
"reset": "重設",
|
"reset": "重設",
|
||||||
"save": "儲存",
|
"save": "儲存",
|
||||||
|
"settings": "設定",
|
||||||
"search": "搜尋",
|
"search": "搜尋",
|
||||||
"select": "選擇",
|
"select": "選擇",
|
||||||
"selectedMessages": "選中 {{count}} 條訊息",
|
"selectedMessages": "選中 {{count}} 條訊息",
|
||||||
@@ -423,7 +425,9 @@
|
|||||||
"pinyin.asc": "按拼音升序",
|
"pinyin.asc": "按拼音升序",
|
||||||
"pinyin.desc": "按拼音降序"
|
"pinyin.desc": "按拼音降序"
|
||||||
},
|
},
|
||||||
"no_results": "沒有結果"
|
"no_results": "沒有結果",
|
||||||
|
"enabled": "已啟用",
|
||||||
|
"disabled": "已停用"
|
||||||
},
|
},
|
||||||
"docs": {
|
"docs": {
|
||||||
"title": "說明文件"
|
"title": "說明文件"
|
||||||
@@ -543,7 +547,11 @@
|
|||||||
"rename": "重新命名",
|
"rename": "重新命名",
|
||||||
"search": "搜尋知識庫",
|
"search": "搜尋知識庫",
|
||||||
"search_placeholder": "輸入查詢內容",
|
"search_placeholder": "輸入查詢內容",
|
||||||
"settings": "知識庫設定",
|
"settings": {
|
||||||
|
"title": "知識庫設定",
|
||||||
|
"preprocessing": "預處理",
|
||||||
|
"preprocessing_tooltip": "預處理上傳的文件"
|
||||||
|
},
|
||||||
"sitemap_placeholder": "請輸入網站地圖 URL",
|
"sitemap_placeholder": "請輸入網站地圖 URL",
|
||||||
"sitemaps": "網站",
|
"sitemaps": "網站",
|
||||||
"source": "來源",
|
"source": "來源",
|
||||||
@@ -567,12 +575,18 @@
|
|||||||
"urls": "網址",
|
"urls": "網址",
|
||||||
"dimensions": "嵌入維度",
|
"dimensions": "嵌入維度",
|
||||||
"dimensions_size_tooltip": "嵌入維度大小,數值越大,嵌入維度越大,但消耗的 Token 也越多",
|
"dimensions_size_tooltip": "嵌入維度大小,數值越大,嵌入維度越大,但消耗的 Token 也越多",
|
||||||
|
"status_embedding_completed": "嵌入完成",
|
||||||
|
"status_preprocess_completed": "預處理完成",
|
||||||
|
"status_embedding_failed": "嵌入失敗",
|
||||||
|
"status_preprocess_failed": "預處理失敗",
|
||||||
"dimensions_size_placeholder": " 嵌入維度大小,例如 1024",
|
"dimensions_size_placeholder": " 嵌入維度大小,例如 1024",
|
||||||
"dimensions_auto_set": "自動設定嵌入維度",
|
"dimensions_auto_set": "自動設定嵌入維度",
|
||||||
"dimensions_error_invalid": "請輸入嵌入維度大小",
|
"dimensions_error_invalid": "請輸入嵌入維度大小",
|
||||||
"dimensions_size_too_large": "嵌入維度不能超過模型上下文限制({{max_context}})",
|
"dimensions_size_too_large": "嵌入維度不能超過模型上下文限制({{max_context}})",
|
||||||
"dimensions_set_right": "⚠️ 請確保模型支援所設置的嵌入維度大小",
|
"dimensions_set_right": "⚠️ 請確保模型支援所設置的嵌入維度大小",
|
||||||
"dimensions_default": "模型將使用預設嵌入維度"
|
"dimensions_default": "模型將使用預設嵌入維度",
|
||||||
|
"quota": "{{name}} 剩餘配額:{{quota}}",
|
||||||
|
"quota_infinity": "{{name}} 配額:無限制"
|
||||||
},
|
},
|
||||||
"languages": {
|
"languages": {
|
||||||
"arabic": "阿拉伯文",
|
"arabic": "阿拉伯文",
|
||||||
@@ -835,7 +849,7 @@
|
|||||||
"notification": {
|
"notification": {
|
||||||
"assistant": "助手回應",
|
"assistant": "助手回應",
|
||||||
"knowledge.success": "成功將{{type}}新增至知識庫",
|
"knowledge.success": "成功將{{type}}新增至知識庫",
|
||||||
"knowledge.error": "無法將 {{type}} 加入知識庫: {{error}}"
|
"knowledge.error": "{{error}}"
|
||||||
},
|
},
|
||||||
"ollama": {
|
"ollama": {
|
||||||
"keep_alive_time.description": "對話後模型在記憶體中保持的時間(預設為 5 分鐘)",
|
"keep_alive_time.description": "對話後模型在記憶體中保持的時間(預設為 5 分鐘)",
|
||||||
@@ -1796,75 +1810,99 @@
|
|||||||
"theme.window.style.title": "視窗樣式",
|
"theme.window.style.title": "視窗樣式",
|
||||||
"theme.window.style.transparent": "透明視窗",
|
"theme.window.style.transparent": "透明視窗",
|
||||||
"title": "設定",
|
"title": "設定",
|
||||||
|
"tool": {
|
||||||
|
"title": "工具設定",
|
||||||
|
"websearch": {
|
||||||
|
"blacklist": "黑名單",
|
||||||
|
"blacklist_description": "以下網站不會出現在搜尋結果中",
|
||||||
|
"blacklist_tooltip": "請使用以下格式 (換行符號分隔)\nexample.com\nhttps://www.example.com\nhttps://example.com\n*://*.example.com",
|
||||||
|
"check": "檢查",
|
||||||
|
"check_failed": "驗證失敗",
|
||||||
|
"check_success": "驗證成功",
|
||||||
|
"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 金鑰",
|
||||||
|
"api_key.placeholder": "請輸入 Tavily API 金鑰",
|
||||||
|
"description": "Tavily 是一個為 AI 代理量身訂製的搜尋引擎,提供即時、準確的結果、智慧查詢建議和深入的研究能力",
|
||||||
|
"title": "Tavily"
|
||||||
|
},
|
||||||
|
"title": "網路搜尋",
|
||||||
|
"subscribe": "黑名單訂閱",
|
||||||
|
"subscribe_update": "更新",
|
||||||
|
"subscribe_add": "新增訂閱",
|
||||||
|
"subscribe_url": "訂閱網址",
|
||||||
|
"subscribe_name": "替代名稱",
|
||||||
|
"subscribe_name.placeholder": "下載的訂閱源沒有名稱時使用的替代名稱。",
|
||||||
|
"subscribe_add_success": "訂閱源新增成功!",
|
||||||
|
"subscribe_delete": "刪除",
|
||||||
|
"overwrite": "覆蓋搜尋服務",
|
||||||
|
"overwrite_tooltip": "強制使用搜尋服務而不是 LLM",
|
||||||
|
"apikey": "API 金鑰",
|
||||||
|
"free": "免費",
|
||||||
|
"content_limit": "內容長度限制",
|
||||||
|
"content_limit_tooltip": "限制搜尋結果的內容長度;超過限制的內容將被截斷。",
|
||||||
|
"compression": {
|
||||||
|
"title": "搜尋結果壓縮",
|
||||||
|
"method": "壓縮方法",
|
||||||
|
"method.none": "不壓縮",
|
||||||
|
"method.cutoff": "截斷",
|
||||||
|
"cutoff.limit": "截斷長度",
|
||||||
|
"cutoff.limit.placeholder": "輸入長度",
|
||||||
|
"cutoff.limit.tooltip": "限制搜尋結果的內容長度,超過限制的內容將被截斷(例如 2000 字符)",
|
||||||
|
"cutoff.unit.char": "字符",
|
||||||
|
"cutoff.unit.token": "Token",
|
||||||
|
"method.rag": "RAG",
|
||||||
|
"rag.document_count": "文檔數量",
|
||||||
|
"rag.document_count.default": "預設",
|
||||||
|
"rag.document_count.tooltip": "預期從單個搜尋結果中提取的文檔數量,實際提取的總數量是這個值乘以搜尋結果數量。",
|
||||||
|
"rag.embedding_dimensions.auto_get": "自動獲取維度",
|
||||||
|
"rag.embedding_dimensions.placeholder": "不設置維度",
|
||||||
|
"rag.embedding_dimensions.tooltip": "留空則不傳遞 dimensions 參數",
|
||||||
|
"info": {
|
||||||
|
"dimensions_auto_success": "維度自動獲取成功,維度為 {{dimensions}}"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"embedding_model_required": "請先選擇嵌入模型",
|
||||||
|
"dimensions_auto_failed": "維度自動獲取失敗",
|
||||||
|
"provider_not_found": "未找到服務商",
|
||||||
|
"rag_failed": "RAG 失敗"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"preprocess": {
|
||||||
|
"title": "前置處理",
|
||||||
|
"provider": "前置處理供應商",
|
||||||
|
"provider_placeholder": "選擇一個預處理供應商"
|
||||||
|
},
|
||||||
|
"preprocessOrOcr.tooltip": "在「設定」->「工具」中設定文件預處理服務供應商或OCR。文件預處理可有效提升複雜格式文件及掃描文件的檢索效能,而OCR僅能辨識文件內圖片文字或掃描PDF文字。",
|
||||||
|
"ocr": {
|
||||||
|
"title": "光學字符識別",
|
||||||
|
"provider": "OCR 供應商",
|
||||||
|
"provider_placeholder": "選擇一個OCR服務提供商",
|
||||||
|
"mac_system_ocr_options": {
|
||||||
|
"mode": {
|
||||||
|
"title": "識別模式",
|
||||||
|
"accurate": "準確",
|
||||||
|
"fast": "快速"
|
||||||
|
},
|
||||||
|
"min_confidence": "最小置信度"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"topic.pin_to_top": "固定話題置頂",
|
||||||
"topic.position": "話題位置",
|
"topic.position": "話題位置",
|
||||||
"topic.position.left": "左側",
|
"topic.position.left": "左側",
|
||||||
"topic.position.right": "右側",
|
"topic.position.right": "右側",
|
||||||
"topic.show.time": "顯示話題時間",
|
"topic.show.time": "顯示話題時間",
|
||||||
"topic.pin_to_top": "固定話題置頂",
|
|
||||||
"tray.onclose": "關閉時最小化到系统匣",
|
"tray.onclose": "關閉時最小化到系统匣",
|
||||||
"tray.show": "顯示系统匣圖示",
|
"tray.show": "顯示系统匣圖示",
|
||||||
"tray.title": "系统匣",
|
"tray.title": "系统匣",
|
||||||
"websearch": {
|
|
||||||
"check_success": "驗證成功",
|
|
||||||
"get_api_key": "點選這裡取得金鑰",
|
|
||||||
"search_with_time": "搜尋包含日期",
|
|
||||||
"tavily": {
|
|
||||||
"api_key": "Tavily API 金鑰",
|
|
||||||
"api_key.placeholder": "請輸入 Tavily API 金鑰",
|
|
||||||
"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": "強制使用搜尋服務商而不是大語言模型進行搜尋",
|
|
||||||
"apikey": "API 金鑰",
|
|
||||||
"free": "免費",
|
|
||||||
"compression": {
|
|
||||||
"title": "搜尋結果壓縮",
|
|
||||||
"method": "壓縮方法",
|
|
||||||
"method.none": "不壓縮",
|
|
||||||
"method.cutoff": "截斷",
|
|
||||||
"cutoff.limit": "截斷長度",
|
|
||||||
"cutoff.limit.placeholder": "輸入長度",
|
|
||||||
"cutoff.limit.tooltip": "限制搜尋結果的內容長度,超過限制的內容將被截斷(例如 2000 字符)",
|
|
||||||
"cutoff.unit.char": "字符",
|
|
||||||
"cutoff.unit.token": "Token",
|
|
||||||
"method.rag": "RAG",
|
|
||||||
"rag.document_count": "文檔數量",
|
|
||||||
"rag.document_count.default": "預設",
|
|
||||||
"rag.document_count.tooltip": "預期從單個搜尋結果中提取的文檔數量,實際提取的總數量是這個值乘以搜尋結果數量。",
|
|
||||||
"rag.embedding_dimensions.auto_get": "自動獲取維度",
|
|
||||||
"rag.embedding_dimensions.placeholder": "不設置維度",
|
|
||||||
"rag.embedding_dimensions.tooltip": "留空則不傳遞 dimensions 參數",
|
|
||||||
"info": {
|
|
||||||
"dimensions_auto_success": "維度自動獲取成功,維度為 {{dimensions}}"
|
|
||||||
},
|
|
||||||
"error": {
|
|
||||||
"embedding_model_required": "請先選擇嵌入模型",
|
|
||||||
"dimensions_auto_failed": "維度自動獲取失敗",
|
|
||||||
"provider_not_found": "未找到服務商",
|
|
||||||
"rag_failed": "RAG 失敗"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"general.auto_check_update.title": "自動更新",
|
"general.auto_check_update.title": "自動更新",
|
||||||
"general.test_plan.title": "測試計畫",
|
"general.test_plan.title": "測試計畫",
|
||||||
"general.test_plan.tooltip": "參與測試計畫,體驗最新功能,但同時也帶來更多風險,請務必提前備份數據",
|
"general.test_plan.tooltip": "參與測試計畫,體驗最新功能,但同時也帶來更多風險,請務必提前備份數據",
|
||||||
@@ -1925,7 +1963,8 @@
|
|||||||
"assistant": "助手訊息",
|
"assistant": "助手訊息",
|
||||||
"backup": "備份訊息",
|
"backup": "備份訊息",
|
||||||
"knowledge_embed": "知識庫訊息"
|
"knowledge_embed": "知識庫訊息"
|
||||||
}
|
},
|
||||||
|
"mineru.api_key": "Mineru 現在每天提供 500 頁的免費配額,且無需輸入金鑰。"
|
||||||
},
|
},
|
||||||
"translate": {
|
"translate": {
|
||||||
"any.language": "任意語言",
|
"any.language": "任意語言",
|
||||||
@@ -2021,14 +2060,29 @@
|
|||||||
"experimental": "實驗性功能",
|
"experimental": "實驗性功能",
|
||||||
"enable": {
|
"enable": {
|
||||||
"title": "啟用",
|
"title": "啟用",
|
||||||
"description": "目前僅支援 Windows 系統"
|
"description": "目前僅支援 Windows & macOS",
|
||||||
|
"mac_process_trust_hint": {
|
||||||
|
"title": "輔助使用權限",
|
||||||
|
"description": [
|
||||||
|
"劃詞助手需「<strong>輔助使用權限</strong>」才能正常工作。",
|
||||||
|
"請點擊「<strong>去設定</strong>」,並在稍後彈出的權限請求彈窗中點擊 「<strong>打開系統設定</strong>」 按鈕,然後在之後的應用程式列表中找到 「<strong>Cherry Studio</strong>」,並開啟權限開關。",
|
||||||
|
"完成設定後,請再次開啟劃詞助手。"
|
||||||
|
],
|
||||||
|
"button": {
|
||||||
|
"open_accessibility_settings": "打開輔助使用設定",
|
||||||
|
"go_to_settings": "去設定"
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"toolbar": {
|
"toolbar": {
|
||||||
"title": "工具列",
|
"title": "工具列",
|
||||||
"trigger_mode": {
|
"trigger_mode": {
|
||||||
"title": "取詞方式",
|
"title": "取詞方式",
|
||||||
"description": "劃詞後,觸發取詞並顯示工具列的方式",
|
"description": "劃詞後,觸發取詞並顯示工具列的方式",
|
||||||
"description_note": "在某些應用中可能無法透過 Ctrl 鍵劃詞。若使用了AHK等工具對Ctrl鍵進行了重新對應,可能導致部分應用程式無法劃詞。",
|
"description_note": {
|
||||||
|
"windows": "在某些應用中可能無法透過 Ctrl 鍵劃詞。若使用了AHK等工具對Ctrl鍵進行了重新對應,可能導致部分應用程式無法劃詞。",
|
||||||
|
"mac": "若使用了快捷鍵或鍵盤映射工具對 ⌘ 鍵進行了重新對應,可能導致部分應用程式無法劃詞。"
|
||||||
|
},
|
||||||
"selected": "劃詞",
|
"selected": "劃詞",
|
||||||
"selected_note": "劃詞後,立即顯示工具列",
|
"selected_note": "劃詞後,立即顯示工具列",
|
||||||
"ctrlkey": "Ctrl 鍵",
|
"ctrlkey": "Ctrl 鍵",
|
||||||
@@ -2153,9 +2207,96 @@
|
|||||||
},
|
},
|
||||||
"filter_modal": {
|
"filter_modal": {
|
||||||
"title": "應用篩選名單",
|
"title": "應用篩選名單",
|
||||||
"user_tips": "請輸入應用的執行檔名稱,每行一個,不區分大小寫,可以模糊匹配。例如:chrome.exe、weixin.exe、Cherry Studio.exe等"
|
"user_tips": {
|
||||||
|
"windows": "請輸入應用的執行檔名稱,每行一個,不區分大小寫,可以模糊匹配。例如:chrome.exe、weixin.exe、Cherry Studio.exe等",
|
||||||
|
"mac": "請輸入應用的 Bundle ID,每行一個,不區分大小寫,可以模糊匹配。例如:com.google.Chrome、com.apple.mail等"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"memory": {
|
||||||
|
"add_memory": "新增記憶",
|
||||||
|
"edit_memory": "編輯記憶",
|
||||||
|
"memory_content": "記憶內容",
|
||||||
|
"please_enter_memory": "請輸入記憶內容",
|
||||||
|
"memory_placeholder": "輸入記憶內容...",
|
||||||
|
"user_id": "使用者ID",
|
||||||
|
"user_id_placeholder": "輸入使用者ID(可選)",
|
||||||
|
"load_failed": "載入記憶失敗",
|
||||||
|
"add_success": "記憶新增成功",
|
||||||
|
"add_failed": "新增記憶失敗",
|
||||||
|
"update_success": "記憶更新成功",
|
||||||
|
"update_failed": "更新記憶失敗",
|
||||||
|
"delete_success": "記憶刪除成功",
|
||||||
|
"delete_failed": "刪除記憶失敗",
|
||||||
|
"delete_confirm_title": "刪除記憶",
|
||||||
|
"delete_confirm_content": "確定要刪除 {{count}} 條記憶嗎?",
|
||||||
|
"delete_confirm": "確定要刪除這條記憶嗎?",
|
||||||
|
"time": "時間",
|
||||||
|
"user": "使用者",
|
||||||
|
"content": "內容",
|
||||||
|
"score": "分數",
|
||||||
|
"title": "記憶",
|
||||||
|
"memories_description": "顯示 {{count}} / {{total}} 條記憶",
|
||||||
|
"search_placeholder": "搜尋記憶...",
|
||||||
|
"start_date": "開始日期",
|
||||||
|
"end_date": "結束日期",
|
||||||
|
"all_users": "所有使用者",
|
||||||
|
"users": "使用者",
|
||||||
|
"delete_selected": "刪除選取",
|
||||||
|
"reset_filters": "重設篩選",
|
||||||
|
"pagination_total": "第 {{start}}-{{end}} 項,共 {{total}} 項",
|
||||||
|
"current_user": "目前使用者",
|
||||||
|
"select_user": "選擇使用者",
|
||||||
|
"default_user": "預設使用者",
|
||||||
|
"switch_user": "切換使用者",
|
||||||
|
"user_switched": "使用者內容已切換至 {{user}}",
|
||||||
|
"switch_user_confirm": "將使用者內容切換至 {{user}}?",
|
||||||
|
"add_user": "新增使用者",
|
||||||
|
"add_new_user": "新增新使用者",
|
||||||
|
"new_user_id": "新使用者ID",
|
||||||
|
"new_user_id_placeholder": "輸入唯一的使用者ID",
|
||||||
|
"user_id_required": "使用者ID為必填欄位",
|
||||||
|
"user_id_reserved": "'default-user' 為保留字,請使用其他ID",
|
||||||
|
"user_id_exists": "此使用者ID已存在",
|
||||||
|
"user_id_too_long": "使用者ID不能超過50個字元",
|
||||||
|
"user_id_invalid_chars": "使用者ID只能包含字母、數字、連字符和底線",
|
||||||
|
"user_id_rules": "使用者ID必须唯一,只能包含字母、數字、連字符(-)和底線(_)",
|
||||||
|
"user_created": "使用者 {{user}} 建立並切換成功",
|
||||||
|
"add_user_failed": "新增使用者失敗",
|
||||||
|
"memory": "個記憶",
|
||||||
|
"reset_user_memories": "重置使用者記憶",
|
||||||
|
"delete_user": "刪除使用者",
|
||||||
|
"loading_memories": "正在載入記憶...",
|
||||||
|
"no_memories": "暫無記憶",
|
||||||
|
"no_matching_memories": "未找到符合的記憶",
|
||||||
|
"no_memories_description": "開始新增您的第一個記憶吧",
|
||||||
|
"try_different_filters": "嘗試調整搜尋條件",
|
||||||
|
"add_first_memory": "新增您的第一個記憶",
|
||||||
|
"user_switch_failed": "切換使用者失敗",
|
||||||
|
"cannot_delete_default_user": "不能刪除預設使用者",
|
||||||
|
"delete_user_confirm_title": "刪除使用者",
|
||||||
|
"delete_user_confirm_content": "確定要刪除使用者 {{user}} 及其所有記憶嗎?",
|
||||||
|
"user_deleted": "使用者 {{user}} 刪除成功",
|
||||||
|
"delete_user_failed": "刪除使用者失敗",
|
||||||
|
"reset_user_memories_confirm_title": "重置使用者記憶",
|
||||||
|
"reset_user_memories_confirm_content": "確定要重置 {{user}} 的所有記憶嗎?",
|
||||||
|
"user_memories_reset": "{{user}} 的所有記憶已重置",
|
||||||
|
"reset_user_memories_failed": "重置使用者記憶失敗",
|
||||||
|
"delete_confirm_single": "確定要刪除這個記憶嗎?",
|
||||||
|
"total_memories": "個記憶",
|
||||||
|
"default": "預設",
|
||||||
|
"custom": "自定義",
|
||||||
|
"description": "記憶功能讓您儲存和管理與助手互動的資訊。您可以新增、編輯和刪除記憶,也可以對它們進行篩選和搜尋。",
|
||||||
|
"global_memory_enabled": "全域記憶已啟用",
|
||||||
|
"global_memory": "全域記憶",
|
||||||
|
"enable_global_memory_first": "請先啟用全域記憶",
|
||||||
|
"configure_memory_first": "請先配置記憶設定",
|
||||||
|
"global_memory_disabled_title": "全域記憶已停用",
|
||||||
|
"global_memory_disabled_desc": "要使用記憶功能,請先在助手設定中啟用全域記憶。",
|
||||||
|
"not_configured_title": "記憶未配置",
|
||||||
|
"not_configured_desc": "請在記憶設定中配置嵌入和LLM模型以啟用記憶功能。",
|
||||||
|
"go_to_memory_page": "前往記憶頁面"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import FileManager from '@renderer/services/FileManager'
|
import FileManager from '@renderer/services/FileManager'
|
||||||
import { FileType, FileTypes } from '@renderer/types'
|
import { FileMetadata, FileTypes } from '@renderer/types'
|
||||||
import { formatFileSize } from '@renderer/utils'
|
import { formatFileSize } from '@renderer/utils'
|
||||||
import { Col, Image, Row, Spin, Table } from 'antd'
|
import { Col, Image, Row, Spin, Table } from 'antd'
|
||||||
import React, { memo } from 'react'
|
import React, { memo } from 'react'
|
||||||
@@ -7,7 +7,7 @@ import styled from 'styled-components'
|
|||||||
|
|
||||||
interface ContentViewProps {
|
interface ContentViewProps {
|
||||||
id: FileTypes | 'all' | string
|
id: FileTypes | 'all' | string
|
||||||
files?: FileType[]
|
files?: FileMetadata[]
|
||||||
dataSource?: any[]
|
dataSource?: any[]
|
||||||
columns: any[]
|
columns: any[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { DeleteOutlined, ExclamationCircleOutlined } from '@ant-design/icons'
|
import { DeleteOutlined, ExclamationCircleOutlined } from '@ant-design/icons'
|
||||||
import { handleDelete } from '@renderer/services/FileAction'
|
import { handleDelete } from '@renderer/services/FileAction'
|
||||||
import FileManager from '@renderer/services/FileManager'
|
import FileManager from '@renderer/services/FileManager'
|
||||||
import { FileType, FileTypes } from '@renderer/types'
|
import { FileMetadata, FileTypes } from '@renderer/types'
|
||||||
import { formatFileSize } from '@renderer/utils'
|
import { formatFileSize } from '@renderer/utils'
|
||||||
import { Col, Image, Row, Spin } from 'antd'
|
import { Col, Image, Row, Spin } from 'antd'
|
||||||
import { t } from 'i18next'
|
import { t } from 'i18next'
|
||||||
@@ -16,14 +16,14 @@ interface FileItemProps {
|
|||||||
list: {
|
list: {
|
||||||
key: FileTypes | 'all' | string
|
key: FileTypes | 'all' | string
|
||||||
file: React.ReactNode
|
file: React.ReactNode
|
||||||
files?: FileType[]
|
files?: FileMetadata[]
|
||||||
count?: number
|
count?: number
|
||||||
size: string
|
size: string
|
||||||
ext: string
|
ext: string
|
||||||
created_at: string
|
created_at: string
|
||||||
actions: React.ReactNode
|
actions: React.ReactNode
|
||||||
}[]
|
}[]
|
||||||
files?: FileType[]
|
files?: FileMetadata[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const FileList: React.FC<FileItemProps> = ({ id, list, files }) => {
|
const FileList: React.FC<FileItemProps> = ({ id, list, files }) => {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import ListItem from '@renderer/components/ListItem'
|
|||||||
import db from '@renderer/databases'
|
import db from '@renderer/databases'
|
||||||
import { handleDelete, handleRename, sortFiles, tempFilesSort } from '@renderer/services/FileAction'
|
import { handleDelete, handleRename, sortFiles, tempFilesSort } from '@renderer/services/FileAction'
|
||||||
import FileManager from '@renderer/services/FileManager'
|
import FileManager from '@renderer/services/FileManager'
|
||||||
import { FileType, FileTypes } from '@renderer/types'
|
import { FileMetadata, FileTypes } from '@renderer/types'
|
||||||
import { formatFileSize } from '@renderer/utils'
|
import { formatFileSize } from '@renderer/utils'
|
||||||
import { Button, Empty, Flex, Popconfirm } from 'antd'
|
import { Button, Empty, Flex, Popconfirm } from 'antd'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
@@ -31,7 +31,7 @@ const FilesPage: FC = () => {
|
|||||||
const [sortField, setSortField] = useState<SortField>('created_at')
|
const [sortField, setSortField] = useState<SortField>('created_at')
|
||||||
const [sortOrder, setSortOrder] = useState<SortOrder>('desc')
|
const [sortOrder, setSortOrder] = useState<SortOrder>('desc')
|
||||||
|
|
||||||
const files = useLiveQuery<FileType[]>(() => {
|
const files = useLiveQuery<FileMetadata[]>(() => {
|
||||||
if (fileType === 'all') {
|
if (fileType === 'all') {
|
||||||
return db.files.orderBy('count').toArray().then(tempFilesSort)
|
return db.files.orderBy('count').toArray().then(tempFilesSort)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { isGenerateImageModel, isVisionModel } from '@renderer/config/models'
|
import { isGenerateImageModel, isVisionModel } from '@renderer/config/models'
|
||||||
import { FileType, Model } from '@renderer/types'
|
import { FileMetadata, Model } from '@renderer/types'
|
||||||
import { documentExts, imageExts, textExts } from '@shared/config/constant'
|
import { documentExts, imageExts, textExts } from '@shared/config/constant'
|
||||||
import { Tooltip } from 'antd'
|
import { Tooltip } from 'antd'
|
||||||
import { Paperclip } from 'lucide-react'
|
import { Paperclip } from 'lucide-react'
|
||||||
@@ -13,8 +13,8 @@ export interface AttachmentButtonRef {
|
|||||||
interface Props {
|
interface Props {
|
||||||
ref?: React.RefObject<AttachmentButtonRef | null>
|
ref?: React.RefObject<AttachmentButtonRef | null>
|
||||||
model: Model
|
model: Model
|
||||||
files: FileType[]
|
files: FileMetadata[]
|
||||||
setFiles: (files: FileType[]) => void
|
setFiles: (files: FileMetadata[]) => void
|
||||||
ToolbarButton: any
|
ToolbarButton: any
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import CustomTag from '@renderer/components/CustomTag'
|
import CustomTag from '@renderer/components/CustomTag'
|
||||||
import FileManager from '@renderer/services/FileManager'
|
import FileManager from '@renderer/services/FileManager'
|
||||||
import { FileType } from '@renderer/types'
|
import { FileMetadata } from '@renderer/types'
|
||||||
import { formatFileSize } from '@renderer/utils'
|
import { formatFileSize } from '@renderer/utils'
|
||||||
import { Flex, Image, Tooltip } from 'antd'
|
import { Flex, Image, Tooltip } from 'antd'
|
||||||
import { isEmpty } from 'lodash'
|
import { isEmpty } from 'lodash'
|
||||||
@@ -22,8 +22,8 @@ import { FC, useState } from 'react'
|
|||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
files: FileType[]
|
files: FileMetadata[]
|
||||||
setFiles: (files: FileType[]) => void
|
setFiles: (files: FileMetadata[]) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_FILENAME_DISPLAY_LENGTH = 20
|
const MAX_FILENAME_DISPLAY_LENGTH = 20
|
||||||
@@ -80,7 +80,7 @@ export const getFileIcon = (type?: string) => {
|
|||||||
return <FileUnknownFilled />
|
return <FileUnknownFilled />
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FileNameRender: FC<{ file: FileType }> = ({ file }) => {
|
export const FileNameRender: FC<{ file: FileMetadata }> = ({ file }) => {
|
||||||
const [visible, setVisible] = useState<boolean>(false)
|
const [visible, setVisible] = useState<boolean>(false)
|
||||||
const isImage = (ext: string) => {
|
const isImage = (ext: string) => {
|
||||||
return ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'].includes(ext)
|
return ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'].includes(ext)
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import WebSearchService from '@renderer/services/WebSearchService'
|
|||||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||||
import { setSearching } from '@renderer/store/runtime'
|
import { setSearching } from '@renderer/store/runtime'
|
||||||
import { sendMessage as _sendMessage } from '@renderer/store/thunk/messageThunk'
|
import { sendMessage as _sendMessage } from '@renderer/store/thunk/messageThunk'
|
||||||
import { Assistant, FileType, KnowledgeBase, KnowledgeItem, Model, Topic } from '@renderer/types'
|
import { Assistant, FileMetadata, KnowledgeBase, KnowledgeItem, Model, Topic } from '@renderer/types'
|
||||||
import type { MessageInputBaseParams } from '@renderer/types/newMessage'
|
import type { MessageInputBaseParams } from '@renderer/types/newMessage'
|
||||||
import { classNames, delay, formatFileSize, getFileExtension } from '@renderer/utils'
|
import { classNames, delay, formatFileSize, getFileExtension } from '@renderer/utils'
|
||||||
import { formatQuotedText } from '@renderer/utils/formats'
|
import { formatQuotedText } from '@renderer/utils/formats'
|
||||||
@@ -62,7 +62,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let _text = ''
|
let _text = ''
|
||||||
let _files: FileType[] = []
|
let _files: FileMetadata[] = []
|
||||||
|
|
||||||
const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) => {
|
const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) => {
|
||||||
const [text, setText] = useState(_text)
|
const [text, setText] = useState(_text)
|
||||||
@@ -84,7 +84,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
|||||||
const [estimateTokenCount, setEstimateTokenCount] = useState(0)
|
const [estimateTokenCount, setEstimateTokenCount] = useState(0)
|
||||||
const [contextCount, setContextCount] = useState({ current: 0, max: 0 })
|
const [contextCount, setContextCount] = useState({ current: 0, max: 0 })
|
||||||
const textareaRef = useRef<TextAreaRef>(null)
|
const textareaRef = useRef<TextAreaRef>(null)
|
||||||
const [files, setFiles] = useState<FileType[]>(_files)
|
const [files, setFiles] = useState<FileMetadata[]>(_files)
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const containerRef = useRef(null)
|
const containerRef = useRef(null)
|
||||||
const { searching } = useRuntime()
|
const { searching } = useRuntime()
|
||||||
@@ -229,7 +229,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
|||||||
list: base.items
|
list: base.items
|
||||||
.filter((file): file is KnowledgeItem => ['file'].includes(file.type))
|
.filter((file): file is KnowledgeItem => ['file'].includes(file.type))
|
||||||
.map((file) => {
|
.map((file) => {
|
||||||
const fileContent = file.content as FileType
|
const fileContent = file.content as FileMetadata
|
||||||
return {
|
return {
|
||||||
label: fileContent.origin_name || fileContent.name,
|
label: fileContent.origin_name || fileContent.name,
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { QuickPanelListItem } from '@renderer/components/QuickPanel'
|
|||||||
import { isGenerateImageModel, isVisionModel } from '@renderer/config/models'
|
import { isGenerateImageModel, isVisionModel } from '@renderer/config/models'
|
||||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||||
import { setIsCollapsed, setToolOrder } from '@renderer/store/inputTools'
|
import { setIsCollapsed, setToolOrder } from '@renderer/store/inputTools'
|
||||||
import { Assistant, FileType, KnowledgeBase, Model } from '@renderer/types'
|
import { Assistant, FileMetadata, KnowledgeBase, Model } from '@renderer/types'
|
||||||
import { classNames } from '@renderer/utils'
|
import { classNames } from '@renderer/utils'
|
||||||
import { Divider, Dropdown, Tooltip } from 'antd'
|
import { Divider, Dropdown, Tooltip } from 'antd'
|
||||||
import { ItemType } from 'antd/es/menu/interface'
|
import { ItemType } from 'antd/es/menu/interface'
|
||||||
@@ -41,7 +41,7 @@ import WebSearchButton, { WebSearchButtonRef } from './WebSearchButton'
|
|||||||
export interface InputbarToolsRef {
|
export interface InputbarToolsRef {
|
||||||
getQuickPanelMenu: (params: {
|
getQuickPanelMenu: (params: {
|
||||||
t: (key: string, options?: any) => string
|
t: (key: string, options?: any) => string
|
||||||
files: FileType[]
|
files: FileMetadata[]
|
||||||
model: Model
|
model: Model
|
||||||
text: string
|
text: string
|
||||||
openSelectFileMenu: () => void
|
openSelectFileMenu: () => void
|
||||||
@@ -55,8 +55,8 @@ export interface InputbarToolsProps {
|
|||||||
assistant: Assistant
|
assistant: Assistant
|
||||||
model: Model
|
model: Model
|
||||||
|
|
||||||
files: FileType[]
|
files: FileMetadata[]
|
||||||
setFiles: (files: FileType[]) => void
|
setFiles: (files: FileMetadata[]) => void
|
||||||
showThinkingButton: boolean
|
showThinkingButton: boolean
|
||||||
showKnowledgeIcon: boolean
|
showKnowledgeIcon: boolean
|
||||||
selectedKnowledgeBases: KnowledgeBase[]
|
selectedKnowledgeBases: KnowledgeBase[]
|
||||||
@@ -152,7 +152,7 @@ const InputbarTools = ({
|
|||||||
|
|
||||||
const getQuickPanelMenuImpl = (params: {
|
const getQuickPanelMenuImpl = (params: {
|
||||||
t: (key: string, options?: any) => string
|
t: (key: string, options?: any) => string
|
||||||
files: FileType[]
|
files: FileMetadata[]
|
||||||
model: Model
|
model: Model
|
||||||
text: string
|
text: string
|
||||||
openSelectFileMenu: () => void
|
openSelectFileMenu: () => void
|
||||||
|
|||||||
@@ -55,8 +55,8 @@ const WebSearchButton: FC<Props> = ({ ref, assistant, ToolbarButton }) => {
|
|||||||
label: p.name,
|
label: p.name,
|
||||||
description: WebSearchService.isWebSearchEnabled(p.id)
|
description: WebSearchService.isWebSearchEnabled(p.id)
|
||||||
? hasObjectKey(p, 'apiKey')
|
? hasObjectKey(p, 'apiKey')
|
||||||
? t('settings.websearch.apikey')
|
? t('settings.tool.websearch.apikey')
|
||||||
: t('settings.websearch.free')
|
: t('settings.tool.websearch.free')
|
||||||
: t('chat.input.web_search.enable_content'),
|
: t('chat.input.web_search.enable_content'),
|
||||||
icon: <Globe />,
|
icon: <Globe />,
|
||||||
isSelected: p.id === assistant?.webSearchProviderId,
|
isSelected: p.id === assistant?.webSearchProviderId,
|
||||||
@@ -81,7 +81,7 @@ const WebSearchButton: FC<Props> = ({ ref, assistant, ToolbarButton }) => {
|
|||||||
items.push({
|
items.push({
|
||||||
label: t('chat.input.web_search.settings'),
|
label: t('chat.input.web_search.settings'),
|
||||||
icon: <Settings />,
|
icon: <Settings />,
|
||||||
action: () => navigate('/settings/web-search')
|
action: () => navigate('/settings/tool/websearch')
|
||||||
})
|
})
|
||||||
|
|
||||||
items.unshift({
|
items.unshift({
|
||||||
|
|||||||
@@ -23,9 +23,10 @@ function CitationBlock({ block }: { block: CitationMessageBlock }) {
|
|||||||
return (
|
return (
|
||||||
(formattedCitations && formattedCitations.length > 0) ||
|
(formattedCitations && formattedCitations.length > 0) ||
|
||||||
hasGeminiBlock ||
|
hasGeminiBlock ||
|
||||||
(block.knowledge && block.knowledge.length > 0)
|
(block.knowledge && block.knowledge.length > 0) ||
|
||||||
|
(block.memories && block.memories.length > 0)
|
||||||
)
|
)
|
||||||
}, [formattedCitations, block.knowledge, hasGeminiBlock])
|
}, [formattedCitations, block.knowledge, block.memories, hasGeminiBlock])
|
||||||
|
|
||||||
const getWebSearchStatusText = (requestId: string) => {
|
const getWebSearchStatusText = (requestId: string) => {
|
||||||
const status = websearch.activeSearches[requestId] ?? { phase: 'default' }
|
const status = websearch.activeSearches[requestId] ?? { phase: 'default' }
|
||||||
|
|||||||
@@ -182,7 +182,8 @@ const KnowledgeCitation: React.FC<{ citation: Citation }> = ({ citation }) => {
|
|||||||
<WebSearchCardHeader>
|
<WebSearchCardHeader>
|
||||||
{citation.showFavicon && <FileSearch width={16} />}
|
{citation.showFavicon && <FileSearch width={16} />}
|
||||||
<CitationLink className="text-nowrap" href={citation.url} onClick={(e) => handleLinkClick(citation.url, e)}>
|
<CitationLink className="text-nowrap" href={citation.url} onClick={(e) => handleLinkClick(citation.url, e)}>
|
||||||
{citation.title}
|
{/* example title: User/path/example.pdf */}
|
||||||
|
{citation.title?.split('/').pop()}
|
||||||
</CitationLink>
|
</CitationLink>
|
||||||
|
|
||||||
<CitationIndex>{citation.number}</CitationIndex>
|
<CitationIndex>{citation.number}</CitationIndex>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const StyledUpload = styled(Upload)`
|
|||||||
`
|
`
|
||||||
|
|
||||||
const MessageAttachments: FC<Props> = ({ block }) => {
|
const MessageAttachments: FC<Props> = ({ block }) => {
|
||||||
// const handleCopyImage = async (image: FileType) => {
|
// const handleCopyImage = async (image: FileMetadata) => {
|
||||||
// const data = await FileManager.readFile(image)
|
// const data = await FileManager.readFile(image)
|
||||||
// const blob = new Blob([data], { type: 'image/png' })
|
// const blob = new Blob([data], { type: 'image/png' })
|
||||||
// const item = new ClipboardItem({ [blob.type]: blob })
|
// const item = new ClipboardItem({ [blob.type]: blob })
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useAssistant } from '@renderer/hooks/useAssistant'
|
|||||||
import { useSettings } from '@renderer/hooks/useSettings'
|
import { useSettings } from '@renderer/hooks/useSettings'
|
||||||
import FileManager from '@renderer/services/FileManager'
|
import FileManager from '@renderer/services/FileManager'
|
||||||
import PasteService from '@renderer/services/PasteService'
|
import PasteService from '@renderer/services/PasteService'
|
||||||
import { FileType, FileTypes } from '@renderer/types'
|
import { FileMetadata, FileTypes } from '@renderer/types'
|
||||||
import { Message, MessageBlock, MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage'
|
import { Message, MessageBlock, MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage'
|
||||||
import { classNames, getFileExtension } from '@renderer/utils'
|
import { classNames, getFileExtension } from '@renderer/utils'
|
||||||
import { getFilesFromDropEvent, isSendMessageKeyPressed } from '@renderer/utils/input'
|
import { getFilesFromDropEvent, isSendMessageKeyPressed } from '@renderer/utils/input'
|
||||||
@@ -33,7 +33,7 @@ interface Props {
|
|||||||
const MessageBlockEditor: FC<Props> = ({ message, onSave, onResend, onCancel }) => {
|
const MessageBlockEditor: FC<Props> = ({ message, onSave, onResend, onCancel }) => {
|
||||||
const allBlocks = findAllBlocks(message)
|
const allBlocks = findAllBlocks(message)
|
||||||
const [editedBlocks, setEditedBlocks] = useState<MessageBlock[]>(allBlocks)
|
const [editedBlocks, setEditedBlocks] = useState<MessageBlock[]>(allBlocks)
|
||||||
const [files, setFiles] = useState<FileType[]>([])
|
const [files, setFiles] = useState<FileMetadata[]>([])
|
||||||
const [isProcessing, setIsProcessing] = useState(false)
|
const [isProcessing, setIsProcessing] = useState(false)
|
||||||
const [isFileDragging, setIsFileDragging] = useState(false)
|
const [isFileDragging, setIsFileDragging] = useState(false)
|
||||||
const { assistant } = useAssistant(message.assistantId)
|
const { assistant } = useAssistant(message.assistantId)
|
||||||
|
|||||||
@@ -7,16 +7,15 @@ import TextEditPopup from '@renderer/components/Popups/TextEditPopup'
|
|||||||
import Scrollbar from '@renderer/components/Scrollbar'
|
import Scrollbar from '@renderer/components/Scrollbar'
|
||||||
import Logger from '@renderer/config/logger'
|
import Logger from '@renderer/config/logger'
|
||||||
import { useKnowledge } from '@renderer/hooks/useKnowledge'
|
import { useKnowledge } from '@renderer/hooks/useKnowledge'
|
||||||
import FileManager from '@renderer/services/FileManager'
|
|
||||||
import { getProviderName } from '@renderer/services/ProviderService'
|
import { getProviderName } from '@renderer/services/ProviderService'
|
||||||
import { FileType, FileTypes, KnowledgeBase, KnowledgeItem } from '@renderer/types'
|
import { FileMetadata, FileTypes, KnowledgeBase, KnowledgeItem } from '@renderer/types'
|
||||||
import { formatFileSize } from '@renderer/utils'
|
import { formatFileSize, uuid } from '@renderer/utils'
|
||||||
import { bookExts, documentExts, textExts, thirdPartyApplicationExts } from '@shared/config/constant'
|
import { bookExts, documentExts, textExts, thirdPartyApplicationExts } from '@shared/config/constant'
|
||||||
import { Alert, Button, Dropdown, Empty, message, Tag, Tooltip, Upload } from 'antd'
|
import { Alert, Button, Dropdown, Empty, message, Tag, Tooltip, Upload } from 'antd'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { ChevronsDown, ChevronsUp, Plus, Search, Settings2 } from 'lucide-react'
|
import { ChevronsDown, ChevronsUp, Plus, Search, Settings2 } from 'lucide-react'
|
||||||
import VirtualList from 'rc-virtual-list'
|
import VirtualList from 'rc-virtual-list'
|
||||||
import { FC, useState } from 'react'
|
import { FC, useEffect, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
@@ -24,7 +23,8 @@ import CustomCollapse from '../../components/CustomCollapse'
|
|||||||
import FileItem from '../files/FileItem'
|
import FileItem from '../files/FileItem'
|
||||||
import { NavbarIcon } from '../home/Navbar'
|
import { NavbarIcon } from '../home/Navbar'
|
||||||
import KnowledgeSearchPopup from './components/KnowledgeSearchPopup'
|
import KnowledgeSearchPopup from './components/KnowledgeSearchPopup'
|
||||||
import KnowledgeSettingsPopup from './components/KnowledgeSettingsPopup'
|
import KnowledgeSettings from './components/KnowledgeSettings'
|
||||||
|
import QuotaTag from './components/QuotaTag'
|
||||||
import StatusIcon from './components/StatusIcon'
|
import StatusIcon from './components/StatusIcon'
|
||||||
|
|
||||||
const { Dragger } = Upload
|
const { Dragger } = Upload
|
||||||
@@ -43,6 +43,9 @@ const getDisplayTime = (item: KnowledgeItem) => {
|
|||||||
const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
|
const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [expandAll, setExpandAll] = useState(false)
|
const [expandAll, setExpandAll] = useState(false)
|
||||||
|
const [progressMap, setProgressMap] = useState<Map<string, number>>(new Map())
|
||||||
|
const [preprocessMap, setPreprocessMap] = useState<Map<string, boolean>>(new Map())
|
||||||
|
const [quota, setQuota] = useState<number | undefined>(undefined)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
base,
|
base,
|
||||||
@@ -58,7 +61,6 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
|
|||||||
addSitemap,
|
addSitemap,
|
||||||
removeItem,
|
removeItem,
|
||||||
getProcessingStatus,
|
getProcessingStatus,
|
||||||
getDirectoryProcessingPercent,
|
|
||||||
addNote,
|
addNote,
|
||||||
addDirectory,
|
addDirectory,
|
||||||
updateItem
|
updateItem
|
||||||
@@ -66,13 +68,34 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
|
|||||||
|
|
||||||
const providerName = getProviderName(base?.model.provider || '')
|
const providerName = getProviderName(base?.model.provider || '')
|
||||||
const disabled = !base?.version || !providerName
|
const disabled = !base?.version || !providerName
|
||||||
|
useEffect(() => {
|
||||||
|
const handlers = [
|
||||||
|
window.electron.ipcRenderer.on('file-preprocess-finished', (_, { itemId, quota }) => {
|
||||||
|
setPreprocessMap((prev) => new Map(prev).set(itemId, true))
|
||||||
|
if (quota) {
|
||||||
|
setQuota(quota)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
window.electron.ipcRenderer.on('file-preprocess-progress', (_, { itemId, progress }) => {
|
||||||
|
setProgressMap((prev) => new Map(prev).set(itemId, progress))
|
||||||
|
}),
|
||||||
|
|
||||||
|
window.electron.ipcRenderer.on('directory-processing-percent', (_, { itemId, percent }) => {
|
||||||
|
console.log('[Progress] Directory:', itemId, percent)
|
||||||
|
setProgressMap((prev) => new Map(prev).set(itemId, percent))
|
||||||
|
})
|
||||||
|
]
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
handlers.forEach((cleanup) => cleanup())
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
if (!base) {
|
if (!base) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const getProgressingPercentForItem = (itemId: string) => getDirectoryProcessingPercent(itemId)
|
|
||||||
|
|
||||||
const handleAddFile = () => {
|
const handleAddFile = () => {
|
||||||
if (disabled) {
|
if (disabled) {
|
||||||
return
|
return
|
||||||
@@ -92,23 +115,36 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
|
|||||||
if (disabled) {
|
if (disabled) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (files) {
|
if (files) {
|
||||||
const _files: FileType[] = files
|
const _files: FileMetadata[] = files
|
||||||
.map((file) => ({
|
.map((file) => {
|
||||||
id: file.name,
|
// 这个路径 filePath 很可能是在文件选择时的原始路径。
|
||||||
name: file.name,
|
const filePath = window.api.file.getPathForFile(file)
|
||||||
path: window.api.file.getPathForFile(file),
|
let nameFromPath = filePath
|
||||||
size: file.size,
|
const lastSlash = filePath.lastIndexOf('/')
|
||||||
ext: `.${file.name.split('.').pop()}`.toLowerCase(),
|
const lastBackslash = filePath.lastIndexOf('\\')
|
||||||
count: 1,
|
if (lastSlash !== -1 || lastBackslash !== -1) {
|
||||||
origin_name: file.name,
|
nameFromPath = filePath.substring(Math.max(lastSlash, lastBackslash) + 1)
|
||||||
type: file.type as FileTypes,
|
}
|
||||||
created_at: new Date().toISOString()
|
|
||||||
}))
|
// 从派生的文件名中获取扩展名
|
||||||
|
const extFromPath = nameFromPath.includes('.') ? `.${nameFromPath.split('.').pop()}` : ''
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: uuid(),
|
||||||
|
name: nameFromPath, // 使用从路径派生的文件名
|
||||||
|
path: filePath,
|
||||||
|
size: file.size,
|
||||||
|
ext: extFromPath.toLowerCase(),
|
||||||
|
count: 1,
|
||||||
|
origin_name: file.name, // 保存 File 对象中原始的文件名
|
||||||
|
type: file.type as FileTypes,
|
||||||
|
created_at: new Date().toISOString()
|
||||||
|
}
|
||||||
|
})
|
||||||
.filter(({ ext }) => fileTypes.includes(ext))
|
.filter(({ ext }) => fileTypes.includes(ext))
|
||||||
const uploadedFiles = await FileManager.uploadFiles(_files)
|
// const uploadedFiles = await FileManager.uploadFiles(_files)
|
||||||
addFiles(uploadedFiles)
|
addFiles(_files)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,6 +265,16 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const showPreprocessIcon = (item: KnowledgeItem) => {
|
||||||
|
if (base.preprocessOrOcrProvider && item.isPreprocessed !== false) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (!base.preprocessOrOcrProvider && item.isPreprocessed === true) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MainContainer>
|
<MainContainer>
|
||||||
<HeaderContainer>
|
<HeaderContainer>
|
||||||
@@ -236,7 +282,7 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
|
|||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
icon={<Settings2 size={18} color="var(--color-icon)" />}
|
icon={<Settings2 size={18} color="var(--color-icon)" />}
|
||||||
onClick={() => KnowledgeSettingsPopup.show({ base })}
|
onClick={() => KnowledgeSettings.show({ base })}
|
||||||
size="small"
|
size="small"
|
||||||
/>
|
/>
|
||||||
<div className="model-row">
|
<div className="model-row">
|
||||||
@@ -255,6 +301,9 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
|
|||||||
{base.rerankModel.name}
|
{base.rerankModel.name}
|
||||||
</Tag>
|
</Tag>
|
||||||
)}
|
)}
|
||||||
|
{base.preprocessOrOcrProvider && base.preprocessOrOcrProvider.type === 'preprocess' && (
|
||||||
|
<QuotaTag base={base} providerId={base.preprocessOrOcrProvider?.provider.id} quota={quota} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</ModelInfo>
|
</ModelInfo>
|
||||||
<HStack gap={8} alignItems="center">
|
<HStack gap={8} alignItems="center">
|
||||||
@@ -326,14 +375,14 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
|
|||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
{(item) => {
|
{(item) => {
|
||||||
const file = item.content as FileType
|
const file = item.content as FileMetadata
|
||||||
return (
|
return (
|
||||||
<div style={{ height: '75px', paddingTop: '12px' }}>
|
<div style={{ height: '75px', paddingTop: '12px' }}>
|
||||||
<FileItem
|
<FileItem
|
||||||
key={item.id}
|
key={item.id}
|
||||||
fileInfo={{
|
fileInfo={{
|
||||||
name: (
|
name: (
|
||||||
<ClickableSpan onClick={() => window.api.file.openPath(FileManager.getFilePath(file))}>
|
<ClickableSpan onClick={() => window.api.file.openPath(file.path)}>
|
||||||
<Ellipsis>
|
<Ellipsis>
|
||||||
<Tooltip title={file.origin_name}>{file.origin_name}</Tooltip>
|
<Tooltip title={file.origin_name}>{file.origin_name}</Tooltip>
|
||||||
</Ellipsis>
|
</Ellipsis>
|
||||||
@@ -346,6 +395,18 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
|
|||||||
{item.uniqueId && (
|
{item.uniqueId && (
|
||||||
<Button type="text" icon={<RefreshIcon />} onClick={() => refreshItem(item)} />
|
<Button type="text" icon={<RefreshIcon />} onClick={() => refreshItem(item)} />
|
||||||
)}
|
)}
|
||||||
|
{showPreprocessIcon(item) && (
|
||||||
|
<StatusIconWrapper>
|
||||||
|
<StatusIcon
|
||||||
|
sourceId={item.id}
|
||||||
|
base={base}
|
||||||
|
getProcessingStatus={getProcessingStatus}
|
||||||
|
type="file"
|
||||||
|
isPreprocessed={preprocessMap.get(item.id) || item.isPreprocessed || false}
|
||||||
|
progress={progressMap.get(item.id)}
|
||||||
|
/>
|
||||||
|
</StatusIconWrapper>
|
||||||
|
)}
|
||||||
<StatusIconWrapper>
|
<StatusIconWrapper>
|
||||||
<StatusIcon
|
<StatusIcon
|
||||||
sourceId={item.id}
|
sourceId={item.id}
|
||||||
@@ -406,7 +467,6 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
|
|||||||
sourceId={item.id}
|
sourceId={item.id}
|
||||||
base={base}
|
base={base}
|
||||||
getProcessingStatus={getProcessingStatus}
|
getProcessingStatus={getProcessingStatus}
|
||||||
getProcessingPercent={getProgressingPercentForItem}
|
|
||||||
type="directory"
|
type="directory"
|
||||||
/>
|
/>
|
||||||
</StatusIconWrapper>
|
</StatusIconWrapper>
|
||||||
@@ -695,12 +755,11 @@ const ClickableSpan = styled.span`
|
|||||||
`
|
`
|
||||||
|
|
||||||
const StatusIconWrapper = styled.div`
|
const StatusIconWrapper = styled.div`
|
||||||
width: 36px;
|
width: 32px;
|
||||||
height: 36px;
|
height: 44px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding-top: 2px;
|
|
||||||
`
|
`
|
||||||
|
|
||||||
const RefreshIcon = styled(RedoOutlined)`
|
const RefreshIcon = styled(RedoOutlined)`
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
import AddKnowledgePopup from './components/AddKnowledgePopup'
|
import AddKnowledgePopup from './components/AddKnowledgePopup'
|
||||||
import KnowledgeSettingsPopup from './components/KnowledgeSettingsPopup'
|
import KnowledgeSettings from './components/KnowledgeSettings'
|
||||||
import KnowledgeContent from './KnowledgeContent'
|
import KnowledgeContent from './KnowledgeContent'
|
||||||
|
|
||||||
const KnowledgePage: FC = () => {
|
const KnowledgePage: FC = () => {
|
||||||
@@ -55,10 +55,10 @@ const KnowledgePage: FC = () => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t('knowledge.settings'),
|
label: t('knowledge.settings.title'),
|
||||||
key: 'settings',
|
key: 'settings',
|
||||||
icon: <SettingOutlined />,
|
icon: <SettingOutlined />,
|
||||||
onClick: () => KnowledgeSettingsPopup.show({ base })
|
onClick: () => KnowledgeSettings.show({ base })
|
||||||
},
|
},
|
||||||
{ type: 'divider' },
|
{ type: 'divider' },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,36 +1,30 @@
|
|||||||
|
import { InfoCircleOutlined, SettingOutlined, WarningOutlined } from '@ant-design/icons'
|
||||||
import AiProvider from '@renderer/aiCore'
|
import AiProvider from '@renderer/aiCore'
|
||||||
import { TopView } from '@renderer/components/TopView'
|
import { TopView } from '@renderer/components/TopView'
|
||||||
import { DEFAULT_KNOWLEDGE_DOCUMENT_COUNT } from '@renderer/config/constant'
|
import { DEFAULT_KNOWLEDGE_DOCUMENT_COUNT, isMac } from '@renderer/config/constant'
|
||||||
|
import { getEmbeddingMaxContext } from '@renderer/config/embedings'
|
||||||
import { isEmbeddingModel, isRerankModel } from '@renderer/config/models'
|
import { isEmbeddingModel, isRerankModel } from '@renderer/config/models'
|
||||||
import { NOT_SUPPORTED_REANK_PROVIDERS } from '@renderer/config/providers'
|
import { NOT_SUPPORTED_REANK_PROVIDERS } from '@renderer/config/providers'
|
||||||
// import { SUPPORTED_REANK_PROVIDERS } from '@renderer/config/providers'
|
// import { SUPPORTED_REANK_PROVIDERS } from '@renderer/config/providers'
|
||||||
import { useKnowledgeBases } from '@renderer/hooks/useKnowledge'
|
import { useKnowledgeBases } from '@renderer/hooks/useKnowledge'
|
||||||
|
import { useOcrProviders } from '@renderer/hooks/useOcr'
|
||||||
|
import { usePreprocessProviders } from '@renderer/hooks/usePreprocess'
|
||||||
import { useProviders } from '@renderer/hooks/useProvider'
|
import { useProviders } from '@renderer/hooks/useProvider'
|
||||||
import { SettingHelpText } from '@renderer/pages/settings'
|
|
||||||
import { getKnowledgeBaseParams } from '@renderer/services/KnowledgeService'
|
import { getKnowledgeBaseParams } from '@renderer/services/KnowledgeService'
|
||||||
import { getModelUniqId } from '@renderer/services/ModelService'
|
import { getModelUniqId } from '@renderer/services/ModelService'
|
||||||
import { KnowledgeBase, Model } from '@renderer/types'
|
import { KnowledgeBase, Model, OcrProvider, PreprocessProvider } from '@renderer/types'
|
||||||
import { getErrorMessage } from '@renderer/utils/error'
|
import { getErrorMessage } from '@renderer/utils/error'
|
||||||
import { Flex, Form, Input, InputNumber, Modal, Select, Slider, Switch } from 'antd'
|
import { Alert, Input, InputNumber, Modal, Select, Slider, Switch, Tabs, TabsProps, Tooltip } from 'antd'
|
||||||
import { find, sortBy } from 'lodash'
|
import { find, sortBy } from 'lodash'
|
||||||
import { ChevronDown } from 'lucide-react'
|
|
||||||
import { nanoid } from 'nanoid'
|
import { nanoid } from 'nanoid'
|
||||||
import { useMemo, useRef, useState } from 'react'
|
import { useMemo, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import styled from 'styled-components'
|
||||||
|
|
||||||
interface ShowParams {
|
interface ShowParams {
|
||||||
title: string
|
title: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FormData {
|
|
||||||
name: string
|
|
||||||
model: string
|
|
||||||
autoDims: boolean | undefined
|
|
||||||
dimensions: number | undefined
|
|
||||||
rerankModel: string | undefined
|
|
||||||
documentCount: number | undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props extends ShowParams {
|
interface Props extends ShowParams {
|
||||||
resolve: (data: any) => void
|
resolve: (data: any) => void
|
||||||
}
|
}
|
||||||
@@ -39,10 +33,15 @@ const PopupContainer: React.FC<Props> = ({ title, resolve }) => {
|
|||||||
const [open, setOpen] = useState(true)
|
const [open, setOpen] = useState(true)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [autoDims, setAutoDims] = useState(true)
|
const [autoDims, setAutoDims] = useState(true)
|
||||||
const [form] = Form.useForm<FormData>()
|
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { providers } = useProviders()
|
const { providers } = useProviders()
|
||||||
const { addKnowledgeBase } = useKnowledgeBases()
|
const { addKnowledgeBase } = useKnowledgeBases()
|
||||||
|
const [newBase, setNewBase] = useState<KnowledgeBase>({} as KnowledgeBase)
|
||||||
|
const [dimensions, setDimensions] = useState<number | undefined>(undefined)
|
||||||
|
|
||||||
|
const { preprocessProviders } = usePreprocessProviders()
|
||||||
|
const { ocrProviders } = useOcrProviders()
|
||||||
|
const [selectedProvider, setSelectedProvider] = useState<PreprocessProvider | OcrProvider | undefined>(undefined)
|
||||||
|
|
||||||
const embeddingModels = useMemo(() => {
|
const embeddingModels = useMemo(() => {
|
||||||
return providers
|
return providers
|
||||||
@@ -95,14 +94,30 @@ const PopupContainer: React.FC<Props> = ({ title, resolve }) => {
|
|||||||
.filter((group) => group.options.length > 0)
|
.filter((group) => group.options.length > 0)
|
||||||
}, [providers, t])
|
}, [providers, t])
|
||||||
|
|
||||||
|
const preprocessOrOcrSelectOptions = useMemo(() => {
|
||||||
|
const preprocessOptions = {
|
||||||
|
label: t('settings.tool.preprocess.provider'),
|
||||||
|
title: t('settings.tool.preprocess.provider'),
|
||||||
|
options: preprocessProviders
|
||||||
|
// todo: 免费期结束后删除
|
||||||
|
.filter((p) => p.apiKey !== '' || p.id === 'mineru')
|
||||||
|
.map((p) => ({ value: p.id, label: p.name }))
|
||||||
|
}
|
||||||
|
const ocrOptions = {
|
||||||
|
label: t('settings.tool.ocr.provider'),
|
||||||
|
title: t('settings.tool.ocr.provider'),
|
||||||
|
options: ocrProviders.filter((p) => p.apiKey !== '').map((p) => ({ value: p.id, label: p.name }))
|
||||||
|
}
|
||||||
|
|
||||||
|
return isMac ? [preprocessOptions, ocrOptions] : [preprocessOptions]
|
||||||
|
}, [ocrProviders, preprocessProviders])
|
||||||
|
|
||||||
const onOk = async () => {
|
const onOk = async () => {
|
||||||
try {
|
try {
|
||||||
const values = await form.validateFields()
|
// const values = await form.validateFields()
|
||||||
const selectedEmbeddingModel = find(embeddingModels, JSON.parse(values.model)) as Model
|
const selectedEmbeddingModel = find(embeddingModels, newBase.model) as Model
|
||||||
|
|
||||||
const selectedRerankModel = values.rerankModel
|
const selectedRerankModel = newBase.rerankModel ? (find(rerankModels, newBase.rerankModel) as Model) : undefined
|
||||||
? (find(rerankModels, JSON.parse(values.rerankModel)) as Model)
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
if (selectedEmbeddingModel) {
|
if (selectedEmbeddingModel) {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -111,40 +126,43 @@ const PopupContainer: React.FC<Props> = ({ title, resolve }) => {
|
|||||||
if (!provider) {
|
if (!provider) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
let finalDimensions: number // 用于存储最终确定的维度值
|
||||||
|
|
||||||
if (autoDims || values.dimensions === undefined) {
|
if (autoDims || dimensions === undefined) {
|
||||||
try {
|
try {
|
||||||
const aiProvider = new AiProvider(provider)
|
const aiProvider = new AiProvider(provider)
|
||||||
values.dimensions = await aiProvider.getEmbeddingDimensions(selectedEmbeddingModel)
|
finalDimensions = await aiProvider.getEmbeddingDimensions(selectedEmbeddingModel)
|
||||||
|
|
||||||
|
setDimensions(finalDimensions)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting embedding dimensions:', error)
|
console.error('Error getting embedding dimensions:', error)
|
||||||
window.message.error(t('message.error.get_embedding_dimensions') + '\n' + getErrorMessage(error))
|
window.message.error(t('message.error.get_embedding_dimensions') + '\n' + getErrorMessage(error))
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else if (typeof values.dimensions === 'string') {
|
} else {
|
||||||
// 按理来说不应该是string的,但是确实是string
|
finalDimensions = dimensions
|
||||||
values.dimensions = parseInt(values.dimensions)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const newBase: KnowledgeBase = {
|
const _newBase = {
|
||||||
|
...newBase,
|
||||||
id: nanoid(),
|
id: nanoid(),
|
||||||
name: values.name,
|
name: newBase.name,
|
||||||
model: selectedEmbeddingModel,
|
model: selectedEmbeddingModel,
|
||||||
rerankModel: selectedRerankModel,
|
rerankModel: selectedRerankModel,
|
||||||
dimensions: autoDims ? undefined : values.dimensions,
|
dimensions: finalDimensions,
|
||||||
documentCount: values.documentCount || DEFAULT_KNOWLEDGE_DOCUMENT_COUNT,
|
documentCount: newBase.documentCount || DEFAULT_KNOWLEDGE_DOCUMENT_COUNT,
|
||||||
items: [],
|
items: [],
|
||||||
created_at: Date.now(),
|
created_at: Date.now(),
|
||||||
updated_at: Date.now(),
|
updated_at: Date.now(),
|
||||||
version: 1
|
version: 1
|
||||||
}
|
}
|
||||||
|
|
||||||
await window.api.knowledgeBase.create(getKnowledgeBaseParams(newBase))
|
await window.api.knowledgeBase.create(getKnowledgeBaseParams(_newBase))
|
||||||
|
|
||||||
addKnowledgeBase(newBase)
|
addKnowledgeBase(_newBase as any)
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
resolve(newBase)
|
resolve(_newBase)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Validation failed:', error)
|
console.error('Validation failed:', error)
|
||||||
@@ -159,8 +177,247 @@ const PopupContainer: React.FC<Props> = ({ title, resolve }) => {
|
|||||||
resolve(null)
|
resolve(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const settingItems: TabsProps['items'] = [
|
||||||
|
{
|
||||||
|
key: '1',
|
||||||
|
label: t('settings.general'),
|
||||||
|
children: (
|
||||||
|
<SettingsPanel>
|
||||||
|
<SettingsItem>
|
||||||
|
<div className="settings-label">{t('common.name')}</div>
|
||||||
|
<Input
|
||||||
|
placeholder={t('common.name')}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.value) {
|
||||||
|
setNewBase({ ...newBase, name: e.target.value })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SettingsItem>
|
||||||
|
|
||||||
|
<SettingsItem>
|
||||||
|
<div className="settings-label">
|
||||||
|
{t('settings.tool.preprocess.title')} / {t('settings.tool.ocr.title')}
|
||||||
|
<Tooltip title={t('settings.tool.preprocessOrOcr.tooltip')} placement="right">
|
||||||
|
<InfoCircleOutlined style={{ marginLeft: 8 }} />
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
value={selectedProvider?.id}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
onChange={(value: string) => {
|
||||||
|
const type = preprocessProviders.find((p) => p.id === value) ? 'preprocess' : 'ocr'
|
||||||
|
const provider = (type === 'preprocess' ? preprocessProviders : ocrProviders).find(
|
||||||
|
(p) => p.id === value
|
||||||
|
)
|
||||||
|
if (!provider) {
|
||||||
|
setSelectedProvider(undefined)
|
||||||
|
setNewBase({
|
||||||
|
...newBase,
|
||||||
|
preprocessOrOcrProvider: undefined
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSelectedProvider(provider)
|
||||||
|
setNewBase({
|
||||||
|
...newBase,
|
||||||
|
preprocessOrOcrProvider: {
|
||||||
|
type: type,
|
||||||
|
provider: provider
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
placeholder={t('settings.tool.preprocess.provider_placeholder')}
|
||||||
|
options={preprocessOrOcrSelectOptions}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
</SettingsItem>
|
||||||
|
|
||||||
|
<SettingsItem>
|
||||||
|
<div className="settings-label">
|
||||||
|
{t('models.embedding_model')}
|
||||||
|
<Tooltip title={t('models.embedding_model_tooltip')} placement="right">
|
||||||
|
<InfoCircleOutlined style={{ marginLeft: 8 }} />
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
options={embeddingSelectOptions}
|
||||||
|
placeholder={t('settings.models.empty')}
|
||||||
|
onChange={(value) => {
|
||||||
|
const model = value
|
||||||
|
? providers.flatMap((p) => p.models).find((m) => getModelUniqId(m) === value)
|
||||||
|
: undefined
|
||||||
|
if (!model) return
|
||||||
|
setNewBase({ ...newBase, model })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SettingsItem>
|
||||||
|
|
||||||
|
<SettingsItem>
|
||||||
|
<div className="settings-label">
|
||||||
|
{t('models.rerank_model')}
|
||||||
|
<Tooltip title={t('models.rerank_model_tooltip')} placement="right">
|
||||||
|
<InfoCircleOutlined style={{ marginLeft: 8 }} />
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
options={rerankSelectOptions}
|
||||||
|
placeholder={t('settings.models.empty')}
|
||||||
|
onChange={(value) => {
|
||||||
|
const rerankModel = value
|
||||||
|
? providers.flatMap((p) => p.models).find((m) => getModelUniqId(m) === value)
|
||||||
|
: undefined
|
||||||
|
setNewBase({ ...newBase, rerankModel })
|
||||||
|
}}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
</SettingsItem>
|
||||||
|
|
||||||
|
<SettingsItem>
|
||||||
|
<div className="settings-label">
|
||||||
|
{t('knowledge.document_count')}
|
||||||
|
<Tooltip title={t('knowledge.document_count_help')}>
|
||||||
|
<InfoCircleOutlined style={{ marginLeft: 8 }} />
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
min={1}
|
||||||
|
max={30}
|
||||||
|
step={1}
|
||||||
|
defaultValue={DEFAULT_KNOWLEDGE_DOCUMENT_COUNT}
|
||||||
|
marks={{ 1: '1', 6: t('knowledge.document_count_default'), 30: '30' }}
|
||||||
|
onChange={(value) => setNewBase({ ...newBase, documentCount: value })}
|
||||||
|
/>
|
||||||
|
</SettingsItem>
|
||||||
|
|
||||||
|
{/* dimensions */}
|
||||||
|
<SettingsItem>
|
||||||
|
<div
|
||||||
|
className="settings-label"
|
||||||
|
style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
|
||||||
|
<span>
|
||||||
|
{t('knowledge.dimensions_auto_set')}
|
||||||
|
<Tooltip title={t('knowledge.dimensions_default')} placement="right">
|
||||||
|
<InfoCircleOutlined style={{ marginLeft: 8 }} />
|
||||||
|
</Tooltip>
|
||||||
|
</span>
|
||||||
|
<Switch
|
||||||
|
checked={autoDims}
|
||||||
|
onChange={(checked) => {
|
||||||
|
setAutoDims(checked)
|
||||||
|
if (checked) {
|
||||||
|
setDimensions(undefined)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsItem>
|
||||||
|
|
||||||
|
{!autoDims && (
|
||||||
|
<SettingsItem>
|
||||||
|
<div className="settings-label">
|
||||||
|
{t('knowledge.dimensions')}
|
||||||
|
<Tooltip title={t('knowledge.dimensions_size_tooltip')} placement="right">
|
||||||
|
<InfoCircleOutlined style={{ marginLeft: 8 }} />
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<InputNumber
|
||||||
|
min={1}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
placeholder={t('knowledge.dimensions_size_placeholder')}
|
||||||
|
value={newBase.dimensions}
|
||||||
|
onChange={(value) => {
|
||||||
|
setDimensions(value === null ? undefined : value)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SettingsItem>
|
||||||
|
)}
|
||||||
|
</SettingsPanel>
|
||||||
|
),
|
||||||
|
icon: <SettingOutlined />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '2',
|
||||||
|
label: t('settings.advanced.title'),
|
||||||
|
children: (
|
||||||
|
<SettingsPanel>
|
||||||
|
<SettingsItem>
|
||||||
|
<div className="settings-label">
|
||||||
|
{t('knowledge.chunk_size')}
|
||||||
|
<Tooltip title={t('knowledge.chunk_size_tooltip')} placement="right">
|
||||||
|
<InfoCircleOutlined style={{ marginLeft: 8 }} />
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<InputNumber
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
min={100}
|
||||||
|
value={newBase.chunkSize}
|
||||||
|
placeholder={t('knowledge.chunk_size_placeholder')}
|
||||||
|
onChange={(value) => {
|
||||||
|
const maxContext = getEmbeddingMaxContext(newBase.model.id)
|
||||||
|
if (!value || !maxContext || value <= maxContext) {
|
||||||
|
setNewBase({ ...newBase, chunkSize: value || undefined })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SettingsItem>
|
||||||
|
|
||||||
|
<SettingsItem>
|
||||||
|
<div className="settings-label">
|
||||||
|
{t('knowledge.chunk_overlap')}
|
||||||
|
<Tooltip title={t('knowledge.chunk_overlap_tooltip')} placement="right">
|
||||||
|
<InfoCircleOutlined style={{ marginLeft: 8 }} />
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<InputNumber
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
min={0}
|
||||||
|
value={newBase.chunkOverlap}
|
||||||
|
placeholder={t('knowledge.chunk_overlap_placeholder')}
|
||||||
|
onChange={async (value) => {
|
||||||
|
if (!value || (newBase.chunkSize && newBase.chunkSize > value)) {
|
||||||
|
setNewBase({ ...newBase, chunkOverlap: value || undefined })
|
||||||
|
}
|
||||||
|
await window.message.error(t('message.error.chunk_overlap_too_large'))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SettingsItem>
|
||||||
|
|
||||||
|
<SettingsItem>
|
||||||
|
<div className="settings-label">
|
||||||
|
{t('knowledge.threshold')}
|
||||||
|
<Tooltip title={t('knowledge.threshold_tooltip')} placement="right">
|
||||||
|
<InfoCircleOutlined style={{ marginLeft: 8 }} />
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<InputNumber
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
step={0.1}
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
value={newBase.threshold}
|
||||||
|
placeholder={t('knowledge.threshold_placeholder')}
|
||||||
|
onChange={(value) => setNewBase({ ...newBase, threshold: value || undefined })}
|
||||||
|
/>
|
||||||
|
</SettingsItem>
|
||||||
|
|
||||||
|
<Alert
|
||||||
|
message={t('knowledge.chunk_size_change_warning')}
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
icon={<WarningOutlined />}
|
||||||
|
/>
|
||||||
|
</SettingsPanel>
|
||||||
|
),
|
||||||
|
icon: <SettingOutlined />
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<SettingsModal
|
||||||
title={title}
|
title={title}
|
||||||
open={open}
|
open={open}
|
||||||
onOk={onOk}
|
onOk={onOk}
|
||||||
@@ -170,104 +427,49 @@ const PopupContainer: React.FC<Props> = ({ title, resolve }) => {
|
|||||||
destroyOnClose
|
destroyOnClose
|
||||||
centered
|
centered
|
||||||
okButtonProps={{ loading }}>
|
okButtonProps={{ loading }}>
|
||||||
<Form form={form} layout="vertical">
|
<div>
|
||||||
<Form.Item
|
<Tabs style={{ minHeight: '50vh' }} defaultActiveKey="1" tabPosition={'left'} items={settingItems} />
|
||||||
name="name"
|
</div>
|
||||||
label={t('common.name')}
|
</SettingsModal>
|
||||||
rules={[{ required: true, message: t('message.error.enter.name') }]}>
|
|
||||||
<Input placeholder={t('common.name')} ref={nameInputRef} />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
name="model"
|
|
||||||
label={t('models.embedding_model')}
|
|
||||||
tooltip={{ title: t('models.embedding_model_tooltip'), placement: 'right' }}
|
|
||||||
rules={[{ required: true, message: t('message.error.enter.model') }]}>
|
|
||||||
<Select
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
options={embeddingSelectOptions}
|
|
||||||
placeholder={t('settings.models.empty')}
|
|
||||||
suffixIcon={<ChevronDown size={16} color="var(--color-border)" />}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
name="rerankModel"
|
|
||||||
label={t('models.rerank_model')}
|
|
||||||
tooltip={{ title: t('models.rerank_model_tooltip'), placement: 'right' }}
|
|
||||||
rules={[{ required: false, message: t('message.error.enter.model') }]}>
|
|
||||||
<Select
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
options={rerankSelectOptions}
|
|
||||||
placeholder={t('settings.models.empty')}
|
|
||||||
suffixIcon={<ChevronDown size={16} color="var(--color-border)" />}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
<SettingHelpText style={{ marginTop: -15, marginBottom: 20 }}>
|
|
||||||
{t('models.rerank_model_not_support_provider', {
|
|
||||||
provider: NOT_SUPPORTED_REANK_PROVIDERS.map((id) => t(`provider.${id}`))
|
|
||||||
})}
|
|
||||||
</SettingHelpText>
|
|
||||||
<Form.Item
|
|
||||||
name="documentCount"
|
|
||||||
label={t('knowledge.document_count')}
|
|
||||||
initialValue={DEFAULT_KNOWLEDGE_DOCUMENT_COUNT} // 设置初始值
|
|
||||||
tooltip={{ title: t('knowledge.document_count_help') }}>
|
|
||||||
<Slider min={1} max={30} step={1} marks={{ 1: '1', 6: t('knowledge.document_count_default'), 30: '30' }} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
name="autoDims"
|
|
||||||
colon={false}
|
|
||||||
initialValue={true}
|
|
||||||
layout="horizontal"
|
|
||||||
label={t('knowledge.dimensions_auto_set')}
|
|
||||||
tooltip={t('knowledge.dimensions_default')}
|
|
||||||
style={{ marginBottom: 0, justifyContent: 'space-between' }}>
|
|
||||||
<Flex justify="flex-end" style={{ marginBottom: '1rem' }}>
|
|
||||||
<Switch
|
|
||||||
checked={autoDims}
|
|
||||||
onClick={() => {
|
|
||||||
form.setFieldValue('autoDims', !autoDims)
|
|
||||||
if (!autoDims) {
|
|
||||||
form.validateFields(['dimensions'])
|
|
||||||
}
|
|
||||||
setAutoDims(!autoDims)
|
|
||||||
}}></Switch>
|
|
||||||
</Flex>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
name="dimensions"
|
|
||||||
colon={false}
|
|
||||||
layout="horizontal"
|
|
||||||
initialValue={undefined}
|
|
||||||
label={t('knowledge.dimensions')}
|
|
||||||
tooltip={{ title: t('knowledge.dimensions_size_tooltip') }}
|
|
||||||
dependencies={['model']}
|
|
||||||
style={{ display: autoDims ? 'none' : 'block' }}
|
|
||||||
rules={[
|
|
||||||
({ getFieldValue }) => ({
|
|
||||||
validator(_, value) {
|
|
||||||
if (getFieldValue('autoDims') || value > 0) {
|
|
||||||
return Promise.resolve()
|
|
||||||
} else {
|
|
||||||
return Promise.reject(t('knowledge.dimensions_error_invalid'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
]}>
|
|
||||||
<InputNumber min={1} style={{ width: '100%' }} placeholder={t('knowledge.dimensions_size_placeholder')} />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
{!autoDims && (
|
|
||||||
<SettingHelpText style={{ marginTop: -15, marginBottom: 20 }}>
|
|
||||||
{t('knowledge.dimensions_set_right')}
|
|
||||||
</SettingHelpText>
|
|
||||||
)}
|
|
||||||
</Form>
|
|
||||||
</Modal>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SettingsPanel = styled.div`
|
||||||
|
padding: 0 16px;
|
||||||
|
`
|
||||||
|
|
||||||
|
const SettingsItem = styled.div`
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
.settings-label {
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const SettingsModal = styled(Modal)`
|
||||||
|
.ant-modal {
|
||||||
|
width: auto !important;
|
||||||
|
height: auto !important;
|
||||||
|
}
|
||||||
|
.ant-modal-content {
|
||||||
|
min-height: 60vh;
|
||||||
|
width: 50vw;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.ant-modal-body {
|
||||||
|
flex: 1;
|
||||||
|
max-height: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ant-tabs-tab {
|
||||||
|
padding-inline-start: 0px !important;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
export default class AddKnowledgePopup {
|
export default class AddKnowledgePopup {
|
||||||
static hide() {
|
static hide() {
|
||||||
TopView.hide('AddKnowledgePopup')
|
TopView.hide('AddKnowledgePopup')
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
|
|||||||
import { HStack } from '@renderer/components/Layout'
|
import { HStack } from '@renderer/components/Layout'
|
||||||
import { TopView } from '@renderer/components/TopView'
|
import { TopView } from '@renderer/components/TopView'
|
||||||
import { searchKnowledgeBase } from '@renderer/services/KnowledgeService'
|
import { searchKnowledgeBase } from '@renderer/services/KnowledgeService'
|
||||||
import { FileType, KnowledgeBase } from '@renderer/types'
|
import { FileMetadata, KnowledgeBase } from '@renderer/types'
|
||||||
import { Divider, Input, InputRef, List, message, Modal, Spin, Tooltip, Typography } from 'antd'
|
import { Divider, Input, InputRef, List, message, Modal, Spin, Tooltip, Typography } from 'antd'
|
||||||
import { Search } from 'lucide-react'
|
import { Search } from 'lucide-react'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
@@ -23,7 +23,7 @@ interface Props extends ShowParams {
|
|||||||
const PopupContainer: React.FC<Props> = ({ base, resolve }) => {
|
const PopupContainer: React.FC<Props> = ({ base, resolve }) => {
|
||||||
const [open, setOpen] = useState(true)
|
const [open, setOpen] = useState(true)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [results, setResults] = useState<Array<ExtractChunkData & { file: FileType | null }>>([])
|
const [results, setResults] = useState<Array<ExtractChunkData & { file: FileMetadata | null }>>([])
|
||||||
const [searchKeyword, setSearchKeyword] = useState('')
|
const [searchKeyword, setSearchKeyword] = useState('')
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const searchInputRef = useRef<InputRef>(null)
|
const searchInputRef = useRef<InputRef>(null)
|
||||||
@@ -149,14 +149,17 @@ const PopupContainer: React.FC<Props> = ({ base, resolve }) => {
|
|||||||
<List.Item>
|
<List.Item>
|
||||||
<ResultItem>
|
<ResultItem>
|
||||||
<MetadataContainer>
|
<MetadataContainer>
|
||||||
<Text type="secondary" ellipsis>
|
<Text type="secondary">
|
||||||
{t('knowledge.source')}:{' '}
|
{t('knowledge.source')}:{' '}
|
||||||
{item.file ? (
|
{item.file ? (
|
||||||
<a href={`http://file/${item.file.name}`} target="_blank" rel="noreferrer">
|
<a href={`http://file/${item.file.name}`} target="_blank" rel="noreferrer">
|
||||||
{item.file.origin_name}
|
{item.file.origin_name}
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
item.metadata.source
|
// item.metadata.source
|
||||||
|
<a href={`http://file/${item.metadata.source}`} target="_blank" rel="noreferrer">
|
||||||
|
{item.metadata.source.split('/').pop() || item.metadata.source}
|
||||||
|
</a>
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
<ScoreTag>Score: {(item.score * 100).toFixed(1)}%</ScoreTag>
|
<ScoreTag>Score: {(item.score * 100).toFixed(1)}%</ScoreTag>
|
||||||
|
|||||||
@@ -0,0 +1,375 @@
|
|||||||
|
import { InfoCircleOutlined, SettingOutlined, WarningOutlined } from '@ant-design/icons'
|
||||||
|
import { TopView } from '@renderer/components/TopView'
|
||||||
|
import { DEFAULT_KNOWLEDGE_DOCUMENT_COUNT, isMac } from '@renderer/config/constant'
|
||||||
|
import { getEmbeddingMaxContext } from '@renderer/config/embedings'
|
||||||
|
import { isEmbeddingModel, isRerankModel } from '@renderer/config/models'
|
||||||
|
import { useKnowledge } from '@renderer/hooks/useKnowledge'
|
||||||
|
import { useOcrProviders } from '@renderer/hooks/useOcr'
|
||||||
|
import { usePreprocessProviders } from '@renderer/hooks/usePreprocess'
|
||||||
|
import { useProviders } from '@renderer/hooks/useProvider'
|
||||||
|
import { getModelUniqId } from '@renderer/services/ModelService'
|
||||||
|
import { KnowledgeBase, PreprocessProvider } from '@renderer/types'
|
||||||
|
import { Alert, Input, InputNumber, Modal, Select, Slider, Tabs, TabsProps, Tooltip } from 'antd'
|
||||||
|
import { sortBy } from 'lodash'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import styled from 'styled-components'
|
||||||
|
|
||||||
|
interface ShowParams {
|
||||||
|
base: KnowledgeBase
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props extends ShowParams {
|
||||||
|
resolve: (data: any) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const PopupContainer: React.FC<Props> = ({ base: _base, resolve }) => {
|
||||||
|
const { preprocessProviders } = usePreprocessProviders()
|
||||||
|
const { ocrProviders } = useOcrProviders()
|
||||||
|
|
||||||
|
const [selectedProvider, setSelectedProvider] = useState<PreprocessProvider | undefined>(
|
||||||
|
_base.preprocessOrOcrProvider?.provider
|
||||||
|
)
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(true)
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { providers } = useProviders()
|
||||||
|
const { base, updateKnowledgeBase } = useKnowledge(_base.id)
|
||||||
|
const [newBase, setNewBase] = useState<KnowledgeBase>(_base)
|
||||||
|
|
||||||
|
if (!base) {
|
||||||
|
resolve(null)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectOptions = providers
|
||||||
|
.filter((p) => p.models.length > 0)
|
||||||
|
.map((p) => ({
|
||||||
|
label: p.isSystem ? t(`provider.${p.id}`) : p.name,
|
||||||
|
title: p.name,
|
||||||
|
options: sortBy(p.models, 'name')
|
||||||
|
.filter((model) => isEmbeddingModel(model))
|
||||||
|
.map((m) => ({
|
||||||
|
label: m.name,
|
||||||
|
value: getModelUniqId(m)
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
.filter((group) => group.options.length > 0)
|
||||||
|
|
||||||
|
const rerankSelectOptions = providers
|
||||||
|
.filter((p) => p.models.length > 0)
|
||||||
|
.map((p) => ({
|
||||||
|
label: p.isSystem ? t(`provider.${p.id}`) : p.name,
|
||||||
|
title: p.name,
|
||||||
|
options: sortBy(p.models, 'name')
|
||||||
|
.filter((model) => isRerankModel(model))
|
||||||
|
.map((m) => ({
|
||||||
|
label: m.name,
|
||||||
|
value: getModelUniqId(m)
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
.filter((group) => group.options.length > 0)
|
||||||
|
|
||||||
|
const preprocessOptions = {
|
||||||
|
label: t('settings.tool.preprocess.provider'),
|
||||||
|
title: t('settings.tool.preprocess.provider'),
|
||||||
|
options: preprocessProviders
|
||||||
|
// todo: 免费期结束后删除
|
||||||
|
.filter((p) => p.apiKey !== '' || p.id === 'mineru')
|
||||||
|
.map((p) => ({ value: p.id, label: p.name }))
|
||||||
|
}
|
||||||
|
const ocrOptions = {
|
||||||
|
label: t('settings.tool.ocr.provider'),
|
||||||
|
title: t('settings.tool.ocr.provider'),
|
||||||
|
options: ocrProviders.filter((p) => p.apiKey !== '').map((p) => ({ value: p.id, label: p.name }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const preprocessOrOcrSelectOptions = [
|
||||||
|
...(preprocessOptions.options.length > 0 ? [preprocessOptions] : []),
|
||||||
|
...(isMac && ocrOptions.options.length > 0 ? [ocrOptions] : [])
|
||||||
|
]
|
||||||
|
|
||||||
|
const onOk = async () => {
|
||||||
|
try {
|
||||||
|
console.log('newbase', newBase)
|
||||||
|
updateKnowledgeBase(newBase)
|
||||||
|
setOpen(false)
|
||||||
|
resolve(newBase)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Validation failed:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCancel = () => {
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onClose = () => {
|
||||||
|
resolve(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const settingItems: TabsProps['items'] = [
|
||||||
|
{
|
||||||
|
key: '1',
|
||||||
|
label: t('settings.general'),
|
||||||
|
children: (
|
||||||
|
<SettingsPanel>
|
||||||
|
<SettingsItem>
|
||||||
|
<div className="settings-label">{t('common.name')}</div>
|
||||||
|
<Input
|
||||||
|
placeholder={t('common.name')}
|
||||||
|
defaultValue={base.name}
|
||||||
|
onChange={(e) => setNewBase({ ...newBase, name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</SettingsItem>
|
||||||
|
|
||||||
|
<SettingsItem>
|
||||||
|
<div className="settings-label">
|
||||||
|
{t('settings.tool.preprocess.title')} / {t('settings.tool.ocr.title')}
|
||||||
|
<Tooltip title={t('settings.tool.preprocessOrOcr.tooltip')} placement="right">
|
||||||
|
<InfoCircleOutlined style={{ marginLeft: 8 }} />
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
value={selectedProvider?.id}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
onChange={(value: string) => {
|
||||||
|
const type = preprocessProviders.find((p) => p.id === value) ? 'preprocess' : 'ocr'
|
||||||
|
const provider = (type === 'preprocess' ? preprocessProviders : ocrProviders).find(
|
||||||
|
(p) => p.id === value
|
||||||
|
)
|
||||||
|
if (!provider) {
|
||||||
|
setSelectedProvider(undefined)
|
||||||
|
setNewBase({
|
||||||
|
...newBase,
|
||||||
|
preprocessOrOcrProvider: undefined
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSelectedProvider(provider)
|
||||||
|
setNewBase({
|
||||||
|
...newBase,
|
||||||
|
preprocessOrOcrProvider: {
|
||||||
|
type: type,
|
||||||
|
provider: provider
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
placeholder={t('settings.tool.preprocess.provider_placeholder')}
|
||||||
|
options={preprocessOrOcrSelectOptions}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
</SettingsItem>
|
||||||
|
|
||||||
|
<SettingsItem>
|
||||||
|
<div className="settings-label">
|
||||||
|
{t('models.embedding_model')}
|
||||||
|
<Tooltip title={t('models.embedding_model_tooltip')} placement="right">
|
||||||
|
<InfoCircleOutlined style={{ marginLeft: 8 }} />
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
options={selectOptions}
|
||||||
|
placeholder={t('settings.models.empty')}
|
||||||
|
defaultValue={getModelUniqId(base.model)}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</SettingsItem>
|
||||||
|
|
||||||
|
<SettingsItem>
|
||||||
|
<div className="settings-label">
|
||||||
|
{t('models.rerank_model')}
|
||||||
|
<Tooltip title={t('models.rerank_model_tooltip')} placement="right">
|
||||||
|
<InfoCircleOutlined style={{ marginLeft: 8 }} />
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
defaultValue={getModelUniqId(base.rerankModel) || undefined}
|
||||||
|
options={rerankSelectOptions}
|
||||||
|
placeholder={t('settings.models.empty')}
|
||||||
|
onChange={(value) => {
|
||||||
|
const rerankModel = value
|
||||||
|
? providers.flatMap((p) => p.models).find((m) => getModelUniqId(m) === value)
|
||||||
|
: undefined
|
||||||
|
setNewBase({ ...newBase, rerankModel })
|
||||||
|
}}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
</SettingsItem>
|
||||||
|
|
||||||
|
<SettingsItem>
|
||||||
|
<div className="settings-label">
|
||||||
|
{t('knowledge.document_count')}
|
||||||
|
<Tooltip title={t('knowledge.document_count_help')}>
|
||||||
|
<InfoCircleOutlined style={{ marginLeft: 8 }} />
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
min={1}
|
||||||
|
max={30}
|
||||||
|
step={1}
|
||||||
|
defaultValue={base.documentCount || DEFAULT_KNOWLEDGE_DOCUMENT_COUNT}
|
||||||
|
marks={{ 1: '1', 6: t('knowledge.document_count_default'), 30: '30' }}
|
||||||
|
onChange={(value) => setNewBase({ ...newBase, documentCount: value })}
|
||||||
|
/>
|
||||||
|
</SettingsItem>
|
||||||
|
</SettingsPanel>
|
||||||
|
),
|
||||||
|
icon: <SettingOutlined />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '2',
|
||||||
|
label: t('settings.advanced.title'),
|
||||||
|
children: (
|
||||||
|
<SettingsPanel>
|
||||||
|
<SettingsItem>
|
||||||
|
<div className="settings-label">
|
||||||
|
{t('knowledge.chunk_size')}
|
||||||
|
<Tooltip title={t('knowledge.chunk_size_tooltip')} placement="right">
|
||||||
|
<InfoCircleOutlined style={{ marginLeft: 8 }} />
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<InputNumber
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
min={100}
|
||||||
|
value={base.chunkSize}
|
||||||
|
placeholder={t('knowledge.chunk_size_placeholder')}
|
||||||
|
onChange={(value) => {
|
||||||
|
const maxContext = getEmbeddingMaxContext(base.model.id)
|
||||||
|
if (!value || !maxContext || value <= maxContext) {
|
||||||
|
setNewBase({ ...newBase, chunkSize: value || undefined })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SettingsItem>
|
||||||
|
|
||||||
|
<SettingsItem>
|
||||||
|
<div className="settings-label">
|
||||||
|
{t('knowledge.chunk_overlap')}
|
||||||
|
<Tooltip title={t('knowledge.chunk_overlap_tooltip')} placement="right">
|
||||||
|
<InfoCircleOutlined style={{ marginLeft: 8 }} />
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<InputNumber
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
min={0}
|
||||||
|
value={base.chunkOverlap}
|
||||||
|
placeholder={t('knowledge.chunk_overlap_placeholder')}
|
||||||
|
onChange={async (value) => {
|
||||||
|
if (!value || (newBase.chunkSize && newBase.chunkSize > value)) {
|
||||||
|
setNewBase({ ...newBase, chunkOverlap: value || undefined })
|
||||||
|
}
|
||||||
|
await window.message.error(t('message.error.chunk_overlap_too_large'))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SettingsItem>
|
||||||
|
|
||||||
|
<SettingsItem>
|
||||||
|
<div className="settings-label">
|
||||||
|
{t('knowledge.threshold')}
|
||||||
|
<Tooltip title={t('knowledge.threshold_tooltip')} placement="right">
|
||||||
|
<InfoCircleOutlined style={{ marginLeft: 8 }} />
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<InputNumber
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
step={0.1}
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
value={base.threshold}
|
||||||
|
placeholder={t('knowledge.threshold_placeholder')}
|
||||||
|
onChange={(value) => setNewBase({ ...newBase, threshold: value || undefined })}
|
||||||
|
/>
|
||||||
|
</SettingsItem>
|
||||||
|
|
||||||
|
<Alert
|
||||||
|
message={t('knowledge.chunk_size_change_warning')}
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
icon={<WarningOutlined />}
|
||||||
|
/>
|
||||||
|
</SettingsPanel>
|
||||||
|
),
|
||||||
|
icon: <SettingOutlined />
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
KnowledgeSettings.hide = onCancel
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsModal
|
||||||
|
title={t('knowledge.settings.title')}
|
||||||
|
open={open}
|
||||||
|
onOk={onOk}
|
||||||
|
onCancel={onCancel}
|
||||||
|
afterClose={onClose}
|
||||||
|
destroyOnClose
|
||||||
|
maskClosable={false}
|
||||||
|
centered>
|
||||||
|
<div>
|
||||||
|
<Tabs style={{ minHeight: '50vh' }} defaultActiveKey="1" tabPosition={'left'} items={settingItems} />
|
||||||
|
</div>
|
||||||
|
</SettingsModal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const TopViewKey = 'KnowledgeSettingsPopup'
|
||||||
|
|
||||||
|
const SettingsPanel = styled.div`
|
||||||
|
padding: 0 16px;
|
||||||
|
`
|
||||||
|
|
||||||
|
const SettingsItem = styled.div`
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
.settings-label {
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
const SettingsModal = styled(Modal)`
|
||||||
|
.ant-modal {
|
||||||
|
width: auto !important;
|
||||||
|
height: auto !important;
|
||||||
|
}
|
||||||
|
.ant-modal-content {
|
||||||
|
min-height: 60vh;
|
||||||
|
width: 50vw;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.ant-modal-body {
|
||||||
|
flex: 1;
|
||||||
|
max-height: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ant-tabs-tab {
|
||||||
|
padding-inline-start: 0px !important;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export default class KnowledgeSettings {
|
||||||
|
static hide() {
|
||||||
|
TopView.hide(TopViewKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
static show(props: ShowParams) {
|
||||||
|
return new Promise<any>((resolve) => {
|
||||||
|
TopView.show(
|
||||||
|
<PopupContainer
|
||||||
|
{...props}
|
||||||
|
resolve={(v) => {
|
||||||
|
resolve(v)
|
||||||
|
TopView.hide(TopViewKey)
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
TopViewKey
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,292 +0,0 @@
|
|||||||
import { WarningOutlined } from '@ant-design/icons'
|
|
||||||
import { TopView } from '@renderer/components/TopView'
|
|
||||||
import { DEFAULT_KNOWLEDGE_DOCUMENT_COUNT } from '@renderer/config/constant'
|
|
||||||
import { getEmbeddingMaxContext } from '@renderer/config/embedings'
|
|
||||||
import { isEmbeddingModel, isRerankModel } from '@renderer/config/models'
|
|
||||||
import { NOT_SUPPORTED_REANK_PROVIDERS } from '@renderer/config/providers'
|
|
||||||
// import { SUPPORTED_REANK_PROVIDERS } from '@renderer/config/providers'
|
|
||||||
import { useKnowledge } from '@renderer/hooks/useKnowledge'
|
|
||||||
import { useProviders } from '@renderer/hooks/useProvider'
|
|
||||||
import { SettingHelpText } from '@renderer/pages/settings'
|
|
||||||
import { getModelUniqId } from '@renderer/services/ModelService'
|
|
||||||
import { KnowledgeBase } from '@renderer/types'
|
|
||||||
import { Alert, Button, Form, Input, InputNumber, Modal, Select, Slider } from 'antd'
|
|
||||||
import { sortBy } from 'lodash'
|
|
||||||
import { ChevronDown } from 'lucide-react'
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
|
|
||||||
interface ShowParams {
|
|
||||||
base: KnowledgeBase
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FormData {
|
|
||||||
name: string
|
|
||||||
model: string
|
|
||||||
documentCount?: number
|
|
||||||
dimensions?: number
|
|
||||||
chunkSize?: number
|
|
||||||
chunkOverlap?: number
|
|
||||||
threshold?: number
|
|
||||||
rerankModel?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props extends ShowParams {
|
|
||||||
resolve: (data: any) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const PopupContainer: React.FC<Props> = ({ base: _base, resolve }) => {
|
|
||||||
const [open, setOpen] = useState(true)
|
|
||||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
|
||||||
const [form] = Form.useForm<FormData>()
|
|
||||||
const { t } = useTranslation()
|
|
||||||
const { providers } = useProviders()
|
|
||||||
const { base, updateKnowledgeBase } = useKnowledge(_base.id)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
form.setFieldsValue({ documentCount: base?.documentCount || 6 })
|
|
||||||
}, [base, form])
|
|
||||||
|
|
||||||
if (!base) {
|
|
||||||
resolve(null)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectOptions = providers
|
|
||||||
.filter((p) => p.models.length > 0)
|
|
||||||
.map((p) => ({
|
|
||||||
label: p.isSystem ? t(`provider.${p.id}`) : p.name,
|
|
||||||
title: p.name,
|
|
||||||
options: sortBy(p.models, 'name')
|
|
||||||
.filter((model) => isEmbeddingModel(model) && !isRerankModel(model))
|
|
||||||
.map((m) => ({
|
|
||||||
label: m.name,
|
|
||||||
value: getModelUniqId(m)
|
|
||||||
}))
|
|
||||||
}))
|
|
||||||
.filter((group) => group.options.length > 0)
|
|
||||||
|
|
||||||
const rerankSelectOptions = providers
|
|
||||||
.filter((p) => p.models.length > 0)
|
|
||||||
.filter((p) => !NOT_SUPPORTED_REANK_PROVIDERS.includes(p.id))
|
|
||||||
.map((p) => ({
|
|
||||||
label: p.isSystem ? t(`provider.${p.id}`) : p.name,
|
|
||||||
title: p.name,
|
|
||||||
options: sortBy(p.models, 'name')
|
|
||||||
.filter((model) => isRerankModel(model))
|
|
||||||
.map((m) => ({
|
|
||||||
label: m.name,
|
|
||||||
value: getModelUniqId(m)
|
|
||||||
}))
|
|
||||||
}))
|
|
||||||
.filter((group) => group.options.length > 0)
|
|
||||||
|
|
||||||
const onOk = async () => {
|
|
||||||
try {
|
|
||||||
const values = await form.validateFields()
|
|
||||||
const newBase = {
|
|
||||||
...base,
|
|
||||||
name: values.name,
|
|
||||||
documentCount: values.documentCount || DEFAULT_KNOWLEDGE_DOCUMENT_COUNT,
|
|
||||||
dimensions: values.dimensions || base.dimensions,
|
|
||||||
chunkSize: values.chunkSize,
|
|
||||||
chunkOverlap: values.chunkOverlap,
|
|
||||||
threshold: values.threshold ?? undefined,
|
|
||||||
rerankModel: values.rerankModel
|
|
||||||
? providers.flatMap((p) => p.models).find((m) => getModelUniqId(m) === values.rerankModel)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
updateKnowledgeBase(newBase)
|
|
||||||
setOpen(false)
|
|
||||||
setTimeout(() => resolve(newBase), 350)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Validation failed:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const onCancel = () => {
|
|
||||||
setOpen(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const onClose = () => {
|
|
||||||
resolve(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
KnowledgeSettingsPopup.hide = onCancel
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
title={t('knowledge.settings')}
|
|
||||||
open={open}
|
|
||||||
onOk={onOk}
|
|
||||||
onCancel={onCancel}
|
|
||||||
afterClose={onClose}
|
|
||||||
destroyOnClose
|
|
||||||
maskClosable={false}
|
|
||||||
transitionName="animation-move-down"
|
|
||||||
centered>
|
|
||||||
<Form form={form} layout="vertical" className="compact-form">
|
|
||||||
<Form.Item
|
|
||||||
name="name"
|
|
||||||
label={t('common.name')}
|
|
||||||
initialValue={base.name}
|
|
||||||
rules={[{ required: true, message: t('message.error.enter.name') }]}>
|
|
||||||
<Input placeholder={t('common.name')} />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
name="model"
|
|
||||||
label={t('models.embedding_model')}
|
|
||||||
initialValue={getModelUniqId(base.model)}
|
|
||||||
tooltip={{ title: t('models.embedding_model_tooltip'), placement: 'right' }}
|
|
||||||
rules={[{ required: true, message: t('message.error.enter.model') }]}>
|
|
||||||
<Select
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
options={selectOptions}
|
|
||||||
placeholder={t('settings.models.empty')}
|
|
||||||
disabled
|
|
||||||
suffixIcon={<ChevronDown size={16} color="var(--color-border)" />}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
name="rerankModel"
|
|
||||||
label={t('models.rerank_model')}
|
|
||||||
tooltip={{ title: t('models.rerank_model_tooltip'), placement: 'right' }}
|
|
||||||
initialValue={getModelUniqId(base.rerankModel) || undefined}
|
|
||||||
rules={[{ required: false, message: t('message.error.enter.model') }]}>
|
|
||||||
<Select
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
options={rerankSelectOptions}
|
|
||||||
placeholder={t('settings.models.empty')}
|
|
||||||
allowClear
|
|
||||||
suffixIcon={<ChevronDown size={16} color="var(--color-border)" />}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
<SettingHelpText style={{ marginTop: -15, marginBottom: 20 }}>
|
|
||||||
{t('models.rerank_model_not_support_provider', {
|
|
||||||
provider: NOT_SUPPORTED_REANK_PROVIDERS.map((id) => t(`provider.${id}`))
|
|
||||||
})}
|
|
||||||
</SettingHelpText>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
name="documentCount"
|
|
||||||
label={t('knowledge.document_count')}
|
|
||||||
tooltip={{ title: t('knowledge.document_count_help') }}>
|
|
||||||
<Slider min={1} max={30} step={1} marks={{ 1: '1', 6: t('knowledge.document_count_default'), 30: '30' }} />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Button color="default" variant="filled" onClick={() => setShowAdvanced(!showAdvanced)}>
|
|
||||||
{t('common.advanced_settings')}
|
|
||||||
<ChevronDown
|
|
||||||
size={16}
|
|
||||||
style={{
|
|
||||||
transform: showAdvanced ? 'rotate(180deg)' : 'rotate(0deg)',
|
|
||||||
transition: 'transform 0.3s'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div style={{ display: showAdvanced ? 'block' : 'none', marginTop: 16 }}>
|
|
||||||
<Form.Item
|
|
||||||
name="chunkSize"
|
|
||||||
label={t('knowledge.chunk_size')}
|
|
||||||
layout="horizontal"
|
|
||||||
tooltip={{ title: t('knowledge.chunk_size_tooltip') }}
|
|
||||||
initialValue={base.chunkSize}
|
|
||||||
rules={[
|
|
||||||
{
|
|
||||||
validator(_, value) {
|
|
||||||
const maxContext = getEmbeddingMaxContext(base.model.id)
|
|
||||||
if (value && maxContext && value > maxContext) {
|
|
||||||
return Promise.reject(new Error(t('knowledge.chunk_size_too_large', { max_context: maxContext })))
|
|
||||||
}
|
|
||||||
return Promise.resolve()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]}>
|
|
||||||
<InputNumber
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
min={100}
|
|
||||||
defaultValue={base.chunkSize}
|
|
||||||
placeholder={t('knowledge.chunk_size_placeholder')}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
name="chunkOverlap"
|
|
||||||
label={t('knowledge.chunk_overlap')}
|
|
||||||
layout="horizontal"
|
|
||||||
initialValue={base.chunkOverlap}
|
|
||||||
tooltip={{ title: t('knowledge.chunk_overlap_tooltip') }}
|
|
||||||
rules={[
|
|
||||||
({ getFieldValue }) => ({
|
|
||||||
validator(_, value) {
|
|
||||||
if (!value || getFieldValue('chunkSize') > value) {
|
|
||||||
return Promise.resolve()
|
|
||||||
}
|
|
||||||
return Promise.reject(new Error(t('message.error.chunk_overlap_too_large')))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
]}
|
|
||||||
dependencies={['chunkSize']}>
|
|
||||||
<InputNumber
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
min={0}
|
|
||||||
defaultValue={base.chunkOverlap}
|
|
||||||
placeholder={t('knowledge.chunk_overlap_placeholder')}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
name="threshold"
|
|
||||||
label={t('knowledge.threshold')}
|
|
||||||
layout="horizontal"
|
|
||||||
tooltip={{ title: t('knowledge.threshold_tooltip') }}
|
|
||||||
initialValue={base.threshold}
|
|
||||||
rules={[
|
|
||||||
{
|
|
||||||
validator(_, value) {
|
|
||||||
if (value && (value > 1 || value < 0)) {
|
|
||||||
return Promise.reject(new Error(t('knowledge.threshold_too_large_or_small')))
|
|
||||||
}
|
|
||||||
return Promise.resolve()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]}>
|
|
||||||
<InputNumber placeholder={t('knowledge.threshold_placeholder')} step={0.1} style={{ width: '100%' }} />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Alert
|
|
||||||
message={t('knowledge.chunk_size_change_warning')}
|
|
||||||
type="warning"
|
|
||||||
showIcon
|
|
||||||
icon={<WarningOutlined />}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
</Modal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const TopViewKey = 'KnowledgeSettingsPopup'
|
|
||||||
|
|
||||||
export default class KnowledgeSettingsPopup {
|
|
||||||
static hide() {
|
|
||||||
TopView.hide(TopViewKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
static show(props: ShowParams) {
|
|
||||||
return new Promise<any>((resolve) => {
|
|
||||||
TopView.show(
|
|
||||||
<PopupContainer
|
|
||||||
{...props}
|
|
||||||
resolve={(v) => {
|
|
||||||
resolve(v)
|
|
||||||
TopView.hide(TopViewKey)
|
|
||||||
}}
|
|
||||||
/>,
|
|
||||||
TopViewKey
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
66
src/renderer/src/pages/knowledge/components/QuotaTag.tsx
Normal file
66
src/renderer/src/pages/knowledge/components/QuotaTag.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { usePreprocessProvider } from '@renderer/hooks/usePreprocess'
|
||||||
|
import { getStoreSetting } from '@renderer/hooks/useSettings'
|
||||||
|
import { getKnowledgeBaseParams } from '@renderer/services/KnowledgeService'
|
||||||
|
import { KnowledgeBase } from '@renderer/types'
|
||||||
|
import { Tag } from 'antd'
|
||||||
|
import { FC, useEffect, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
const QuotaTag: FC<{ base: KnowledgeBase; providerId: string; quota?: number }> = ({
|
||||||
|
base,
|
||||||
|
providerId,
|
||||||
|
quota: _quota
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { provider, updatePreprocessProvider } = usePreprocessProvider(providerId)
|
||||||
|
const [quota, setQuota] = useState<number | undefined>(_quota)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkQuota = async () => {
|
||||||
|
if (provider.id !== 'mineru') return
|
||||||
|
// 使用用户的key时quota为无限
|
||||||
|
if (provider.apiKey) {
|
||||||
|
setQuota(-9999)
|
||||||
|
updatePreprocessProvider({ ...provider, quota: -9999 })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (quota === undefined) {
|
||||||
|
const userId = getStoreSetting('userId')
|
||||||
|
const baseParams = getKnowledgeBaseParams(base)
|
||||||
|
try {
|
||||||
|
const response = await window.api.knowledgeBase.checkQuota({
|
||||||
|
base: baseParams,
|
||||||
|
userId: userId as string
|
||||||
|
})
|
||||||
|
setQuota(response)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[KnowledgeContent] Error checking quota:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (_quota) {
|
||||||
|
updatePreprocessProvider({ ...provider, quota: _quota })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
checkQuota()
|
||||||
|
}, [_quota, base])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{quota && (
|
||||||
|
<Tag color="orange" style={{ borderRadius: 20, margin: 0 }}>
|
||||||
|
{quota === -9999
|
||||||
|
? t('knowledge.quota_infinity', {
|
||||||
|
name: provider.name
|
||||||
|
})
|
||||||
|
: t('knowledge.quota', {
|
||||||
|
name: provider.name,
|
||||||
|
quota: quota
|
||||||
|
})}
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default QuotaTag
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons'
|
import { CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons'
|
||||||
import { KnowledgeBase, ProcessingStatus } from '@renderer/types'
|
import { KnowledgeBase, ProcessingStatus } from '@renderer/types'
|
||||||
import { Progress, Tooltip } from 'antd'
|
import { Progress, Tooltip } from 'antd'
|
||||||
import { FC } from 'react'
|
import React from 'react'
|
||||||
|
import { FC, useMemo } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
@@ -9,64 +10,82 @@ interface StatusIconProps {
|
|||||||
sourceId: string
|
sourceId: string
|
||||||
base: KnowledgeBase
|
base: KnowledgeBase
|
||||||
getProcessingStatus: (sourceId: string) => ProcessingStatus | undefined
|
getProcessingStatus: (sourceId: string) => ProcessingStatus | undefined
|
||||||
getProcessingPercent?: (sourceId: string) => number | undefined
|
|
||||||
type: string
|
type: string
|
||||||
|
progress?: number
|
||||||
|
isPreprocessed?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const StatusIcon: FC<StatusIconProps> = ({ sourceId, base, getProcessingStatus, getProcessingPercent, type }) => {
|
const StatusIcon: FC<StatusIconProps> = ({
|
||||||
|
sourceId,
|
||||||
|
base,
|
||||||
|
getProcessingStatus,
|
||||||
|
type,
|
||||||
|
progress = 0,
|
||||||
|
isPreprocessed
|
||||||
|
}) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const status = getProcessingStatus(sourceId)
|
const status = getProcessingStatus(sourceId)
|
||||||
const percent = getProcessingPercent?.(sourceId)
|
|
||||||
const item = base.items.find((item) => item.id === sourceId)
|
const item = base.items.find((item) => item.id === sourceId)
|
||||||
const errorText = item?.processingError
|
const errorText = item?.processingError
|
||||||
|
|
||||||
if (!status) {
|
const statusDisplay = useMemo(() => {
|
||||||
if (item?.uniqueId) {
|
if (!status) {
|
||||||
|
if (item?.uniqueId) {
|
||||||
|
if (isPreprocessed && item.type === 'file') {
|
||||||
|
return (
|
||||||
|
<Tooltip title={t('knowledge.status_preprocess_completed')} placement="left">
|
||||||
|
<CheckCircleOutlined style={{ color: '#52c41a' }} />
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Tooltip title={t('knowledge.status_embedding_completed')} placement="left">
|
||||||
|
<CheckCircleOutlined style={{ color: '#52c41a' }} />
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Tooltip title={t('knowledge.status_completed')} placement="left">
|
<Tooltip title={t('knowledge.status_new')} placement="left">
|
||||||
<CheckCircleOutlined style={{ color: '#52c41a' }} />
|
<StatusDot $status="new" />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return (
|
|
||||||
<Tooltip title={t('knowledge.status_new')} placement="left">
|
|
||||||
<StatusDot $status="new" />
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'pending':
|
case 'pending':
|
||||||
return (
|
return (
|
||||||
<Tooltip title={t('knowledge.status_pending')} placement="left">
|
<Tooltip title={t('knowledge.status_pending')} placement="left">
|
||||||
<StatusDot $status="pending" />
|
<StatusDot $status="pending" />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)
|
)
|
||||||
|
|
||||||
case 'processing': {
|
case 'processing': {
|
||||||
return type === 'directory' ? (
|
return type === 'directory' || type === 'file' ? (
|
||||||
<Progress type="circle" size={14} percent={Number(percent?.toFixed(0))} />
|
<Progress type="circle" size={14} percent={Number(progress?.toFixed(0))} />
|
||||||
) : (
|
) : (
|
||||||
<Tooltip title={t('knowledge.status_processing')} placement="left">
|
<Tooltip title={t('knowledge.status_processing')} placement="left">
|
||||||
<StatusDot $status="processing" />
|
<StatusDot $status="processing" />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
case 'completed':
|
||||||
|
return (
|
||||||
|
<Tooltip title={t('knowledge.status_completed')} placement="left">
|
||||||
|
<CheckCircleOutlined style={{ color: '#52c41a' }} />
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
case 'failed':
|
||||||
|
return (
|
||||||
|
<Tooltip title={errorText || t('knowledge.status_failed')} placement="left">
|
||||||
|
<CloseCircleOutlined style={{ color: '#ff4d4f' }} />
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
case 'completed':
|
}, [status, item?.uniqueId, type, progress, errorText, t])
|
||||||
return (
|
|
||||||
<Tooltip title={t('knowledge.status_completed')} placement="left">
|
return statusDisplay
|
||||||
<CheckCircleOutlined style={{ color: '#52c41a' }} />
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
case 'failed':
|
|
||||||
return (
|
|
||||||
<Tooltip title={errorText || t('knowledge.status_failed')} placement="left">
|
|
||||||
<CloseCircleOutlined style={{ color: '#ff4d4f' }} />
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
default:
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const StatusDot = styled.div<{ $status: 'pending' | 'processing' | 'new' }>`
|
const StatusDot = styled.div<{ $status: 'pending' | 'processing' | 'new' }>`
|
||||||
@@ -91,4 +110,14 @@ const StatusDot = styled.div<{ $status: 'pending' | 'processing' | 'new' }>`
|
|||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
export default StatusIcon
|
export default React.memo(StatusIcon, (prevProps, nextProps) => {
|
||||||
|
return (
|
||||||
|
prevProps.sourceId === nextProps.sourceId &&
|
||||||
|
prevProps.type === nextProps.type &&
|
||||||
|
prevProps.base.id === nextProps.base.id &&
|
||||||
|
prevProps.progress === nextProps.progress &&
|
||||||
|
prevProps.getProcessingStatus(prevProps.sourceId) === nextProps.getProcessingStatus(nextProps.sourceId) &&
|
||||||
|
prevProps.base.items.find((item) => item.id === prevProps.sourceId)?.processingError ===
|
||||||
|
nextProps.base.items.find((item) => item.id === nextProps.sourceId)?.processingError
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|||||||
1246
src/renderer/src/pages/memory/index.tsx
Normal file
1246
src/renderer/src/pages/memory/index.tsx
Normal file
File diff suppressed because it is too large
Load Diff
126
src/renderer/src/pages/memory/settings-modal.tsx
Normal file
126
src/renderer/src/pages/memory/settings-modal.tsx
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { isEmbeddingModel, isRerankModel } from '@renderer/config/models'
|
||||||
|
import { useProviders } from '@renderer/hooks/useProvider'
|
||||||
|
import { getModelUniqId } from '@renderer/services/ModelService'
|
||||||
|
import { selectMemoryConfig, updateMemoryConfig } from '@renderer/store/memory'
|
||||||
|
import { Form, Input, Modal, Select } from 'antd'
|
||||||
|
import { t } from 'i18next'
|
||||||
|
import { sortBy } from 'lodash'
|
||||||
|
import { FC, useEffect } from 'react'
|
||||||
|
import { useDispatch, useSelector } from 'react-redux'
|
||||||
|
|
||||||
|
interface MemoriesSettingsModalProps {
|
||||||
|
visible: boolean
|
||||||
|
onSubmit: (values: any) => void
|
||||||
|
onCancel: () => void
|
||||||
|
form: any
|
||||||
|
}
|
||||||
|
|
||||||
|
const MemoriesSettingsModal: FC<MemoriesSettingsModalProps> = ({ visible, onSubmit, onCancel, form }) => {
|
||||||
|
const { providers } = useProviders()
|
||||||
|
const dispatch = useDispatch()
|
||||||
|
const memoryConfig = useSelector(selectMemoryConfig)
|
||||||
|
|
||||||
|
// Get all models for lookup
|
||||||
|
const allModels = providers.flatMap((p) => p.models)
|
||||||
|
|
||||||
|
// Check if embedding settings were previously configured
|
||||||
|
const isEmbeddingConfigured = memoryConfig?.embedderModel !== undefined
|
||||||
|
|
||||||
|
// Initialize form with current memory config when modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible && memoryConfig) {
|
||||||
|
form.setFieldsValue({
|
||||||
|
llmModel: memoryConfig.llmModel ? getModelUniqId(memoryConfig.llmModel) : undefined,
|
||||||
|
embedderModel: memoryConfig.embedderModel ? getModelUniqId(memoryConfig.embedderModel) : undefined,
|
||||||
|
embedderDimensions: memoryConfig.embedderDimensions
|
||||||
|
// customFactExtractionPrompt: memoryConfig.customFactExtractionPrompt,
|
||||||
|
// customUpdateMemoryPrompt: memoryConfig.customUpdateMemoryPrompt
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [visible, memoryConfig, form])
|
||||||
|
|
||||||
|
const handleFormSubmit = (values: any) => {
|
||||||
|
// Convert model IDs back to Model objects
|
||||||
|
const llmModel = values.llmModel ? allModels.find((m) => getModelUniqId(m) === values.llmModel) : undefined
|
||||||
|
const embedderModel = values.embedderModel
|
||||||
|
? allModels.find((m) => getModelUniqId(m) === values.embedderModel)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const updatedConfig = {
|
||||||
|
...memoryConfig,
|
||||||
|
llmModel,
|
||||||
|
embedderModel,
|
||||||
|
embedderDimensions: values.embedderDimensions
|
||||||
|
// customFactExtractionPrompt: values.customFactExtractionPrompt,
|
||||||
|
// customUpdateMemoryPrompt: values.customUpdateMemoryPrompt
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(updateMemoryConfig(updatedConfig))
|
||||||
|
onSubmit(updatedConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
const llmSelectOptions = providers
|
||||||
|
.filter((p) => p.models.length > 0)
|
||||||
|
.map((p) => ({
|
||||||
|
label: p.isSystem ? t(`provider.${p.id}`) : p.name,
|
||||||
|
title: p.name,
|
||||||
|
options: sortBy(p.models, 'name')
|
||||||
|
.filter((model) => !isEmbeddingModel(model) && p.type === 'openai')
|
||||||
|
.map((m) => ({
|
||||||
|
label: m.name,
|
||||||
|
value: getModelUniqId(m)
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
.filter((group) => group.options.length > 0)
|
||||||
|
|
||||||
|
const embeddingSelectOptions = providers
|
||||||
|
.filter((p) => p.models.length > 0)
|
||||||
|
.map((p) => ({
|
||||||
|
label: p.isSystem ? t(`provider.${p.id}`) : p.name,
|
||||||
|
title: p.name,
|
||||||
|
options: sortBy(p.models, 'name')
|
||||||
|
.filter((model) => isEmbeddingModel(model) && !isRerankModel(model))
|
||||||
|
.map((m) => ({
|
||||||
|
label: m.name,
|
||||||
|
value: getModelUniqId(m)
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
.filter((group) => group.options.length > 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title="Memory Settings" open={visible} onOk={form.submit} onCancel={onCancel} width={600}>
|
||||||
|
<Form form={form} layout="vertical" onFinish={handleFormSubmit}>
|
||||||
|
<Form.Item
|
||||||
|
label="LLM Model"
|
||||||
|
name="llmModel"
|
||||||
|
rules={[{ required: true, message: 'Please select an LLM model' }]}>
|
||||||
|
<Select placeholder="Select LLM Model" options={llmSelectOptions} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="Embedding Model"
|
||||||
|
name="embedderModel"
|
||||||
|
rules={[{ required: true, message: 'Please select an embedding model' }]}>
|
||||||
|
<Select
|
||||||
|
placeholder="Select Embedding Model"
|
||||||
|
options={embeddingSelectOptions}
|
||||||
|
disabled={isEmbeddingConfigured}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="Embedding Dimensions"
|
||||||
|
name="embedderDimensions"
|
||||||
|
rules={[{ required: true, message: 'Please enter embedding dimensions' }]}>
|
||||||
|
<Input type="number" placeholder="1536" disabled={isEmbeddingConfigured} />
|
||||||
|
</Form.Item>
|
||||||
|
{/* <Form.Item label="Custom Fact Extraction Prompt" name="customFactExtractionPrompt">
|
||||||
|
<Input.TextArea placeholder="Optional custom prompt for fact extraction..." rows={3} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="Custom Update Memory Prompt" name="customUpdateMemoryPrompt">
|
||||||
|
<Input.TextArea placeholder="Optional custom prompt for memory updates..." rows={3} />
|
||||||
|
</Form.Item> */}
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MemoriesSettingsModal
|
||||||
@@ -16,7 +16,7 @@ import FileManager from '@renderer/services/FileManager'
|
|||||||
import { translateText } from '@renderer/services/TranslateService'
|
import { translateText } from '@renderer/services/TranslateService'
|
||||||
import { useAppDispatch } from '@renderer/store'
|
import { useAppDispatch } from '@renderer/store'
|
||||||
import { setGenerating } from '@renderer/store/runtime'
|
import { setGenerating } from '@renderer/store/runtime'
|
||||||
import type { FileType } from '@renderer/types'
|
import type { FileMetadata } from '@renderer/types'
|
||||||
import type { PaintingAction, PaintingsState } from '@renderer/types'
|
import type { PaintingAction, PaintingsState } from '@renderer/types'
|
||||||
import { getErrorMessage, uuid } from '@renderer/utils'
|
import { getErrorMessage, uuid } from '@renderer/utils'
|
||||||
import { Avatar, Button, Input, InputNumber, Radio, Segmented, Select, Slider, Switch, Tooltip, Upload } from 'antd'
|
import { Avatar, Button, Input, InputNumber, Radio, Segmented, Select, Slider, Switch, Tooltip, Upload } from 'antd'
|
||||||
@@ -47,7 +47,7 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => {
|
|||||||
const [abortController, setAbortController] = useState<AbortController | null>(null)
|
const [abortController, setAbortController] = useState<AbortController | null>(null)
|
||||||
const [spaceClickCount, setSpaceClickCount] = useState(0)
|
const [spaceClickCount, setSpaceClickCount] = useState(0)
|
||||||
const [isTranslating, setIsTranslating] = useState(false)
|
const [isTranslating, setIsTranslating] = useState(false)
|
||||||
const [fileMap, setFileMap] = useState<{ [key: string]: FileType }>({})
|
const [fileMap, setFileMap] = useState<{ [key: string]: FileMetadata }>({})
|
||||||
|
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
@@ -127,7 +127,7 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
return downloadedFiles.filter((file): file is FileType => file !== null)
|
return downloadedFiles.filter((file): file is FileMetadata => file !== null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onGenerate = async () => {
|
const onGenerate = async () => {
|
||||||
@@ -722,7 +722,7 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => {
|
|||||||
listType="picture-card"
|
listType="picture-card"
|
||||||
beforeUpload={(file) => {
|
beforeUpload={(file) => {
|
||||||
const path = URL.createObjectURL(file)
|
const path = URL.createObjectURL(file)
|
||||||
setFileMap({ ...fileMap, [path]: file as unknown as FileType })
|
setFileMap({ ...fileMap, [path]: file as unknown as FileMetadata })
|
||||||
updatePaintingState({ [item.key!]: path })
|
updatePaintingState({ [item.key!]: path })
|
||||||
return false // 阻止默认上传行为
|
return false // 阻止默认上传行为
|
||||||
}}>
|
}}>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { useRuntime } from '@renderer/hooks/useRuntime'
|
|||||||
import FileManager from '@renderer/services/FileManager'
|
import FileManager from '@renderer/services/FileManager'
|
||||||
import { useAppDispatch } from '@renderer/store'
|
import { useAppDispatch } from '@renderer/store'
|
||||||
import { setGenerating } from '@renderer/store/runtime'
|
import { setGenerating } from '@renderer/store/runtime'
|
||||||
import type { FileType, PaintingsState } from '@renderer/types'
|
import type { FileMetadata, PaintingsState } from '@renderer/types'
|
||||||
import { uuid } from '@renderer/utils'
|
import { uuid } from '@renderer/utils'
|
||||||
import { DmxapiPainting } from '@types'
|
import { DmxapiPainting } from '@types'
|
||||||
import { Avatar, Button, Input, Radio, Segmented, Select, Switch, Tooltip } from 'antd'
|
import { Avatar, Button, Input, Radio, Segmented, Select, Switch, Tooltip } from 'antd'
|
||||||
@@ -70,7 +70,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
|
|||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
|
||||||
interface FileMapType {
|
interface FileMapType {
|
||||||
imageFiles?: FileType[]
|
imageFiles?: FileMetadata[]
|
||||||
paths?: string[]
|
paths?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,19 +195,19 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
|
|||||||
const currentFiles = prevFileMap.imageFiles || []
|
const currentFiles = prevFileMap.imageFiles || []
|
||||||
const currentPaths = prevFileMap.paths || []
|
const currentPaths = prevFileMap.paths || []
|
||||||
|
|
||||||
let newFiles: FileType[]
|
let newFiles: FileMetadata[]
|
||||||
let newPaths: string[]
|
let newPaths: string[]
|
||||||
|
|
||||||
if (index !== undefined) {
|
if (index !== undefined) {
|
||||||
// 替换指定索引的图片
|
// 替换指定索引的图片
|
||||||
newFiles = [...currentFiles]
|
newFiles = [...currentFiles]
|
||||||
newFiles[index] = file as FileType
|
newFiles[index] = file as FileMetadata
|
||||||
|
|
||||||
newPaths = [...currentPaths]
|
newPaths = [...currentPaths]
|
||||||
newPaths[index] = path
|
newPaths[index] = path
|
||||||
} else {
|
} else {
|
||||||
// 添加新图片到最后
|
// 添加新图片到最后
|
||||||
newFiles = [...currentFiles, file as FileType]
|
newFiles = [...currentFiles, file as FileMetadata]
|
||||||
newPaths = [...currentPaths, path]
|
newPaths = [...currentPaths, path]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -465,7 +465,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
|
|||||||
// 下载图像
|
// 下载图像
|
||||||
if (urls.length > 0) {
|
if (urls.length > 0) {
|
||||||
const downloadedFiles = await downloadImages(urls)
|
const downloadedFiles = await downloadImages(urls)
|
||||||
const validFiles = downloadedFiles.filter((file): file is FileType => file !== null)
|
const validFiles = downloadedFiles.filter((file): file is FileMetadata => file !== null)
|
||||||
|
|
||||||
if (validFiles?.length > 0) {
|
if (validFiles?.length > 0) {
|
||||||
if (painting.autoCreate && painting.files.length > 0) {
|
if (painting.autoCreate && painting.files.length > 0) {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import FileManager from '@renderer/services/FileManager'
|
|||||||
import { translateText } from '@renderer/services/TranslateService'
|
import { translateText } from '@renderer/services/TranslateService'
|
||||||
import { useAppDispatch } from '@renderer/store'
|
import { useAppDispatch } from '@renderer/store'
|
||||||
import { setGenerating } from '@renderer/store/runtime'
|
import { setGenerating } from '@renderer/store/runtime'
|
||||||
import type { FileType, Painting } from '@renderer/types'
|
import type { FileMetadata, Painting } from '@renderer/types'
|
||||||
import { getErrorMessage, uuid } from '@renderer/utils'
|
import { getErrorMessage, uuid } from '@renderer/utils'
|
||||||
import { Button, Input, InputNumber, Radio, Select, Slider, Switch, Tooltip } from 'antd'
|
import { Button, Input, InputNumber, Radio, Select, Slider, Switch, Tooltip } from 'antd'
|
||||||
import TextArea from 'antd/es/input/TextArea'
|
import TextArea from 'antd/es/input/TextArea'
|
||||||
@@ -229,7 +229,7 @@ const SiliconPage: FC<{ Options: string[] }> = ({ Options }) => {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
const validFiles = downloadedFiles.filter((file): file is FileType => file !== null)
|
const validFiles = downloadedFiles.filter((file): file is FileMetadata => file !== null)
|
||||||
|
|
||||||
await FileManager.addFiles(validFiles)
|
await FileManager.addFiles(validFiles)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { DeleteOutlined } from '@ant-design/icons'
|
import { DeleteOutlined } from '@ant-design/icons'
|
||||||
import IcImageUp from '@renderer/assets/images/paintings/ic_ImageUp.svg'
|
import IcImageUp from '@renderer/assets/images/paintings/ic_ImageUp.svg'
|
||||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||||
import type { FileType } from '@renderer/types'
|
import { FileMetadata } from '@renderer/types'
|
||||||
import { Popconfirm, Upload } from 'antd'
|
import { Popconfirm, Upload } from 'antd'
|
||||||
import { Button } from 'antd'
|
import { Button } from 'antd'
|
||||||
import type { RcFile, UploadProps } from 'antd/es/upload'
|
import type { RcFile, UploadProps } from 'antd/es/upload'
|
||||||
@@ -10,7 +10,7 @@ import styled from 'styled-components'
|
|||||||
|
|
||||||
interface ImageUploaderProps {
|
interface ImageUploaderProps {
|
||||||
fileMap: {
|
fileMap: {
|
||||||
imageFiles?: FileType[]
|
imageFiles?: FileMetadata[]
|
||||||
paths?: string[]
|
paths?: string[]
|
||||||
}
|
}
|
||||||
maxImages: number
|
maxImages: number
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { CacheService } from '@renderer/services/CacheService'
|
import { CacheService } from '@renderer/services/CacheService'
|
||||||
import { FileType, TokenFluxPainting } from '@renderer/types'
|
import { FileMetadata, TokenFluxPainting } from '@renderer/types'
|
||||||
|
|
||||||
import type { TokenFluxModel } from '../config/tokenFluxConfig'
|
import type { TokenFluxModel } from '../config/tokenFluxConfig'
|
||||||
|
|
||||||
@@ -230,7 +230,7 @@ export class TokenFluxService {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
return downloadedFiles.filter((file): file is FileType => file !== null)
|
return downloadedFiles.filter((file): file is FileMetadata => file !== null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { InfoCircleOutlined, SettingOutlined } from '@ant-design/icons'
|
||||||
|
import { Box } from '@renderer/components/Layout'
|
||||||
|
import MemoryService from '@renderer/services/MemoryService'
|
||||||
|
import { selectGlobalMemoryEnabled, selectMemoryConfig } from '@renderer/store/memory'
|
||||||
|
import { Assistant, AssistantSettings } from '@renderer/types'
|
||||||
|
import { Alert, Button, Card, Space, Switch, Tooltip, Typography } from 'antd'
|
||||||
|
import { useForm } from 'antd/es/form/Form'
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useSelector } from 'react-redux'
|
||||||
|
import styled from 'styled-components'
|
||||||
|
|
||||||
|
import MemoriesSettingsModal from '../../memory/settings-modal'
|
||||||
|
|
||||||
|
const { Text } = Typography
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
assistant: Assistant
|
||||||
|
updateAssistant: (assistant: Assistant) => void
|
||||||
|
updateAssistantSettings: (settings: AssistantSettings) => void
|
||||||
|
onClose?: () => void // Add optional close callback
|
||||||
|
}
|
||||||
|
|
||||||
|
const AssistantMemorySettings: React.FC<Props> = ({ assistant, updateAssistant, onClose }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const memoryConfig = useSelector(selectMemoryConfig)
|
||||||
|
const globalMemoryEnabled = useSelector(selectGlobalMemoryEnabled)
|
||||||
|
const [memoryStats, setMemoryStats] = useState<{ count: number; loading: boolean }>({
|
||||||
|
count: 0,
|
||||||
|
loading: true
|
||||||
|
})
|
||||||
|
const [settingsModalVisible, setSettingsModalVisible] = useState(false)
|
||||||
|
const memoryService = MemoryService.getInstance()
|
||||||
|
const form = useForm()
|
||||||
|
|
||||||
|
// Load memory statistics for this assistant
|
||||||
|
const loadMemoryStats = useCallback(async () => {
|
||||||
|
setMemoryStats((prev) => ({ ...prev, loading: true }))
|
||||||
|
try {
|
||||||
|
const result = await memoryService.list({
|
||||||
|
agentId: assistant.id,
|
||||||
|
limit: 1000
|
||||||
|
})
|
||||||
|
setMemoryStats({ count: result.results.length, loading: false })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load memory stats:', error)
|
||||||
|
setMemoryStats({ count: 0, loading: false })
|
||||||
|
}
|
||||||
|
}, [assistant.id, memoryService])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadMemoryStats()
|
||||||
|
}, [loadMemoryStats])
|
||||||
|
|
||||||
|
const handleMemoryToggle = (enabled: boolean) => {
|
||||||
|
updateAssistant({ ...assistant, enableMemory: enabled })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNavigateToMemory = () => {
|
||||||
|
// Close current modal/page first
|
||||||
|
if (onClose) {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
// Then navigate to memory page
|
||||||
|
window.location.hash = '#/memory'
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMemoryConfigured = memoryConfig.embedderModel && memoryConfig.llmModel
|
||||||
|
const isMemoryEnabled = globalMemoryEnabled && isMemoryConfigured
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<HeaderContainer>
|
||||||
|
<Box style={{ fontWeight: 'bold', fontSize: '14px' }}>
|
||||||
|
{t('memory.title', 'Memory')}
|
||||||
|
<Tooltip
|
||||||
|
title={t(
|
||||||
|
'memory.description',
|
||||||
|
'Enable memory to help the assistant remember facts and context from conversations'
|
||||||
|
)}>
|
||||||
|
<InfoIcon />
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
<Space>
|
||||||
|
<Button size="small" icon={<SettingOutlined />} onClick={handleNavigateToMemory}>
|
||||||
|
{t('common.settings')}
|
||||||
|
</Button>
|
||||||
|
<Tooltip
|
||||||
|
title={
|
||||||
|
!globalMemoryEnabled
|
||||||
|
? t('memory.enable_global_memory_first', 'Please enable global memory in the Memory page first')
|
||||||
|
: !isMemoryConfigured
|
||||||
|
? t('memory.configure_memory_first', 'Please configure memory models first')
|
||||||
|
: ''
|
||||||
|
}>
|
||||||
|
<Switch
|
||||||
|
checked={assistant.enableMemory || false}
|
||||||
|
onChange={handleMemoryToggle}
|
||||||
|
disabled={!isMemoryEnabled}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</Space>
|
||||||
|
</HeaderContainer>
|
||||||
|
|
||||||
|
{!globalMemoryEnabled && (
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
message={t('memory.global_memory_disabled_title', 'Global Memory Disabled')}
|
||||||
|
description={t(
|
||||||
|
'memory.global_memory_disabled_desc',
|
||||||
|
'Global memory is currently disabled. Please enable it in the Memory page to use memory functionality.'
|
||||||
|
)}
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
action={
|
||||||
|
<Button size="small" onClick={handleNavigateToMemory}>
|
||||||
|
{t('memory.go_to_memory_page', 'Go to Memory Page')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{globalMemoryEnabled && !isMemoryConfigured && (
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
message={t('memory.not_configured_title', 'Memory Not Configured')}
|
||||||
|
description={t(
|
||||||
|
'memory.not_configured_desc',
|
||||||
|
'Please configure embedding and LLM models in memory settings to enable memory functionality.'
|
||||||
|
)}
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card size="small" style={{ marginBottom: 16 }}>
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }}>
|
||||||
|
<div>
|
||||||
|
<Text strong>{t('memory.stored_memories', 'Stored Memories')}: </Text>
|
||||||
|
<Text>{memoryStats.loading ? t('common.loading') : memoryStats.count}</Text>
|
||||||
|
</div>
|
||||||
|
{memoryConfig.embedderModel && (
|
||||||
|
<div>
|
||||||
|
<Text strong>{t('memory.embedding_model', 'Embedding Model')}: </Text>
|
||||||
|
<Text code>{memoryConfig.embedderModel.name}</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{memoryConfig.llmModel && (
|
||||||
|
<div>
|
||||||
|
<Text strong>{t('memory.llm_model', 'LLM Model')}: </Text>
|
||||||
|
<Text code>{memoryConfig.llmModel.name}</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<MemoriesSettingsModal
|
||||||
|
visible={settingsModalVisible}
|
||||||
|
onSubmit={() => setSettingsModalVisible(false)}
|
||||||
|
onCancel={() => setSettingsModalVisible(false)}
|
||||||
|
form={form}
|
||||||
|
/>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const Container = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
`
|
||||||
|
|
||||||
|
const HeaderContainer = styled.div`
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
`
|
||||||
|
|
||||||
|
const InfoIcon = styled(InfoCircleOutlined)`
|
||||||
|
margin-left: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-text-2);
|
||||||
|
cursor: help;
|
||||||
|
`
|
||||||
|
|
||||||
|
export default AssistantMemorySettings
|
||||||
@@ -11,6 +11,7 @@ import styled from 'styled-components'
|
|||||||
|
|
||||||
import AssistantKnowledgeBaseSettings from './AssistantKnowledgeBaseSettings'
|
import AssistantKnowledgeBaseSettings from './AssistantKnowledgeBaseSettings'
|
||||||
import AssistantMCPSettings from './AssistantMCPSettings'
|
import AssistantMCPSettings from './AssistantMCPSettings'
|
||||||
|
import AssistantMemorySettings from './AssistantMemorySettings'
|
||||||
import AssistantModelSettings from './AssistantModelSettings'
|
import AssistantModelSettings from './AssistantModelSettings'
|
||||||
import AssistantPromptSettings from './AssistantPromptSettings'
|
import AssistantPromptSettings from './AssistantPromptSettings'
|
||||||
import AssistantRegularPromptsSettings from './AssistantRegularPromptsSettings'
|
import AssistantRegularPromptsSettings from './AssistantRegularPromptsSettings'
|
||||||
@@ -20,7 +21,14 @@ interface AssistantSettingPopupShowParams {
|
|||||||
tab?: AssistantSettingPopupTab
|
tab?: AssistantSettingPopupTab
|
||||||
}
|
}
|
||||||
|
|
||||||
type AssistantSettingPopupTab = 'prompt' | 'model' | 'messages' | 'knowledge_base' | 'mcp' | 'regular_phrases'
|
type AssistantSettingPopupTab =
|
||||||
|
| 'prompt'
|
||||||
|
| 'model'
|
||||||
|
| 'messages'
|
||||||
|
| 'knowledge_base'
|
||||||
|
| 'mcp'
|
||||||
|
| 'regular_phrases'
|
||||||
|
| 'memory'
|
||||||
|
|
||||||
interface Props extends AssistantSettingPopupShowParams {
|
interface Props extends AssistantSettingPopupShowParams {
|
||||||
resolve: (assistant: Assistant) => void
|
resolve: (assistant: Assistant) => void
|
||||||
@@ -73,6 +81,10 @@ const AssistantSettingPopupContainer: React.FC<Props> = ({ resolve, tab, ...prop
|
|||||||
{
|
{
|
||||||
key: 'regular_phrases',
|
key: 'regular_phrases',
|
||||||
label: t('assistants.settings.regular_phrases.title', 'Regular Prompts')
|
label: t('assistants.settings.regular_phrases.title', 'Regular Prompts')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'memory',
|
||||||
|
label: t('memory.title', 'Memories')
|
||||||
}
|
}
|
||||||
].filter(Boolean) as { key: string; label: string }[]
|
].filter(Boolean) as { key: string; label: string }[]
|
||||||
|
|
||||||
@@ -140,6 +152,14 @@ const AssistantSettingPopupContainer: React.FC<Props> = ({ resolve, tab, ...prop
|
|||||||
{menu === 'regular_phrases' && (
|
{menu === 'regular_phrases' && (
|
||||||
<AssistantRegularPromptsSettings assistant={assistant} updateAssistant={updateAssistant} />
|
<AssistantRegularPromptsSettings assistant={assistant} updateAssistant={updateAssistant} />
|
||||||
)}
|
)}
|
||||||
|
{menu === 'memory' && (
|
||||||
|
<AssistantMemorySettings
|
||||||
|
assistant={assistant}
|
||||||
|
updateAssistant={updateAssistant}
|
||||||
|
updateAssistantSettings={updateAssistantSettings}
|
||||||
|
onClose={onCancel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Settings>
|
</Settings>
|
||||||
</HStack>
|
</HStack>
|
||||||
</StyledModal>
|
</StyledModal>
|
||||||
|
|||||||
@@ -239,10 +239,7 @@ const GeneralSettings: FC = () => {
|
|||||||
<SettingDivider />
|
<SettingDivider />
|
||||||
<SettingRow>
|
<SettingRow>
|
||||||
<SettingRowTitle>{t('settings.notification.knowledge_embed')}</SettingRowTitle>
|
<SettingRowTitle>{t('settings.notification.knowledge_embed')}</SettingRowTitle>
|
||||||
<Switch
|
<Switch checked={notificationSettings.knowledge} onChange={(v) => handleNotificationChange('knowledge', v)} />
|
||||||
checked={notificationSettings.knowledgeEmbed}
|
|
||||||
onChange={(v) => handleNotificationChange('knowledgeEmbed', v)}
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
</SettingGroup>
|
</SettingGroup>
|
||||||
<SettingGroup theme={theme}>
|
<SettingGroup theme={theme}>
|
||||||
|
|||||||
@@ -252,7 +252,7 @@ const GithubCopilotSettings: FC<GithubCopilotSettingsProps> = ({ provider: initi
|
|||||||
min={1}
|
min={1}
|
||||||
max={60}
|
max={60}
|
||||||
step={1}
|
step={1}
|
||||||
marks={{ 1: '1', 10: t('settings.websearch.search_result_default'), 60: '60' }}
|
marks={{ 1: '1', 10: t('settings.tool.websearch.search_result_default'), 60: '60' }}
|
||||||
onChangeComplete={(value) => updateProvider({ ...provider, rateLimit: value })}
|
onChangeComplete={(value) => updateProvider({ ...provider, rateLimit: value })}
|
||||||
/>
|
/>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { isWin } from '@renderer/config/constant'
|
import { isMac, isWin } from '@renderer/config/constant'
|
||||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||||
import { useSelectionAssistant } from '@renderer/hooks/useSelectionAssistant'
|
import { useSelectionAssistant } from '@renderer/hooks/useSelectionAssistant'
|
||||||
import { FilterMode, TriggerMode } from '@renderer/types/selectionTypes'
|
import { FilterMode, TriggerMode } from '@renderer/types/selectionTypes'
|
||||||
@@ -19,8 +19,9 @@ import {
|
|||||||
SettingRowTitle,
|
SettingRowTitle,
|
||||||
SettingTitle
|
SettingTitle
|
||||||
} from '..'
|
} from '..'
|
||||||
import SelectionActionsList from './SelectionActionsList'
|
import MacProcessTrustHintModal from './components/MacProcessTrustHintModal'
|
||||||
import SelectionFilterListModal from './SelectionFilterListModal'
|
import SelectionActionsList from './components/SelectionActionsList'
|
||||||
|
import SelectionFilterListModal from './components/SelectionFilterListModal'
|
||||||
|
|
||||||
const SelectionAssistantSettings: FC = () => {
|
const SelectionAssistantSettings: FC = () => {
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
@@ -49,15 +50,43 @@ const SelectionAssistantSettings: FC = () => {
|
|||||||
setFilterMode,
|
setFilterMode,
|
||||||
setFilterList
|
setFilterList
|
||||||
} = useSelectionAssistant()
|
} = useSelectionAssistant()
|
||||||
|
|
||||||
|
const isSupportedOS = isWin || isMac
|
||||||
|
|
||||||
const [isFilterListModalOpen, setIsFilterListModalOpen] = useState(false)
|
const [isFilterListModalOpen, setIsFilterListModalOpen] = useState(false)
|
||||||
|
const [isMacTrustModalOpen, setIsMacTrustModalOpen] = useState(false)
|
||||||
const [opacityValue, setOpacityValue] = useState(actionWindowOpacity)
|
const [opacityValue, setOpacityValue] = useState(actionWindowOpacity)
|
||||||
|
|
||||||
// force disable selection assistant on non-windows systems
|
// force disable selection assistant on non-windows systems
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isWin && selectionEnabled) {
|
const checkMacProcessTrust = async () => {
|
||||||
setSelectionEnabled(false)
|
const isTrusted = await window.api.mac.isProcessTrusted()
|
||||||
|
if (!isTrusted) {
|
||||||
|
setSelectionEnabled(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [selectionEnabled, setSelectionEnabled])
|
|
||||||
|
if (!isSupportedOS && selectionEnabled) {
|
||||||
|
setSelectionEnabled(false)
|
||||||
|
return
|
||||||
|
} else if (isMac && selectionEnabled) {
|
||||||
|
checkMacProcessTrust()
|
||||||
|
}
|
||||||
|
}, [isSupportedOS, selectionEnabled, setSelectionEnabled])
|
||||||
|
|
||||||
|
const handleEnableCheckboxChange = async (checked: boolean) => {
|
||||||
|
if (!isSupportedOS) return
|
||||||
|
|
||||||
|
if (isMac && checked) {
|
||||||
|
const isTrusted = await window.api.mac.isProcessTrusted()
|
||||||
|
if (!isTrusted) {
|
||||||
|
setIsMacTrustModalOpen(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectionEnabled(checked)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingContainer theme={theme}>
|
<SettingContainer theme={theme}>
|
||||||
@@ -71,18 +100,18 @@ const SelectionAssistantSettings: FC = () => {
|
|||||||
style={{ fontSize: 12 }}>
|
style={{ fontSize: 12 }}>
|
||||||
{'FAQ & ' + t('settings.about.feedback.button')}
|
{'FAQ & ' + t('settings.about.feedback.button')}
|
||||||
</Button>
|
</Button>
|
||||||
<ExperimentalText>{t('selection.settings.experimental')}</ExperimentalText>
|
{isMac && <ExperimentalText>{t('selection.settings.experimental')}</ExperimentalText>}
|
||||||
</Row>
|
</Row>
|
||||||
<SettingDivider />
|
<SettingDivider />
|
||||||
<SettingRow>
|
<SettingRow>
|
||||||
<SettingLabel>
|
<SettingLabel>
|
||||||
<SettingRowTitle>{t('selection.settings.enable.title')}</SettingRowTitle>
|
<SettingRowTitle>{t('selection.settings.enable.title')}</SettingRowTitle>
|
||||||
{!isWin && <SettingDescription>{t('selection.settings.enable.description')}</SettingDescription>}
|
{!isSupportedOS && <SettingDescription>{t('selection.settings.enable.description')}</SettingDescription>}
|
||||||
</SettingLabel>
|
</SettingLabel>
|
||||||
<Switch
|
<Switch
|
||||||
checked={isWin && selectionEnabled}
|
checked={isSupportedOS && selectionEnabled}
|
||||||
onChange={(checked) => setSelectionEnabled(checked)}
|
onChange={(checked) => handleEnableCheckboxChange(checked)}
|
||||||
disabled={!isWin}
|
disabled={!isSupportedOS}
|
||||||
/>
|
/>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
|
|
||||||
@@ -103,7 +132,10 @@ const SelectionAssistantSettings: FC = () => {
|
|||||||
<SettingLabel>
|
<SettingLabel>
|
||||||
<SettingRowTitle>
|
<SettingRowTitle>
|
||||||
<div style={{ marginRight: '4px' }}>{t('selection.settings.toolbar.trigger_mode.title')}</div>
|
<div style={{ marginRight: '4px' }}>{t('selection.settings.toolbar.trigger_mode.title')}</div>
|
||||||
<Tooltip placement="top" title={t('selection.settings.toolbar.trigger_mode.description_note')} arrow>
|
<Tooltip
|
||||||
|
placement="top"
|
||||||
|
title={t(`selection.settings.toolbar.trigger_mode.description_note.${isWin ? 'windows' : 'mac'}`)}
|
||||||
|
arrow>
|
||||||
<QuestionIcon size={14} />
|
<QuestionIcon size={14} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</SettingRowTitle>
|
</SettingRowTitle>
|
||||||
@@ -116,9 +148,11 @@ const SelectionAssistantSettings: FC = () => {
|
|||||||
<Tooltip placement="top" title={t('selection.settings.toolbar.trigger_mode.selected_note')} arrow>
|
<Tooltip placement="top" title={t('selection.settings.toolbar.trigger_mode.selected_note')} arrow>
|
||||||
<Radio.Button value="selected">{t('selection.settings.toolbar.trigger_mode.selected')}</Radio.Button>
|
<Radio.Button value="selected">{t('selection.settings.toolbar.trigger_mode.selected')}</Radio.Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip placement="top" title={t('selection.settings.toolbar.trigger_mode.ctrlkey_note')} arrow>
|
{isWin && (
|
||||||
<Radio.Button value="ctrlkey">{t('selection.settings.toolbar.trigger_mode.ctrlkey')}</Radio.Button>
|
<Tooltip placement="top" title={t('selection.settings.toolbar.trigger_mode.ctrlkey_note')} arrow>
|
||||||
</Tooltip>
|
<Radio.Button value="ctrlkey">{t('selection.settings.toolbar.trigger_mode.ctrlkey')}</Radio.Button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
<Tooltip
|
<Tooltip
|
||||||
placement="topRight"
|
placement="topRight"
|
||||||
title={
|
title={
|
||||||
@@ -256,6 +290,8 @@ const SelectionAssistantSettings: FC = () => {
|
|||||||
</SettingGroup>
|
</SettingGroup>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isMac && <MacProcessTrustHintModal open={isMacTrustModalOpen} onClose={() => setIsMacTrustModalOpen(false)} />}
|
||||||
</SettingContainer>
|
</SettingContainer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { Button, Modal, Typography } from 'antd'
|
||||||
|
import { FC } from 'react'
|
||||||
|
import { Trans, useTranslation } from 'react-i18next'
|
||||||
|
import styled from 'styled-components'
|
||||||
|
|
||||||
|
const { Text, Paragraph } = Typography
|
||||||
|
|
||||||
|
interface MacProcessTrustHintModalProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const MacProcessTrustHintModal: FC<MacProcessTrustHintModalProps> = ({ open, onClose }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const handleOpenAccessibility = () => {
|
||||||
|
window.api.shell.openExternal('x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility')
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
window.api.mac.requestProcessTrust()
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={t('selection.settings.enable.mac_process_trust_hint.title')}
|
||||||
|
open={open}
|
||||||
|
onCancel={onClose}
|
||||||
|
footer={
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', width: '100%' }}>
|
||||||
|
<Button type="link" style={{ color: 'var(--color-text-3)', fontSize: 12 }} onClick={handleOpenAccessibility}>
|
||||||
|
{t('selection.settings.enable.mac_process_trust_hint.button.open_accessibility_settings')}
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" onClick={handleConfirm}>
|
||||||
|
{t('selection.settings.enable.mac_process_trust_hint.button.go_to_settings')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
centered
|
||||||
|
destroyOnClose>
|
||||||
|
<ContentContainer>
|
||||||
|
<Paragraph>
|
||||||
|
<Text>
|
||||||
|
<Trans i18nKey="selection.settings.enable.mac_process_trust_hint.description.0" />
|
||||||
|
</Text>
|
||||||
|
</Paragraph>
|
||||||
|
<Paragraph>
|
||||||
|
<Text>
|
||||||
|
<Trans i18nKey="selection.settings.enable.mac_process_trust_hint.description.1" />
|
||||||
|
</Text>
|
||||||
|
</Paragraph>
|
||||||
|
<Paragraph>
|
||||||
|
<Text>
|
||||||
|
<Trans i18nKey="selection.settings.enable.mac_process_trust_hint.description.2" />
|
||||||
|
</Text>
|
||||||
|
</Paragraph>
|
||||||
|
</ContentContainer>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ContentContainer = styled.div`
|
||||||
|
padding: 16px 0;
|
||||||
|
`
|
||||||
|
|
||||||
|
export default MacProcessTrustHintModal
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user