Compare commits
152 Commits
copilot/ad
...
v1.5.0-rc.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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:
|
||||
node-version: 20
|
||||
|
||||
- name: macos-latest dependencies fix
|
||||
if: matrix.os == 'macos-latest'
|
||||
run: |
|
||||
brew install python-setuptools
|
||||
|
||||
- name: Install corepack
|
||||
run: corepack enable && corepack prepare yarn@4.6.0 --activate
|
||||
|
||||
@@ -79,6 +74,7 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
|
||||
|
||||
- name: Build Mac
|
||||
if: matrix.os == 'macos-latest'
|
||||
@@ -95,6 +91,7 @@ jobs:
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
|
||||
|
||||
- name: Build Windows
|
||||
if: matrix.os == 'windows-latest'
|
||||
@@ -105,6 +102,7 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
|
||||
|
||||
- name: Release
|
||||
uses: ncipollo/release-action@v1
|
||||
|
||||
@@ -11,6 +11,54 @@ index 852f6c4d16f86a7bb8a78bf1ed5a14647a279aa1..60e7f5f16a844541eb1909b215fcda18
|
||||
if (minimumSystemVersion != null) {
|
||||
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
|
||||
index 7924c5b47d01f8dfccccb8f46658015fa66da1f7..1a1588923c3939ae1297b87931ba83f0ebc052d8 100644
|
||||
--- a/out/publish/updateInfoBuilder.js
|
||||
@@ -66,14 +114,14 @@ index e8bd7bb46c8a54b3f55cf3a853ef924195271e01..f956e9f3fe9eb903c78aef3502553b01
|
||||
file: installerPath,
|
||||
updateInfo,
|
||||
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
|
||||
+++ b/out/util/yarn.js
|
||||
@@ -140,6 +140,7 @@ async function rebuild(config, { appDir, projectDir }, options) {
|
||||
arch,
|
||||
platform,
|
||||
buildFromSource,
|
||||
+ ignoreModules: config.excludeReBuildModules || undefined,
|
||||
+ ignoreModules: options.excludeReBuildModules || undefined,
|
||||
projectRootPath: projectDir,
|
||||
mode: config.nativeRebuilder || "sequential",
|
||||
disablePreGypCopy: true,
|
||||
|
||||
@@ -49,6 +49,8 @@ files:
|
||||
- '!node_modules/mammoth/{mammoth.browser.js,mammoth.browser.min.js}'
|
||||
- '!node_modules/selection-hook/prebuilds/**/*' # we rebuild .node, don't use prebuilds
|
||||
- '!**/*.{h,iobj,ipdb,tlog,recipe,vcxproj,vcxproj.filters}' # filter .node build files
|
||||
- '!node_modules/pdfjs-dist/web/**/*'
|
||||
- '!node_modules/pdfjs-dist/legacy/web/*'
|
||||
asarUnpack:
|
||||
- resources/**
|
||||
- '**/*.{metal,exp,lib}'
|
||||
|
||||
@@ -19,7 +19,7 @@ export default defineConfig({
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
external: ['@libsql/client', 'bufferutil', 'utf-8-validate'],
|
||||
external: ['@libsql/client', 'bufferutil', 'utf-8-validate', '@cherrystudio/mac-system-ocr'],
|
||||
output: {
|
||||
// 彻底禁用代码分割 - 返回 null 强制单文件打包
|
||||
manualChunks: undefined,
|
||||
@@ -30,7 +30,7 @@ export default defineConfig({
|
||||
sourcemap: process.env.NODE_ENV === 'development'
|
||||
},
|
||||
optimizeDeps: {
|
||||
noDiscovery: process.env.NODE_ENV === 'development'
|
||||
disabled: 'dev'
|
||||
}
|
||||
},
|
||||
preload: {
|
||||
|
||||
10
package.json
10
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "CherryStudio",
|
||||
"version": "1.4.2",
|
||||
"version": "1.5.0-rc.0",
|
||||
"private": true,
|
||||
"description": "A powerful AI assistant for producer.",
|
||||
"main": "./out/main/index.js",
|
||||
@@ -20,7 +20,7 @@
|
||||
"scripts": {
|
||||
"start": "electron-vite preview",
|
||||
"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:check": "yarn typecheck && yarn check:i18n && yarn test",
|
||||
"build:unpack": "dotenv npm run build && electron-builder --dir",
|
||||
@@ -58,11 +58,13 @@
|
||||
"prepare": "husky"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cherrystudio/pdf-to-img-napi": "^0.0.1",
|
||||
"@libsql/client": "0.14.0",
|
||||
"@libsql/win32-x64-msvc": "^0.4.7",
|
||||
"@strongtz/win32-arm64-msvc": "^0.4.7",
|
||||
"jsdom": "26.1.0",
|
||||
"os-proxy-config": "^1.1.2",
|
||||
"pdfjs-dist": "4.10.38",
|
||||
"selection-hook": "^0.9.23",
|
||||
"turndown": "7.2.0"
|
||||
},
|
||||
@@ -98,6 +100,7 @@
|
||||
"@kangfenmao/keyv-storage": "^0.1.0",
|
||||
"@langchain/community": "^0.3.36",
|
||||
"@langchain/ollama": "^0.2.1",
|
||||
"@mistralai/mistralai": "^1.6.0",
|
||||
"@modelcontextprotocol/sdk": "^1.11.4",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"@notionhq/client": "^2.2.15",
|
||||
@@ -218,6 +221,9 @@
|
||||
"webdav": "^5.8.0",
|
||||
"zipread": "^1.3.3"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@cherrystudio/mac-system-ocr": "^0.2.2"
|
||||
},
|
||||
"resolutions": {
|
||||
"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",
|
||||
|
||||
@@ -106,9 +106,10 @@ export enum IpcChannel {
|
||||
File_Clear = 'file:clear',
|
||||
File_Read = 'file:read',
|
||||
File_Delete = 'file:delete',
|
||||
File_DeleteDir = 'file:deleteDir',
|
||||
File_Get = 'file:get',
|
||||
File_SelectFolder = 'file:selectFolder',
|
||||
File_Create = 'file:create',
|
||||
File_CreateTempFile = 'file:createTempFile',
|
||||
File_Write = 'file:write',
|
||||
File_WriteWithId = 'file:writeWithId',
|
||||
File_SaveImage = 'file:saveImage',
|
||||
@@ -120,6 +121,12 @@ export enum IpcChannel {
|
||||
File_Base64File = 'file:base64File',
|
||||
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',
|
||||
|
||||
Shortcuts_Update = 'shortcuts:update',
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { ProcessingStatus } from '@types'
|
||||
|
||||
export type LoaderReturn = {
|
||||
entriesAdded: number
|
||||
uniqueId: string
|
||||
uniqueIds: string[]
|
||||
loaderType: string
|
||||
status?: ProcessingStatus
|
||||
message?: string
|
||||
messageSource?: 'preprocess' | 'embedding'
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ const fs = require('fs')
|
||||
const path = require('path')
|
||||
const os = require('os')
|
||||
const { execSync } = require('child_process')
|
||||
const AdmZip = require('adm-zip')
|
||||
const { downloadWithRedirects } = require('./download')
|
||||
const { StreamZipAsync } = require('node-stream-zip')
|
||||
|
||||
// Base URL for downloading bun binaries
|
||||
const BUN_RELEASE_BASE_URL = 'https://gitcode.com/CherryHQ/bun/releases/download'
|
||||
@@ -64,10 +64,14 @@ async function downloadBunBinary(platform, arch, version = DEFAULT_BUN_VERSION,
|
||||
// Use the new download function
|
||||
await downloadWithRedirects(downloadUrl, tempFilename)
|
||||
|
||||
// Extract the zip file using adm-zip
|
||||
// Extract the zip file using node-stream-zip
|
||||
console.log(`Extracting ${packageName} to ${binDir}...`)
|
||||
const zip = new AdmZip(tempFilename)
|
||||
zip.extractAllTo(tempdir, true)
|
||||
const zip = new StreamZipAsync(tempFilename)
|
||||
zip.extract(null, tempdir, (err) => {
|
||||
if (err) {
|
||||
throw new Error(`Failed to extract file: ${err}`)
|
||||
}
|
||||
})
|
||||
|
||||
// Move files using Node.js fs
|
||||
const sourceDir = path.join(tempdir, packageName.split('.')[0])
|
||||
|
||||
@@ -3,8 +3,8 @@ const path = require('path')
|
||||
const os = require('os')
|
||||
const { execSync } = require('child_process')
|
||||
const tar = require('tar')
|
||||
const AdmZip = require('adm-zip')
|
||||
const { downloadWithRedirects } = require('./download')
|
||||
const { StreamZipAsync } = require('node-stream-zip')
|
||||
|
||||
// Base URL for downloading uv binaries
|
||||
const UV_RELEASE_BASE_URL = 'https://gitcode.com/CherryHQ/uv/releases/download'
|
||||
@@ -68,9 +68,13 @@ async function downloadUvBinary(platform, arch, version = DEFAULT_UV_VERSION, is
|
||||
|
||||
// 根据文件扩展名选择解压方法
|
||||
if (packageName.endsWith('.zip')) {
|
||||
// 使用 adm-zip 处理 zip 文件
|
||||
const zip = new AdmZip(tempFilename)
|
||||
zip.extractAllTo(binDir, true)
|
||||
// 使用 node-stream-zip 处理 zip 文件
|
||||
const zip = new StreamZipAsync(tempFilename)
|
||||
zip.extract(null, binDir, (err) => {
|
||||
if (err) {
|
||||
throw new Error(`Failed to extract file: ${err}`)
|
||||
}
|
||||
})
|
||||
fs.unlinkSync(tempFilename)
|
||||
console.log(`Successfully installed uv ${version} for ${platform}-${arch}`)
|
||||
return true
|
||||
|
||||
@@ -23,6 +23,9 @@ exports.default = async function (context) {
|
||||
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']
|
||||
keepPackageNodeFiles(node_modules_path, '@libsql', _arch)
|
||||
|
||||
// 删除 macOS 专用的 OCR 包
|
||||
removeMacOnlyPackages(node_modules_path)
|
||||
}
|
||||
|
||||
if (platform === 'windows') {
|
||||
@@ -35,6 +38,8 @@ exports.default = async function (context) {
|
||||
keepPackageNodeFiles(node_modules_path, '@strongtz', ['win32-x64-msvc'])
|
||||
keepPackageNodeFiles(node_modules_path, '@libsql', ['win32-x64-msvc'])
|
||||
}
|
||||
|
||||
removeMacOnlyPackages(node_modules_path)
|
||||
}
|
||||
|
||||
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 文件
|
||||
* @param {*} nodeModulesPath
|
||||
|
||||
@@ -6,7 +6,7 @@ import { getBinaryPath, isBinaryExists, runInstallScript } from '@main/utils/pro
|
||||
import { handleZoomFactor } from '@main/utils/zoom'
|
||||
import { FeedUrl } from '@shared/config/constant'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { Shortcut, ThemeMode } from '@types'
|
||||
import { FileMetadata, Provider, Shortcut, ThemeMode } from '@types'
|
||||
import { BrowserWindow, ipcMain, session, shell } from 'electron'
|
||||
import log from 'electron-log'
|
||||
import { Notification } from 'src/renderer/src/types/notification'
|
||||
@@ -16,14 +16,15 @@ import BackupManager from './services/BackupManager'
|
||||
import { configManager } from './services/ConfigManager'
|
||||
import CopilotService from './services/CopilotService'
|
||||
import { ExportService } from './services/ExportService'
|
||||
import FileService from './services/FileService'
|
||||
import FileStorage from './services/FileStorage'
|
||||
import FileService from './services/FileSystemService'
|
||||
import KnowledgeService from './services/KnowledgeService'
|
||||
import mcpService from './services/MCPService'
|
||||
import NotificationService from './services/NotificationService'
|
||||
import * as NutstoreService from './services/NutstoreService'
|
||||
import ObsidianVaultService from './services/ObsidianVaultService'
|
||||
import { ProxyConfig, proxyManager } from './services/ProxyManager'
|
||||
import { FileServiceManager } from './services/remotefile/FileServiceManager'
|
||||
import { searchService } from './services/SearchService'
|
||||
import { SelectionService } from './services/SelectionService'
|
||||
import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService'
|
||||
@@ -217,9 +218,10 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
ipcMain.handle(IpcChannel.File_Clear, fileManager.clear)
|
||||
ipcMain.handle(IpcChannel.File_Read, fileManager.readFile)
|
||||
ipcMain.handle(IpcChannel.File_Delete, fileManager.deleteFile)
|
||||
ipcMain.handle('file:deleteDir', fileManager.deleteDir)
|
||||
ipcMain.handle(IpcChannel.File_Get, fileManager.getFile)
|
||||
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_WriteWithId, fileManager.writeFileWithId)
|
||||
ipcMain.handle(IpcChannel.File_SaveImage, fileManager.saveImage)
|
||||
@@ -230,6 +232,27 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
ipcMain.handle(IpcChannel.File_Copy, fileManager.copyFile)
|
||||
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
|
||||
ipcMain.handle(IpcChannel.Fs_Read, FileService.readFile)
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { JsonLoader, LocalPathLoader, RAGApplication, TextLoader } from '@cherry
|
||||
import type { AddLoaderReturn } from '@cherrystudio/embedjs-interfaces'
|
||||
import { WebLoader } from '@cherrystudio/embedjs-loader-web'
|
||||
import { LoaderReturn } from '@shared/config/types'
|
||||
import { FileType, KnowledgeBaseParams } from '@types'
|
||||
import { FileMetadata, KnowledgeBaseParams } from '@types'
|
||||
import Logger from 'electron-log'
|
||||
|
||||
import { DraftsExportLoader } from './draftsExportLoader'
|
||||
@@ -38,7 +38,7 @@ const FILE_LOADER_MAP: Record<string, string> = {
|
||||
|
||||
export async function addOdLoader(
|
||||
ragApplication: RAGApplication,
|
||||
file: FileType,
|
||||
file: FileMetadata,
|
||||
base: KnowledgeBaseParams,
|
||||
forceReload: boolean
|
||||
): Promise<AddLoaderReturn> {
|
||||
@@ -64,7 +64,7 @@ export async function addOdLoader(
|
||||
|
||||
export async function addFileLoader(
|
||||
ragApplication: RAGApplication,
|
||||
file: FileType,
|
||||
file: FileMetadata,
|
||||
base: KnowledgeBaseParams,
|
||||
forceReload: boolean
|
||||
): 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.')
|
||||
}
|
||||
}
|
||||
26
src/main/preprocess/PreprocessProvider.ts
Normal file
26
src/main/preprocess/PreprocessProvider.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查文件是否已经被预处理过
|
||||
* @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 { documentExts, imageExts, MB } from '@shared/config/constant'
|
||||
import { FileType } from '@types'
|
||||
import { FileMetadata } from '@types'
|
||||
import * as crypto from 'crypto'
|
||||
import {
|
||||
dialog,
|
||||
@@ -51,8 +51,9 @@ class FileStorage {
|
||||
})
|
||||
}
|
||||
|
||||
findDuplicateFile = async (filePath: string): Promise<FileType | null> => {
|
||||
findDuplicateFile = async (filePath: string): Promise<FileMetadata | null> => {
|
||||
const stats = fs.statSync(filePath)
|
||||
console.log('stats', stats, filePath)
|
||||
const fileSize = stats.size
|
||||
|
||||
const files = await fs.promises.readdir(this.storageDir)
|
||||
@@ -90,7 +91,7 @@ class FileStorage {
|
||||
public selectFile = async (
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
options?: OpenDialogOptions
|
||||
): Promise<FileType[] | null> => {
|
||||
): Promise<FileMetadata[] | null> => {
|
||||
const defaultOptions: OpenDialogOptions = {
|
||||
properties: ['openFile']
|
||||
}
|
||||
@@ -149,7 +150,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)
|
||||
|
||||
if (duplicateFile) {
|
||||
@@ -173,7 +174,7 @@ class FileStorage {
|
||||
const stats = await fs.promises.stat(destPath)
|
||||
const fileType = getFileType(ext)
|
||||
|
||||
const fileMetadata: FileType = {
|
||||
const fileMetadata: FileMetadata = {
|
||||
id: uuid,
|
||||
origin_name,
|
||||
name: uuid + ext,
|
||||
@@ -188,7 +189,7 @@ class FileStorage {
|
||||
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)) {
|
||||
return null
|
||||
}
|
||||
@@ -197,7 +198,7 @@ class FileStorage {
|
||||
const ext = path.extname(filePath)
|
||||
const fileType = getFileType(ext)
|
||||
|
||||
const fileInfo: FileType = {
|
||||
const fileInfo: FileMetadata = {
|
||||
id: uuidv4(),
|
||||
origin_name: path.basename(filePath),
|
||||
name: path.basename(filePath),
|
||||
@@ -213,9 +214,19 @@ class FileStorage {
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
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> => {
|
||||
const filePath = path.join(this.storageDir, id)
|
||||
|
||||
@@ -240,8 +251,8 @@ class FileStorage {
|
||||
if (!fs.existsSync(this.tempDir)) {
|
||||
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 (
|
||||
@@ -268,7 +279,7 @@ class FileStorage {
|
||||
}
|
||||
}
|
||||
|
||||
public saveBase64Image = async (_: Electron.IpcMainInvokeEvent, base64Data: string): Promise<FileType> => {
|
||||
public saveBase64Image = async (_: Electron.IpcMainInvokeEvent, base64Data: string): Promise<FileMetadata> => {
|
||||
try {
|
||||
if (!base64Data) {
|
||||
throw new Error('Base64 data is required')
|
||||
@@ -294,7 +305,7 @@ class FileStorage {
|
||||
|
||||
await fs.promises.writeFile(destPath, buffer)
|
||||
|
||||
const fileMetadata: FileType = {
|
||||
const fileMetadata: FileMetadata = {
|
||||
id: uuid,
|
||||
origin_name: uuid + ext,
|
||||
name: uuid + ext,
|
||||
@@ -435,7 +446,7 @@ class FileStorage {
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
url: string,
|
||||
isUseContentType?: boolean
|
||||
): Promise<FileType> => {
|
||||
): Promise<FileMetadata> => {
|
||||
try {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
@@ -477,7 +488,7 @@ class FileStorage {
|
||||
const stats = await fs.promises.stat(destPath)
|
||||
const fileType = getFileType(ext)
|
||||
|
||||
const fileMetadata: FileType = {
|
||||
const fileMetadata: FileMetadata = {
|
||||
id: uuid,
|
||||
origin_name: filename,
|
||||
name: uuid + ext,
|
||||
|
||||
@@ -23,13 +23,15 @@ import { SitemapLoader } from '@cherrystudio/embedjs-loader-sitemap'
|
||||
import { WebLoader } from '@cherrystudio/embedjs-loader-web'
|
||||
import Embeddings from '@main/embeddings/Embeddings'
|
||||
import { addFileLoader } from '@main/loader'
|
||||
import OcrProvider from '@main/ocr/OcrProvider'
|
||||
import PreprocessProvider from '@main/preprocess/PreprocessProvider'
|
||||
import Reranker from '@main/reranker/Reranker'
|
||||
import { windowService } from '@main/services/WindowService'
|
||||
import { getAllFiles } from '@main/utils/file'
|
||||
import { MB } from '@shared/config/constant'
|
||||
import type { LoaderReturn } from '@shared/config/types'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { FileType, KnowledgeBaseParams, KnowledgeItem } from '@types'
|
||||
import { FileMetadata, KnowledgeBaseParams, KnowledgeItem } from '@types'
|
||||
import { app } from 'electron'
|
||||
import Logger from 'electron-log'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
@@ -38,12 +40,14 @@ export interface KnowledgeBaseAddItemOptions {
|
||||
base: KnowledgeBaseParams
|
||||
item: KnowledgeItem
|
||||
forceReload?: boolean
|
||||
userId?: string
|
||||
}
|
||||
|
||||
interface KnowledgeBaseAddItemOptionsNonNullableAttribute {
|
||||
base: KnowledgeBaseParams
|
||||
item: KnowledgeItem
|
||||
forceReload: boolean
|
||||
userId: string
|
||||
}
|
||||
|
||||
interface EvaluateTaskWorkload {
|
||||
@@ -95,7 +99,13 @@ class KnowledgeService {
|
||||
private knowledgeItemProcessingQueueMappingPromise: Map<LoaderTaskOfSet, () => void> = new Map()
|
||||
private static MAXIMUM_WORKLOAD = 80 * MB
|
||||
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() {
|
||||
this.initStorageDir()
|
||||
@@ -149,6 +159,7 @@ class KnowledgeService {
|
||||
}
|
||||
|
||||
public delete = async (_: Electron.IpcMainInvokeEvent, id: string): Promise<void> => {
|
||||
console.log('id', id)
|
||||
const dbPath = path.join(this.storageDir, id)
|
||||
if (fs.existsSync(dbPath)) {
|
||||
fs.rmSync(dbPath, { recursive: true })
|
||||
@@ -161,28 +172,49 @@ class KnowledgeService {
|
||||
this.workload >= KnowledgeService.MAXIMUM_WORKLOAD
|
||||
)
|
||||
}
|
||||
|
||||
private fileTask(
|
||||
ragApplication: RAGApplication,
|
||||
options: KnowledgeBaseAddItemOptionsNonNullableAttribute
|
||||
): LoaderTask {
|
||||
const { base, item, forceReload } = options
|
||||
const file = item.content as FileType
|
||||
const { base, item, forceReload, userId } = options
|
||||
const file = item.content as FileMetadata
|
||||
|
||||
const loaderTask: LoaderTask = {
|
||||
loaderTasks: [
|
||||
{
|
||||
state: LoaderTaskItemState.PENDING,
|
||||
task: () =>
|
||||
addFileLoader(ragApplication, file, base, forceReload)
|
||||
.then((result) => {
|
||||
loaderTask.loaderDoneReturn = result
|
||||
return result
|
||||
})
|
||||
.catch((err) => {
|
||||
Logger.error(err)
|
||||
return KnowledgeService.ERROR_LOADER_RETURN
|
||||
}),
|
||||
task: async () => {
|
||||
try {
|
||||
// 添加预处理逻辑
|
||||
const fileToProcess: FileMetadata = await this.preprocessing(file, base, item, userId)
|
||||
|
||||
// 使用处理后的文件进行加载
|
||||
return addFileLoader(ragApplication, fileToProcess, base, forceReload)
|
||||
.then((result) => {
|
||||
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 }
|
||||
}
|
||||
],
|
||||
@@ -191,7 +223,6 @@ class KnowledgeService {
|
||||
|
||||
return loaderTask
|
||||
}
|
||||
|
||||
private directoryTask(
|
||||
ragApplication: RAGApplication,
|
||||
options: KnowledgeBaseAddItemOptionsNonNullableAttribute
|
||||
@@ -231,7 +262,11 @@ class KnowledgeService {
|
||||
})
|
||||
.catch((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 }
|
||||
})
|
||||
@@ -277,7 +312,11 @@ class KnowledgeService {
|
||||
})
|
||||
.catch((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 }
|
||||
@@ -317,7 +356,11 @@ class KnowledgeService {
|
||||
})
|
||||
.catch((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 }
|
||||
}
|
||||
@@ -357,7 +400,11 @@ class KnowledgeService {
|
||||
})
|
||||
.catch((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 }
|
||||
@@ -423,10 +470,10 @@ class KnowledgeService {
|
||||
})
|
||||
}
|
||||
|
||||
public add = (_: Electron.IpcMainInvokeEvent, options: KnowledgeBaseAddItemOptions): Promise<LoaderReturn> => {
|
||||
public add = async (_: Electron.IpcMainInvokeEvent, options: KnowledgeBaseAddItemOptions): Promise<LoaderReturn> => {
|
||||
return new Promise((resolve) => {
|
||||
const { base, item, forceReload = false } = options
|
||||
const optionsNonNullableAttribute = { base, item, forceReload }
|
||||
const { base, item, forceReload = false, userId = '' } = options
|
||||
const optionsNonNullableAttribute = { base, item, forceReload, userId }
|
||||
this.getRagApplication(base)
|
||||
.then((ragApplication) => {
|
||||
const task = (() => {
|
||||
@@ -452,12 +499,20 @@ class KnowledgeService {
|
||||
})
|
||||
this.processingQueueHandle()
|
||||
} else {
|
||||
resolve(KnowledgeService.ERROR_LOADER_RETURN)
|
||||
resolve({
|
||||
...KnowledgeService.ERROR_LOADER_RETURN,
|
||||
message: 'Unsupported item type',
|
||||
messageSource: 'embedding'
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
Logger.error(err)
|
||||
resolve(KnowledgeService.ERROR_LOADER_RETURN)
|
||||
resolve({
|
||||
...KnowledgeService.ERROR_LOADER_RETURN,
|
||||
message: `Failed to add item: ${err.message}`,
|
||||
messageSource: 'embedding'
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -490,6 +545,52 @@ class KnowledgeService {
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
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 { isMac } from '@main/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 { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
@@ -28,7 +28,19 @@ export function getFileType(ext: string): FileTypes {
|
||||
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)
|
||||
|
||||
files.forEach((file) => {
|
||||
@@ -50,7 +62,7 @@ export function getAllFiles(dirPath: string, arrayOfFiles: FileType[] = []): Fil
|
||||
const name = path.basename(file)
|
||||
const size = fs.statSync(fullPath).size
|
||||
|
||||
const fileItem: FileType = {
|
||||
const fileItem: FileMetadata = {
|
||||
id: uuidv4(),
|
||||
name,
|
||||
path: fullPath,
|
||||
|
||||
@@ -49,7 +49,7 @@ export async function getBinaryPath(name?: string): Promise<string> {
|
||||
|
||||
const binaryName = await getBinaryName(name)
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,18 @@ import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
|
||||
import { electronAPI } from '@electron-toolkit/preload'
|
||||
import { FeedUrl } from '@shared/config/constant'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { FileType, KnowledgeBaseParams, KnowledgeItem, MCPServer, Shortcut, ThemeMode, WebDavConfig } from '@types'
|
||||
import {
|
||||
FileListResponse,
|
||||
FileMetadata,
|
||||
FileUploadResponse,
|
||||
KnowledgeBaseParams,
|
||||
KnowledgeItem,
|
||||
MCPServer,
|
||||
Provider,
|
||||
Shortcut,
|
||||
ThemeMode,
|
||||
WebDavConfig
|
||||
} from '@types'
|
||||
import { contextBridge, ipcRenderer, OpenDialogOptions, shell, webUtils } from 'electron'
|
||||
import { Notification } from 'src/renderer/src/types/notification'
|
||||
import { CreateDirectoryOptions } from 'webdav'
|
||||
@@ -62,13 +73,25 @@ const api = {
|
||||
},
|
||||
file: {
|
||||
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),
|
||||
deleteDir: (dirPath: string) => ipcRenderer.invoke(IpcChannel.File_DeleteDir, dirPath),
|
||||
read: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Read, fileId),
|
||||
clear: () => ipcRenderer.invoke(IpcChannel.File_Clear),
|
||||
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),
|
||||
|
||||
writeWithId: (id: string, content: string) => ipcRenderer.invoke(IpcChannel.File_WriteWithId, id, content),
|
||||
open: (options?: OpenDialogOptions) => ipcRenderer.invoke(IpcChannel.File_Open, options),
|
||||
openPath: (path: string) => ipcRenderer.invoke(IpcChannel.File_OpenPath, path),
|
||||
@@ -76,12 +99,12 @@ const api = {
|
||||
ipcRenderer.invoke(IpcChannel.File_Save, path, content, options),
|
||||
selectFolder: () => ipcRenderer.invoke(IpcChannel.File_SelectFolder),
|
||||
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),
|
||||
saveBase64Image: (data: string) => ipcRenderer.invoke(IpcChannel.File_SaveBase64Image, data),
|
||||
download: (url: string, isUseContentType?: boolean) =>
|
||||
ipcRenderer.invoke(IpcChannel.File_Download, url, isUseContentType),
|
||||
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),
|
||||
getPathForFile: (file: File) => webUtils.getPathForFile(file)
|
||||
},
|
||||
@@ -102,12 +125,14 @@ const api = {
|
||||
add: ({
|
||||
base,
|
||||
item,
|
||||
userId,
|
||||
forceReload = false
|
||||
}: {
|
||||
base: KnowledgeBaseParams
|
||||
item: KnowledgeItem
|
||||
userId?: string
|
||||
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 }) =>
|
||||
ipcRenderer.invoke(IpcChannel.KnowledgeBase_Remove, { uniqueId, uniqueIds, base }),
|
||||
search: ({ search, base }: { search: string; base: KnowledgeBaseParams }) =>
|
||||
@@ -120,13 +145,16 @@ const api = {
|
||||
ipcRenderer.invoke(IpcChannel.Windows_SetMinimumSize, width, height),
|
||||
resetMinimumSize: () => ipcRenderer.invoke(IpcChannel.Windows_ResetMinimumSize)
|
||||
},
|
||||
gemini: {
|
||||
uploadFile: (file: FileType, { apiKey, baseURL }: { apiKey: string; baseURL: string }) =>
|
||||
ipcRenderer.invoke(IpcChannel.Gemini_UploadFile, file, { apiKey, baseURL }),
|
||||
base64File: (file: FileType) => ipcRenderer.invoke(IpcChannel.Gemini_Base64File, file),
|
||||
retrieveFile: (file: FileType, apiKey: string) => ipcRenderer.invoke(IpcChannel.Gemini_RetrieveFile, file, apiKey),
|
||||
listFiles: (apiKey: string) => ipcRenderer.invoke(IpcChannel.Gemini_ListFiles, apiKey),
|
||||
deleteFile: (fileId: string, apiKey: string) => ipcRenderer.invoke(IpcChannel.Gemini_DeleteFile, fileId, apiKey)
|
||||
fileService: {
|
||||
upload: (provider: Provider, file: FileMetadata): Promise<FileUploadResponse> =>
|
||||
ipcRenderer.invoke(IpcChannel.FileService_Upload, provider, file),
|
||||
list: (provider: Provider): Promise<FileListResponse> => ipcRenderer.invoke(IpcChannel.FileService_List, provider),
|
||||
delete: (provider: Provider, fileId: string) => ipcRenderer.invoke(IpcChannel.FileService_Delete, provider, fileId),
|
||||
retrieve: (provider: Provider, fileId: string): Promise<FileUploadResponse> =>
|
||||
ipcRenderer.invoke(IpcChannel.FileService_Retrieve, provider, fileId)
|
||||
},
|
||||
selectionMenu: {
|
||||
action: (action: string) => ipcRenderer.invoke('selection-menu:action', action)
|
||||
},
|
||||
config: {
|
||||
set: (key: string, value: any, isNotify: boolean = false) =>
|
||||
|
||||
@@ -31,7 +31,7 @@ import { estimateTextTokens } from '@renderer/services/TokenService'
|
||||
import {
|
||||
Assistant,
|
||||
EFFORT_RATIO,
|
||||
FileType,
|
||||
FileMetadata,
|
||||
FileTypes,
|
||||
GenerateImageParams,
|
||||
MCPCallToolResponse,
|
||||
@@ -187,7 +187,7 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
* @param file - The file
|
||||
* @returns The part
|
||||
*/
|
||||
private async handlePdfFile(file: FileType): Promise<Part> {
|
||||
private async handlePdfFile(file: FileMetadata): Promise<Part> {
|
||||
const smallFileSize = 20 * MB
|
||||
const isSmallFile = file.size < smallFileSize
|
||||
|
||||
@@ -726,7 +726,7 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
return sdkPayload.history || []
|
||||
}
|
||||
|
||||
private async uploadFile(file: FileType): Promise<File> {
|
||||
private async uploadFile(file: FileMetadata): Promise<File> {
|
||||
return await this.sdkInstance!.files.upload({
|
||||
file: file.path,
|
||||
config: {
|
||||
@@ -737,7 +737,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)
|
||||
return {
|
||||
data,
|
||||
@@ -745,7 +745,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')
|
||||
|
||||
if (cachedResponse) {
|
||||
@@ -758,7 +758,7 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
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) {
|
||||
if (f.state === FileState.ACTIVE) {
|
||||
if (f.displayName === file.origin_name && Number(f.sizeBytes) === file.size) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@font-face {
|
||||
font-family: 'iconfont'; /* Project id 4753420 */
|
||||
src: url('iconfont.woff2?t=1742184675192') format('woff2');
|
||||
src: url('iconfont.woff2?t=1742793497518') format('woff2');
|
||||
}
|
||||
|
||||
.iconfont {
|
||||
@@ -11,6 +11,18 @@
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.icon-plugin:before {
|
||||
content: '\e612';
|
||||
}
|
||||
|
||||
.icon-tools:before {
|
||||
content: '\e762';
|
||||
}
|
||||
|
||||
.icon-OCRshibie:before {
|
||||
content: '\e658';
|
||||
}
|
||||
|
||||
.icon-obsidian:before {
|
||||
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 |
@@ -19,7 +19,7 @@ const Artifacts: FC<Props> = ({ html }) => {
|
||||
* 在应用内打开
|
||||
*/
|
||||
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)
|
||||
const filePath = `file://${path}`
|
||||
const title = extractTitle(html) || 'Artifacts ' + t('chat.artifacts.button.preview')
|
||||
@@ -35,7 +35,7 @@ const Artifacts: FC<Props> = ({ html }) => {
|
||||
* 外部链接打开
|
||||
*/
|
||||
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)
|
||||
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
|
||||
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 type { Message as NewMessage, MessageBlock } from '@renderer/types/newMessage'
|
||||
import { Dexie, type EntityTable } from 'dexie'
|
||||
@@ -7,7 +7,7 @@ import { upgradeToV5, upgradeToV7 } from './upgrades'
|
||||
|
||||
// Database declaration (move this to its own module also)
|
||||
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
|
||||
settings: EntityTable<{ id: string; value: any }, 'id'>
|
||||
knowledge_notes: EntityTable<KnowledgeItem, 'id'>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
/* eslint-disable react-hooks/rules-of-hooks */
|
||||
import { db } from '@renderer/databases'
|
||||
import KnowledgeQueue from '@renderer/queue/KnowledgeQueue'
|
||||
import FileManager from '@renderer/services/FileManager'
|
||||
import { getKnowledgeBaseParams } from '@renderer/services/KnowledgeService'
|
||||
import { RootState } from '@renderer/store'
|
||||
import {
|
||||
@@ -19,10 +17,9 @@ import {
|
||||
updateItemProcessingStatus,
|
||||
updateNotes
|
||||
} 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 { IpcChannel } from '@shared/IpcChannel'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
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) => ({
|
||||
id: uuidv4(),
|
||||
type: 'file' as const,
|
||||
@@ -56,6 +53,7 @@ export const useKnowledge = (baseId: string) => {
|
||||
processingError: '',
|
||||
retryCount: 0
|
||||
}))
|
||||
console.log('Adding files:', filesItems)
|
||||
dispatch(addFilesAction({ baseId, items: filesItems }))
|
||||
setTimeout(() => KnowledgeQueue.checkAllBases(), 0)
|
||||
}
|
||||
@@ -147,7 +145,7 @@ export const useKnowledge = (baseId: string) => {
|
||||
}
|
||||
}
|
||||
if (item.type === 'file' && typeof item.content === 'object') {
|
||||
await FileManager.deleteFile(item.content.id)
|
||||
await window.api.file.deleteDir(item.content.id)
|
||||
}
|
||||
}
|
||||
// 刷新项目
|
||||
@@ -189,41 +187,18 @@ export const useKnowledge = (baseId: string) => {
|
||||
}
|
||||
|
||||
// 获取特定项目的处理状态
|
||||
const getProcessingStatus = (itemId: string) => {
|
||||
return base?.items.find((item) => item.id === itemId)?.processingStatus
|
||||
}
|
||||
const getProcessingStatus = useCallback(
|
||||
(itemId: string) => {
|
||||
return base?.items.find((item) => item.id === itemId)?.processingStatus
|
||||
},
|
||||
[base?.items]
|
||||
)
|
||||
|
||||
// 获取特定类型的所有处理项
|
||||
const getProcessingItemsByType = (type: 'file' | 'url' | 'note') => {
|
||||
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 = () => {
|
||||
dispatch(clearCompletedProcessing({ baseId }))
|
||||
@@ -306,7 +281,6 @@ export const useKnowledge = (baseId: string) => {
|
||||
refreshItem,
|
||||
getProcessingStatus,
|
||||
getProcessingItemsByType,
|
||||
getDirectoryProcessingPercent,
|
||||
clearCompleted,
|
||||
clearAll,
|
||||
removeItem,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import FileManager from '@renderer/services/FileManager'
|
||||
import { FileType } from '@renderer/types'
|
||||
import { FileMetadata } from '@renderer/types'
|
||||
import { isEmpty } from 'lodash'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { useKnowledgeBases } from './useKnowledge'
|
||||
|
||||
export const useKnowledgeFiles = () => {
|
||||
const [knowledgeFiles, setKnowledgeFiles] = useState<FileType[]>([])
|
||||
const [knowledgeFiles, setKnowledgeFiles] = useState<FileMetadata[]>([])
|
||||
const { bases, updateKnowledgeBases } = useKnowledgeBases()
|
||||
|
||||
useEffect(() => {
|
||||
@@ -16,7 +16,7 @@ export const useKnowledgeFiles = () => {
|
||||
.filter((item) => item.type === 'file')
|
||||
.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)
|
||||
}, [bases])
|
||||
@@ -31,7 +31,7 @@ export const useKnowledgeFiles = () => {
|
||||
? {
|
||||
...item,
|
||||
content: {
|
||||
...(item.content as FileType),
|
||||
...(item.content as FileMetadata),
|
||||
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.upload": "Upload image or document file",
|
||||
"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.button.ok": "Go to Settings",
|
||||
"input.web_search.enable": "Enable web search",
|
||||
@@ -542,13 +542,21 @@
|
||||
"rename": "Rename",
|
||||
"search": "Search knowledge base",
|
||||
"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",
|
||||
"sitemaps": "Websites",
|
||||
"source": "Source",
|
||||
"status": "Status",
|
||||
"status_completed": "Completed",
|
||||
"status_embedding_completed": "Embedding Completed",
|
||||
"status_preprocess_completed": "Preprocessing Completed",
|
||||
"status_failed": "Failed",
|
||||
"status_embedding_failed": "Embedding Failed",
|
||||
"status_preprocess_failed": "Preprocessing Failed",
|
||||
"status_new": "Added",
|
||||
"status_pending": "Pending",
|
||||
"status_processing": "Processing",
|
||||
@@ -571,7 +579,8 @@
|
||||
"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_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}}"
|
||||
},
|
||||
"languages": {
|
||||
"arabic": "Arabic",
|
||||
@@ -814,7 +823,7 @@
|
||||
"notification": {
|
||||
"assistant": "Assistant Response",
|
||||
"knowledge.success": "Successfully added {{type}} to the knowledge base",
|
||||
"knowledge.error": "Failed to add {{type}} to knowledge base: {{error}}"
|
||||
"knowledge.error": "{{error}}"
|
||||
},
|
||||
"ollama": {
|
||||
"keep_alive_time.description": "The time in minutes to keep the connection alive, default is 5 minutes.",
|
||||
@@ -1740,41 +1749,63 @@
|
||||
"tray.onclose": "Minimize to Tray on Close",
|
||||
"tray.show": "Show Tray Icon",
|
||||
"tray.title": "Tray",
|
||||
"websearch": {
|
||||
"blacklist": "Blacklist",
|
||||
"blacklist_description": "Results from the following websites will not appear in search results",
|
||||
"blacklist_tooltip": "Please use the following format (separated by newlines)\nPattern matching: *://*.example.com/*\nRegular expression: /example\\.(net|org)/",
|
||||
"check": "Check",
|
||||
"check_failed": "Verification failed",
|
||||
"check_success": "Verification successful",
|
||||
"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"
|
||||
"tool": {
|
||||
"title": "Tools Settings",
|
||||
"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.",
|
||||
"preprocess": {
|
||||
"title": "Pre Process",
|
||||
"provider": "Pre Process Provider",
|
||||
"provider_placeholder": "Choose a Pre Process provider"
|
||||
},
|
||||
"title": "Web Search",
|
||||
"subscribe": "Blacklist Subscription",
|
||||
"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",
|
||||
"content_limit": "Content length limit",
|
||||
"content_limit_tooltip": "Limit the content length of the search results; content that exceeds the limit will be truncated."
|
||||
"ocr": {
|
||||
"title": "OCR",
|
||||
"provider": "OCR Provider",
|
||||
"provider_placeholder": "Choose an OCR provider",
|
||||
"mac_system_ocr_options": {
|
||||
"mode": {
|
||||
"title": "Recognition Mode",
|
||||
"accurate": "Accurate",
|
||||
"fast": "Fast"
|
||||
},
|
||||
"min_confidence": "Minimum Confidence"
|
||||
}
|
||||
},
|
||||
"websearch": {
|
||||
"blacklist": "Blacklist",
|
||||
"blacklist_description": "Results from the following websites will not appear in search results",
|
||||
"blacklist_tooltip": "Please use the following format (separated by newlines)\nPattern matching: *://*.example.com/*\nRegular expression: /example\\.(net|org)/",
|
||||
"check": "Check",
|
||||
"check_failed": "Verification failed",
|
||||
"check_success": "Verification successful",
|
||||
"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",
|
||||
"subscribe": "Blacklist Subscription",
|
||||
"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",
|
||||
"content_limit": "Content length limit",
|
||||
"content_limit_tooltip": "Limit the content length of the search results; content that exceeds the limit will be truncated."
|
||||
}
|
||||
},
|
||||
"quickPhrase": {
|
||||
"title": "Quick Phrases",
|
||||
|
||||
@@ -542,7 +542,11 @@
|
||||
"rename": "名前を変更",
|
||||
"search": "ナレッジベースを検索",
|
||||
"search_placeholder": "検索するテキストを入力",
|
||||
"settings": "ナレッジベース設定",
|
||||
"settings": {
|
||||
"title": "ナレッジベース設定",
|
||||
"preprocessing": "預処理",
|
||||
"preprocessing_tooltip": "アップロードされたファイルのOCR預処理"
|
||||
},
|
||||
"sitemap_placeholder": "サイトマップURLを入力",
|
||||
"sitemaps": "サイトマップ",
|
||||
"source": "ソース",
|
||||
@@ -566,12 +570,17 @@
|
||||
"urls": "URL",
|
||||
"dimensions": "埋め込み次元",
|
||||
"dimensions_size_tooltip": "埋め込み次元のサイズは、数値が大きいほど埋め込み次元も大きくなりますが、消費するトークンも増えます。",
|
||||
"status_embedding_completed": "埋め込み完了",
|
||||
"status_preprocess_completed": "前処理完了",
|
||||
"status_embedding_failed": "埋め込み失敗",
|
||||
"status_preprocess_failed": "前処理に失敗しました",
|
||||
"dimensions_size_placeholder": " 埋め込み次元のサイズ(例:1024)",
|
||||
"dimensions_auto_set": "埋め込み次元を自動設定",
|
||||
"dimensions_error_invalid": "埋め込み次元のサイズを入力してください",
|
||||
"dimensions_size_too_large": "埋め込み次元はモデルのコンテキスト制限({{max_context}})を超えてはなりません。",
|
||||
"dimensions_set_right": "⚠️ モデルが設定した埋め込み次元のサイズをサポートしていることを確認してください",
|
||||
"dimensions_default": "モデルはデフォルトの埋め込み次元を使用します"
|
||||
"dimensions_default": "モデルはデフォルトの埋め込み次元を使用します",
|
||||
"quota": "{{name}} 残りクォータ: {{quota}}"
|
||||
},
|
||||
"languages": {
|
||||
"arabic": "アラビア語",
|
||||
@@ -814,7 +823,7 @@
|
||||
"notification": {
|
||||
"assistant": "助手回應",
|
||||
"knowledge.success": "ナレッジベースに{{type}}を正常に追加しました",
|
||||
"knowledge.error": "ナレッジベースへの{{type}}の追加に失敗しました: {{error}}"
|
||||
"knowledge.error": "{{error}}"
|
||||
},
|
||||
"ollama": {
|
||||
"keep_alive_time.description": "モデルがメモリに保持される時間(デフォルト:5分)",
|
||||
@@ -1720,6 +1729,64 @@
|
||||
"theme.window.style.title": "ウィンドウスタイル",
|
||||
"theme.window.style.transparent": "透明ウィンドウ",
|
||||
"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": "検索結果のコンテンツの長さを制限します。制限を超えるコンテンツは切り捨てられます。"
|
||||
},
|
||||
"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.left": "左",
|
||||
"topic.position.right": "右",
|
||||
@@ -1728,42 +1795,6 @@
|
||||
"tray.onclose": "閉じるときにトレイに最小化",
|
||||
"tray.show": "トレイアイコンを表示",
|
||||
"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": "無料",
|
||||
"content_limit": "内容の長さ制限",
|
||||
"content_limit_tooltip": "検索結果の内容長を制限し、制限を超える内容は切り捨てられます。"
|
||||
},
|
||||
"general.auto_check_update.title": "自動更新",
|
||||
"general.early_access.title": "早期アクセス",
|
||||
"general.early_access.tooltip": "有効にすると、GitHub の最新バージョンを使用します。ダウンロード速度が遅く、不安定な場合があります。データを事前にバックアップしてください。",
|
||||
|
||||
@@ -542,7 +542,11 @@
|
||||
"rename": "Переименовать",
|
||||
"search": "Поиск в базе знаний",
|
||||
"search_placeholder": "Введите текст для поиска",
|
||||
"settings": "Настройки базы знаний",
|
||||
"settings": {
|
||||
"title": "Настройки базы знаний",
|
||||
"preprocessing": "Предварительная обработка",
|
||||
"preprocessing_tooltip": "Предварительная обработка изображений с помощью OCR"
|
||||
},
|
||||
"sitemap_placeholder": "Введите URL карты сайта",
|
||||
"sitemaps": "Сайты",
|
||||
"source": "Источник",
|
||||
@@ -566,12 +570,17 @@
|
||||
"urls": "URL-адреса",
|
||||
"dimensions": "векторное пространство",
|
||||
"dimensions_size_tooltip": "Размерность вложения, чем больше значение, тем больше размерность вложения, но и потребляемых токенов также становится больше.",
|
||||
"status_embedding_completed": "Вложение завершено",
|
||||
"status_preprocess_completed": "Предварительная обработка завершена",
|
||||
"status_embedding_failed": "Не удалось встроить",
|
||||
"status_preprocess_failed": "Предварительная обработка не удалась",
|
||||
"dimensions_size_placeholder": " Размерность эмбеддинга, например 1024",
|
||||
"dimensions_auto_set": "Автоматическая установка размерности эмбеддинга",
|
||||
"dimensions_error_invalid": "Пожалуйста, введите размерность эмбеддинга",
|
||||
"dimensions_size_too_large": "Размерность вложения не может превышать ограничение контекста модели ({{max_context}})",
|
||||
"dimensions_set_right": "⚠️ Убедитесь, что модель поддерживает заданный размер эмбеддинга",
|
||||
"dimensions_default": "Модель будет использовать размер эмбеддинга по умолчанию"
|
||||
"dimensions_default": "Модель будет использовать размер эмбеддинга по умолчанию",
|
||||
"quota": "{{name}} Остаток квоты: {{quota}}"
|
||||
},
|
||||
"languages": {
|
||||
"arabic": "Арабский",
|
||||
@@ -814,7 +823,7 @@
|
||||
"notification": {
|
||||
"assistant": "Ответ ассистента",
|
||||
"knowledge.success": "Успешно добавлено {{type}} в базу знаний",
|
||||
"knowledge.error": "Не удалось добавить {{type}} в базу знаний: {{error}}"
|
||||
"knowledge.error": "{{error}}"
|
||||
},
|
||||
"ollama": {
|
||||
"keep_alive_time.description": "Время в минутах, в течение которого модель остается активной, по умолчанию 5 минут.",
|
||||
@@ -1720,6 +1729,64 @@
|
||||
"theme.window.style.title": "Стиль окна",
|
||||
"theme.window.style.transparent": "Прозрачное окно",
|
||||
"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": "Ограничить длину контента в результатах поиска; контент, превышающий лимит, будет усечен."
|
||||
},
|
||||
"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.left": "Слева",
|
||||
"topic.position.right": "Справа",
|
||||
@@ -1728,42 +1795,6 @@
|
||||
"tray.onclose": "Свернуть в трей при закрытии",
|
||||
"tray.show": "Показать значок в трее",
|
||||
"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": "Бесплатно",
|
||||
"content_limit": "Ограничение длины текста",
|
||||
"content_limit_tooltip": "Ограничьте длину содержимого результатов поиска, контент, превышающий ограничение, будет обрезан."
|
||||
},
|
||||
"general.auto_check_update.title": "Автоматическое обновление",
|
||||
"general.early_access.title": "Ранний доступ",
|
||||
"general.early_access.tooltip": "Включить для использования последней версии из GitHub, что может быть медленнее и нестабильно. Пожалуйста, сделайте резервную копию данных заранее.",
|
||||
|
||||
@@ -550,7 +550,11 @@
|
||||
"rename": "重命名",
|
||||
"search": "搜索知识库",
|
||||
"search_placeholder": "输入查询内容",
|
||||
"settings": "知识库设置",
|
||||
"settings": {
|
||||
"title": "知识库设置",
|
||||
"preprocessing": "预处理",
|
||||
"preprocessing_tooltip": "使用 OCR 预处理上传的文件"
|
||||
},
|
||||
"sitemap_placeholder": "请输入站点地图 URL",
|
||||
"sitemaps": "网站",
|
||||
"source": "来源",
|
||||
@@ -571,7 +575,12 @@
|
||||
"topN_tooltip": "返回的匹配结果数量,数值越大,匹配结果越多,但消耗的 Token 也越多",
|
||||
"url_added": "网址已添加",
|
||||
"url_placeholder": "请输入网址, 多个网址用回车分隔",
|
||||
"urls": "网址"
|
||||
"urls": "网址",
|
||||
"status_embedding_completed": "嵌入完成",
|
||||
"status_preprocess_completed": "预处理完成",
|
||||
"status_embedding_failed": "嵌入失败",
|
||||
"status_preprocess_failed": "预处理失败",
|
||||
"quota": "{{name}} 剩余额度:{{quota}}"
|
||||
},
|
||||
"languages": {
|
||||
"arabic": "阿拉伯文",
|
||||
@@ -814,7 +823,7 @@
|
||||
"notification": {
|
||||
"assistant": "助手响应",
|
||||
"knowledge.success": "成功添加 {{type}} 到知识库",
|
||||
"knowledge.error": "添加 {{type}} 到知识库失败: {{error}}"
|
||||
"knowledge.error": "{{error}}"
|
||||
},
|
||||
"ollama": {
|
||||
"keep_alive_time.description": "对话后模型在内存中保持的时间(默认:5分钟)",
|
||||
@@ -1600,7 +1609,7 @@
|
||||
"title": "通知设置",
|
||||
"assistant": "助手消息",
|
||||
"backup": "备份",
|
||||
"knowledge_embed": "知识嵌入"
|
||||
"knowledge_embed": "知识库"
|
||||
},
|
||||
"provider": {
|
||||
"add.name": "提供商名称",
|
||||
@@ -1740,42 +1749,6 @@
|
||||
"tray.onclose": "关闭时最小化到托盘",
|
||||
"tray.show": "显示托盘图标",
|
||||
"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": "免费",
|
||||
"content_limit": "内容长度限制",
|
||||
"content_limit_tooltip": "限制搜索结果的内容长度, 超过限制的内容将被截断"
|
||||
},
|
||||
"quickPhrase": {
|
||||
"title": "快捷短语",
|
||||
"add": "添加短语",
|
||||
@@ -1821,6 +1794,64 @@
|
||||
"service_tier.auto": "自动",
|
||||
"service_tier.default": "默认",
|
||||
"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": "限制搜索结果的内容长度, 超过限制的内容将被截断"
|
||||
},
|
||||
"preprocessOrOcr.tooltip": "在设置 -> 工具中设置文档预处理服务商或OCR,文档预处理可以有效提升复杂格式文档与扫描版文档的检索效果,OCR仅可识别文档内图片或扫描版PDF的文本"
|
||||
}
|
||||
},
|
||||
"translate": {
|
||||
|
||||
@@ -542,7 +542,11 @@
|
||||
"rename": "重新命名",
|
||||
"search": "搜尋知識庫",
|
||||
"search_placeholder": "輸入查詢內容",
|
||||
"settings": "知識庫設定",
|
||||
"settings": {
|
||||
"title": "知識庫設定",
|
||||
"preprocessing": "預處理",
|
||||
"preprocessing_tooltip": "預處理上傳的文件"
|
||||
},
|
||||
"sitemap_placeholder": "請輸入網站地圖 URL",
|
||||
"sitemaps": "網站",
|
||||
"source": "來源",
|
||||
@@ -566,12 +570,17 @@
|
||||
"urls": "網址",
|
||||
"dimensions": "嵌入維度",
|
||||
"dimensions_size_tooltip": "嵌入維度大小,數值越大,嵌入維度越大,但消耗的 Token 也越多",
|
||||
"status_embedding_completed": "嵌入完成",
|
||||
"status_preprocess_completed": "預處理完成",
|
||||
"status_embedding_failed": "嵌入失敗",
|
||||
"status_preprocess_failed": "預處理失敗",
|
||||
"dimensions_size_placeholder": " 嵌入維度大小,例如 1024",
|
||||
"dimensions_auto_set": "自動設定嵌入維度",
|
||||
"dimensions_error_invalid": "請輸入嵌入維度大小",
|
||||
"dimensions_size_too_large": "嵌入維度不能超過模型上下文限制({{max_context}})",
|
||||
"dimensions_set_right": "⚠️ 請確保模型支援所設置的嵌入維度大小",
|
||||
"dimensions_default": "模型將使用預設嵌入維度"
|
||||
"dimensions_default": "模型將使用預設嵌入維度",
|
||||
"quota": "{{name}} 剩餘配額:{{quota}}"
|
||||
},
|
||||
"languages": {
|
||||
"arabic": "阿拉伯文",
|
||||
@@ -814,7 +823,7 @@
|
||||
"notification": {
|
||||
"assistant": "助手回應",
|
||||
"knowledge.success": "成功將{{type}}新增至知識庫",
|
||||
"knowledge.error": "無法將 {{type}} 加入知識庫: {{error}}"
|
||||
"knowledge.error": "{{error}}"
|
||||
},
|
||||
"ollama": {
|
||||
"keep_alive_time.description": "對話後模型在記憶體中保持的時間(預設為 5 分鐘)",
|
||||
@@ -1723,50 +1732,72 @@
|
||||
"theme.window.style.title": "視窗樣式",
|
||||
"theme.window.style.transparent": "透明視窗",
|
||||
"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": "限制搜尋結果的內容長度;超過限制的內容將被截斷。"
|
||||
},
|
||||
"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.left": "左側",
|
||||
"topic.position.right": "右側",
|
||||
"topic.show.time": "顯示話題時間",
|
||||
"topic.pin_to_top": "固定話題置頂",
|
||||
"tray.onclose": "關閉時最小化到系统匣",
|
||||
"tray.show": "顯示系统匣圖示",
|
||||
"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": "免費",
|
||||
"content_limit": "內容長度限制",
|
||||
"content_limit_tooltip": "限制搜尋結果的內容長度,超過限制的內容將被截斷"
|
||||
},
|
||||
"general.auto_check_update.title": "自動更新",
|
||||
"general.early_access.title": "搶先體驗",
|
||||
"general.early_access.tooltip": "開啟後,將使用 GitHub 的最新版本,下載速度可能較慢,請務必提前備份數據",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import FileManager from '@renderer/services/FileManager'
|
||||
import { FileType, FileTypes } from '@renderer/types'
|
||||
import { FileMetadata, FileTypes } from '@renderer/types'
|
||||
import { formatFileSize } from '@renderer/utils'
|
||||
import { Col, Image, Row, Spin, Table } from 'antd'
|
||||
import React, { memo } from 'react'
|
||||
@@ -7,7 +7,7 @@ import styled from 'styled-components'
|
||||
|
||||
interface ContentViewProps {
|
||||
id: FileTypes | 'all' | string
|
||||
files?: FileType[]
|
||||
files?: FileMetadata[]
|
||||
dataSource?: any[]
|
||||
columns: any[]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import FileManager from '@renderer/services/FileManager'
|
||||
import { FileType, FileTypes } from '@renderer/types'
|
||||
import { FileMetadata, FileTypes } from '@renderer/types'
|
||||
import { formatFileSize } from '@renderer/utils'
|
||||
import { Col, Image, Row, Spin } from 'antd'
|
||||
import { t } from 'i18next'
|
||||
@@ -14,14 +14,14 @@ interface FileItemProps {
|
||||
list: {
|
||||
key: FileTypes | 'all' | string
|
||||
file: React.ReactNode
|
||||
files?: FileType[]
|
||||
files?: FileMetadata[]
|
||||
count?: number
|
||||
size: string
|
||||
ext: string
|
||||
created_at: string
|
||||
actions: React.ReactNode
|
||||
}[]
|
||||
files?: FileType[]
|
||||
files?: FileMetadata[]
|
||||
}
|
||||
|
||||
const FileList: React.FC<FileItemProps> = ({ id, list, files }) => {
|
||||
|
||||
@@ -12,7 +12,7 @@ import Logger from '@renderer/config/logger'
|
||||
import db from '@renderer/databases'
|
||||
import FileManager from '@renderer/services/FileManager'
|
||||
import store from '@renderer/store'
|
||||
import { FileType, FileTypes } from '@renderer/types'
|
||||
import { FileMetadata, FileTypes } from '@renderer/types'
|
||||
import { Message } from '@renderer/types/newMessage'
|
||||
import { formatFileSize } from '@renderer/utils'
|
||||
import { Button, Empty, Flex, Popconfirm } from 'antd'
|
||||
@@ -33,8 +33,7 @@ const FilesPage: FC = () => {
|
||||
const [fileType, setFileType] = useState<string>('document')
|
||||
const [sortField, setSortField] = useState<SortField>('created_at')
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>('desc')
|
||||
|
||||
const tempFilesSort = (files: FileType[]) => {
|
||||
const tempFilesSort = (files: FileMetadata[]) => {
|
||||
return files.sort((a, b) => {
|
||||
const aIsTemp = a.origin_name.startsWith('temp_file')
|
||||
const bIsTemp = b.origin_name.startsWith('temp_file')
|
||||
@@ -44,7 +43,7 @@ const FilesPage: FC = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const sortFiles = (files: FileType[]) => {
|
||||
const sortFiles = (files: FileMetadata[]) => {
|
||||
return [...files].sort((a, b) => {
|
||||
let comparison = 0
|
||||
switch (sortField) {
|
||||
@@ -62,7 +61,7 @@ const FilesPage: FC = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const files = useLiveQuery<FileType[]>(() => {
|
||||
const files = useLiveQuery<FileMetadata[]>(() => {
|
||||
if (fileType === 'all') {
|
||||
return db.files.orderBy('count').toArray().then(tempFilesSort)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { Tooltip } from 'antd'
|
||||
import { Paperclip } from 'lucide-react'
|
||||
@@ -13,8 +13,8 @@ export interface AttachmentButtonRef {
|
||||
interface Props {
|
||||
ref?: React.RefObject<AttachmentButtonRef | null>
|
||||
model: Model
|
||||
files: FileType[]
|
||||
setFiles: (files: FileType[]) => void
|
||||
files: FileMetadata[]
|
||||
setFiles: (files: FileMetadata[]) => void
|
||||
ToolbarButton: any
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from '@ant-design/icons'
|
||||
import CustomTag from '@renderer/components/CustomTag'
|
||||
import FileManager from '@renderer/services/FileManager'
|
||||
import { FileType } from '@renderer/types'
|
||||
import { FileMetadata } from '@renderer/types'
|
||||
import { formatFileSize } from '@renderer/utils'
|
||||
import { Flex, Image, Tooltip } from 'antd'
|
||||
import { isEmpty } from 'lodash'
|
||||
@@ -22,8 +22,8 @@ import { FC, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface Props {
|
||||
files: FileType[]
|
||||
setFiles: (files: FileType[]) => void
|
||||
files: FileMetadata[]
|
||||
setFiles: (files: FileMetadata[]) => void
|
||||
}
|
||||
|
||||
const MAX_FILENAME_DISPLAY_LENGTH = 20
|
||||
@@ -80,7 +80,7 @@ export const getFileIcon = (type?: string) => {
|
||||
return <FileUnknownFilled />
|
||||
}
|
||||
|
||||
export const FileNameRender: FC<{ file: FileType }> = ({ file }) => {
|
||||
export const FileNameRender: FC<{ file: FileMetadata }> = ({ file }) => {
|
||||
const [visible, setVisible] = useState<boolean>(false)
|
||||
const isImage = (ext: string) => {
|
||||
return ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'].includes(ext)
|
||||
|
||||
@@ -31,7 +31,7 @@ import WebSearchService from '@renderer/services/WebSearchService'
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import { setSearching } from '@renderer/store/runtime'
|
||||
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 { classNames, delay, formatFileSize, getFileExtension } from '@renderer/utils'
|
||||
import { formatQuotedText } from '@renderer/utils/formats'
|
||||
@@ -62,7 +62,7 @@ interface Props {
|
||||
}
|
||||
|
||||
let _text = ''
|
||||
let _files: FileType[] = []
|
||||
let _files: FileMetadata[] = []
|
||||
|
||||
const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) => {
|
||||
const [text, setText] = useState(_text)
|
||||
@@ -83,7 +83,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
const [estimateTokenCount, setEstimateTokenCount] = useState(0)
|
||||
const [contextCount, setContextCount] = useState({ current: 0, max: 0 })
|
||||
const textareaRef = useRef<TextAreaRef>(null)
|
||||
const [files, setFiles] = useState<FileType[]>(_files)
|
||||
const [files, setFiles] = useState<FileMetadata[]>(_files)
|
||||
const { t } = useTranslation()
|
||||
const containerRef = useRef(null)
|
||||
const { searching } = useRuntime()
|
||||
@@ -249,7 +249,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
list: base.items
|
||||
.filter((file): file is KnowledgeItem => ['file'].includes(file.type))
|
||||
.map((file) => {
|
||||
const fileContent = file.content as FileType
|
||||
const fileContent = file.content as FileMetadata
|
||||
return {
|
||||
label: fileContent.origin_name || fileContent.name,
|
||||
description:
|
||||
|
||||
@@ -3,7 +3,7 @@ import { QuickPanelListItem } from '@renderer/components/QuickPanel'
|
||||
import { isGenerateImageModel, isVisionModel } from '@renderer/config/models'
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
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 { Divider, Dropdown, Tooltip } from 'antd'
|
||||
import { ItemType } from 'antd/es/menu/interface'
|
||||
@@ -41,7 +41,7 @@ import WebSearchButton, { WebSearchButtonRef } from './WebSearchButton'
|
||||
export interface InputbarToolsRef {
|
||||
getQuickPanelMenu: (params: {
|
||||
t: (key: string, options?: any) => string
|
||||
files: FileType[]
|
||||
files: FileMetadata[]
|
||||
model: Model
|
||||
text: string
|
||||
openSelectFileMenu: () => void
|
||||
@@ -55,8 +55,8 @@ export interface InputbarToolsProps {
|
||||
assistant: Assistant
|
||||
model: Model
|
||||
|
||||
files: FileType[]
|
||||
setFiles: (files: FileType[]) => void
|
||||
files: FileMetadata[]
|
||||
setFiles: (files: FileMetadata[]) => void
|
||||
showThinkingButton: boolean
|
||||
showKnowledgeIcon: boolean
|
||||
selectedKnowledgeBases: KnowledgeBase[]
|
||||
@@ -152,7 +152,7 @@ const InputbarTools = ({
|
||||
|
||||
const getQuickPanelMenuImpl = (params: {
|
||||
t: (key: string, options?: any) => string
|
||||
files: FileType[]
|
||||
files: FileMetadata[]
|
||||
model: Model
|
||||
text: string
|
||||
openSelectFileMenu: () => void
|
||||
|
||||
@@ -55,8 +55,8 @@ const WebSearchButton: FC<Props> = ({ ref, assistant, ToolbarButton }) => {
|
||||
label: p.name,
|
||||
description: WebSearchService.isWebSearchEnabled(p.id)
|
||||
? hasObjectKey(p, 'apiKey')
|
||||
? t('settings.websearch.apikey')
|
||||
: t('settings.websearch.free')
|
||||
? t('settings.tool.websearch.apikey')
|
||||
: t('settings.tool.websearch.free')
|
||||
: t('chat.input.web_search.enable_content'),
|
||||
icon: <Globe />,
|
||||
isSelected: p.id === assistant?.webSearchProviderId,
|
||||
@@ -81,7 +81,7 @@ const WebSearchButton: FC<Props> = ({ ref, assistant, ToolbarButton }) => {
|
||||
items.push({
|
||||
label: t('chat.input.web_search.settings'),
|
||||
icon: <Settings />,
|
||||
action: () => navigate('/settings/web-search')
|
||||
action: () => navigate('/settings/tool/websearch')
|
||||
})
|
||||
|
||||
items.unshift({
|
||||
|
||||
@@ -166,7 +166,8 @@ const KnowledgeCitation: React.FC<{ citation: Citation }> = ({ citation }) => {
|
||||
<CitationIndex>{citation.number}</CitationIndex>
|
||||
{citation.showFavicon && <FileSearch width={16} />}
|
||||
<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>
|
||||
{citation.content && <CopyButton content={citation.content} />}
|
||||
</WebSearchCardHeader>
|
||||
|
||||
@@ -20,7 +20,7 @@ const StyledUpload = styled(Upload)`
|
||||
`
|
||||
|
||||
const MessageAttachments: FC<Props> = ({ block }) => {
|
||||
// const handleCopyImage = async (image: FileType) => {
|
||||
// const handleCopyImage = async (image: FileMetadata) => {
|
||||
// const data = await FileManager.readFile(image)
|
||||
// const blob = new Blob([data], { type: 'image/png' })
|
||||
// const item = new ClipboardItem({ [blob.type]: blob })
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import FileManager from '@renderer/services/FileManager'
|
||||
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 { classNames, getFileExtension } from '@renderer/utils'
|
||||
import { getFilesFromDropEvent } from '@renderer/utils/input'
|
||||
@@ -33,7 +33,7 @@ interface Props {
|
||||
const MessageBlockEditor: FC<Props> = ({ message, onSave, onResend, onCancel }) => {
|
||||
const allBlocks = findAllBlocks(message)
|
||||
const [editedBlocks, setEditedBlocks] = useState<MessageBlock[]>(allBlocks)
|
||||
const [files, setFiles] = useState<FileType[]>([])
|
||||
const [files, setFiles] = useState<FileMetadata[]>([])
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
const [isFileDragging, setIsFileDragging] = useState(false)
|
||||
const { assistant } = useAssistant(message.assistantId)
|
||||
|
||||
@@ -9,14 +9,14 @@ import Logger from '@renderer/config/logger'
|
||||
import { useKnowledge } from '@renderer/hooks/useKnowledge'
|
||||
import FileManager from '@renderer/services/FileManager'
|
||||
import { getProviderName } from '@renderer/services/ProviderService'
|
||||
import { FileType, FileTypes, KnowledgeBase, KnowledgeItem } from '@renderer/types'
|
||||
import { formatFileSize } from '@renderer/utils'
|
||||
import { FileMetadata, FileTypes, KnowledgeBase, KnowledgeItem } from '@renderer/types'
|
||||
import { formatFileSize, uuid } from '@renderer/utils'
|
||||
import { bookExts, documentExts, textExts, thirdPartyApplicationExts } from '@shared/config/constant'
|
||||
import { Alert, Button, Dropdown, Empty, message, Tag, Tooltip, Upload } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import { ChevronsDown, ChevronsUp, Plus, Search, Settings2 } from 'lucide-react'
|
||||
import VirtualList from 'rc-virtual-list'
|
||||
import { FC, useState } from 'react'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@@ -24,7 +24,8 @@ import CustomCollapse from '../../components/CustomCollapse'
|
||||
import FileItem from '../files/FileItem'
|
||||
import { NavbarIcon } from '../home/Navbar'
|
||||
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'
|
||||
|
||||
const { Dragger } = Upload
|
||||
@@ -38,6 +39,9 @@ const fileTypes = [...bookExts, ...thirdPartyApplicationExts, ...documentExts, .
|
||||
const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
|
||||
const { t } = useTranslation()
|
||||
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 {
|
||||
base,
|
||||
@@ -53,7 +57,6 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
|
||||
addSitemap,
|
||||
removeItem,
|
||||
getProcessingStatus,
|
||||
getDirectoryProcessingPercent,
|
||||
addNote,
|
||||
addDirectory,
|
||||
updateItem
|
||||
@@ -61,13 +64,34 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
|
||||
|
||||
const providerName = getProviderName(base?.model.provider || '')
|
||||
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) {
|
||||
return null
|
||||
}
|
||||
|
||||
const getProgressingPercentForItem = (itemId: string) => getDirectoryProcessingPercent(itemId)
|
||||
|
||||
const handleAddFile = () => {
|
||||
if (disabled) {
|
||||
return
|
||||
@@ -87,23 +111,36 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
|
||||
if (disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
if (files) {
|
||||
const _files: FileType[] = files
|
||||
.map((file) => ({
|
||||
id: file.name,
|
||||
name: file.name,
|
||||
path: window.api.file.getPathForFile(file),
|
||||
size: file.size,
|
||||
ext: `.${file.name.split('.').pop()}`.toLowerCase(),
|
||||
count: 1,
|
||||
origin_name: file.name,
|
||||
type: file.type as FileTypes,
|
||||
created_at: new Date().toISOString()
|
||||
}))
|
||||
const _files: FileMetadata[] = files
|
||||
.map((file) => {
|
||||
// 这个路径 filePath 很可能是在文件选择时的原始路径。
|
||||
const filePath = window.api.file.getPathForFile(file)
|
||||
let nameFromPath = filePath
|
||||
const lastSlash = filePath.lastIndexOf('/')
|
||||
const lastBackslash = filePath.lastIndexOf('\\')
|
||||
if (lastSlash !== -1 || lastBackslash !== -1) {
|
||||
nameFromPath = filePath.substring(Math.max(lastSlash, lastBackslash) + 1)
|
||||
}
|
||||
|
||||
// 从派生的文件名中获取扩展名
|
||||
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))
|
||||
const uploadedFiles = await FileManager.uploadFiles(_files)
|
||||
addFiles(uploadedFiles)
|
||||
// const uploadedFiles = await FileManager.uploadFiles(_files)
|
||||
addFiles(_files)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,6 +261,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 (
|
||||
<MainContainer>
|
||||
<HeaderContainer>
|
||||
@@ -231,7 +278,7 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
|
||||
<Button
|
||||
type="text"
|
||||
icon={<Settings2 size={18} color="var(--color-icon)" />}
|
||||
onClick={() => KnowledgeSettingsPopup.show({ base })}
|
||||
onClick={() => KnowledgeSettings.show({ base })}
|
||||
size="small"
|
||||
/>
|
||||
<div className="model-row">
|
||||
@@ -250,6 +297,9 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
|
||||
{base.rerankModel.name}
|
||||
</Tag>
|
||||
)}
|
||||
{base.preprocessOrOcrProvider && base.preprocessOrOcrProvider.type === 'preprocess' && (
|
||||
<QuotaTag providerId={base.preprocessOrOcrProvider?.provider.id} quota={quota} />
|
||||
)}
|
||||
</div>
|
||||
</ModelInfo>
|
||||
<HStack gap={8} alignItems="center">
|
||||
@@ -321,7 +371,7 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
|
||||
}
|
||||
}}>
|
||||
{(item) => {
|
||||
const file = item.content as FileType
|
||||
const file = item.content as FileMetadata
|
||||
return (
|
||||
<div style={{ height: '75px', paddingTop: '12px' }}>
|
||||
<FileItem
|
||||
@@ -341,6 +391,18 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
|
||||
{item.uniqueId && (
|
||||
<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>
|
||||
<StatusIcon
|
||||
sourceId={item.id}
|
||||
@@ -401,7 +463,6 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
|
||||
sourceId={item.id}
|
||||
base={base}
|
||||
getProcessingStatus={getProcessingStatus}
|
||||
getProcessingPercent={getProgressingPercentForItem}
|
||||
type="directory"
|
||||
/>
|
||||
</StatusIconWrapper>
|
||||
@@ -690,12 +751,11 @@ const ClickableSpan = styled.span`
|
||||
`
|
||||
|
||||
const StatusIconWrapper = styled.div`
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
width: 32px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-top: 2px;
|
||||
`
|
||||
|
||||
const RefreshIcon = styled(RedoOutlined)`
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import AddKnowledgePopup from './components/AddKnowledgePopup'
|
||||
import KnowledgeSettingsPopup from './components/KnowledgeSettingsPopup'
|
||||
import KnowledgeSettings from './components/KnowledgeSettings'
|
||||
import KnowledgeContent from './KnowledgeContent'
|
||||
|
||||
const KnowledgePage: FC = () => {
|
||||
@@ -55,10 +55,10 @@ const KnowledgePage: FC = () => {
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('knowledge.settings'),
|
||||
label: t('knowledge.settings.title'),
|
||||
key: 'settings',
|
||||
icon: <SettingOutlined />,
|
||||
onClick: () => KnowledgeSettingsPopup.show({ base })
|
||||
onClick: () => KnowledgeSettings.show({ base })
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
|
||||
@@ -1,35 +1,30 @@
|
||||
import { InfoCircleOutlined, SettingOutlined, WarningOutlined } from '@ant-design/icons'
|
||||
import AiProvider from '@renderer/aiCore'
|
||||
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 { NOT_SUPPORTED_REANK_PROVIDERS } from '@renderer/config/providers'
|
||||
// import { SUPPORTED_REANK_PROVIDERS } from '@renderer/config/providers'
|
||||
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 { SettingHelpText } from '@renderer/pages/settings'
|
||||
import { getKnowledgeBaseParams } from '@renderer/services/KnowledgeService'
|
||||
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 { 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 { nanoid } from 'nanoid'
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface ShowParams {
|
||||
title: string
|
||||
}
|
||||
|
||||
interface FormData {
|
||||
name: string
|
||||
model: string
|
||||
autoDims: boolean | undefined
|
||||
dimensions: number | undefined
|
||||
rerankModel: string | undefined
|
||||
documentCount: number | undefined
|
||||
}
|
||||
|
||||
interface Props extends ShowParams {
|
||||
resolve: (data: any) => void
|
||||
}
|
||||
@@ -38,10 +33,15 @@ const PopupContainer: React.FC<Props> = ({ title, resolve }) => {
|
||||
const [open, setOpen] = useState(true)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [autoDims, setAutoDims] = useState(true)
|
||||
const [form] = Form.useForm<FormData>()
|
||||
const { t } = useTranslation()
|
||||
const { providers } = useProviders()
|
||||
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(() => {
|
||||
return providers
|
||||
@@ -94,14 +94,30 @@ const PopupContainer: React.FC<Props> = ({ title, resolve }) => {
|
||||
.filter((group) => group.options.length > 0)
|
||||
}, [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 () => {
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
const selectedEmbeddingModel = find(embeddingModels, JSON.parse(values.model)) as Model
|
||||
// const values = await form.validateFields()
|
||||
const selectedEmbeddingModel = find(embeddingModels, newBase.model) as Model
|
||||
|
||||
const selectedRerankModel = values.rerankModel
|
||||
? (find(rerankModels, JSON.parse(values.rerankModel)) as Model)
|
||||
: undefined
|
||||
const selectedRerankModel = newBase.rerankModel ? (find(rerankModels, newBase.rerankModel) as Model) : undefined
|
||||
|
||||
if (selectedEmbeddingModel) {
|
||||
setLoading(true)
|
||||
@@ -110,40 +126,43 @@ const PopupContainer: React.FC<Props> = ({ title, resolve }) => {
|
||||
if (!provider) {
|
||||
return
|
||||
}
|
||||
let finalDimensions: number // 用于存储最终确定的维度值
|
||||
|
||||
if (autoDims || typeof values.dimensions === 'undefined') {
|
||||
if (autoDims || dimensions === undefined) {
|
||||
try {
|
||||
const aiProvider = new AiProvider(provider)
|
||||
values.dimensions = await aiProvider.getEmbeddingDimensions(selectedEmbeddingModel)
|
||||
finalDimensions = await aiProvider.getEmbeddingDimensions(selectedEmbeddingModel)
|
||||
|
||||
setDimensions(finalDimensions)
|
||||
} catch (error) {
|
||||
console.error('Error getting embedding dimensions:', error)
|
||||
console.error('获取嵌入维度时出错:', error)
|
||||
window.message.error(t('message.error.get_embedding_dimensions') + '\n' + getErrorMessage(error))
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
} else if (typeof values.dimensions === 'string') {
|
||||
// 按理来说不应该是string的,但是确实是string
|
||||
values.dimensions = parseInt(values.dimensions)
|
||||
} else {
|
||||
finalDimensions = dimensions
|
||||
}
|
||||
|
||||
const newBase: KnowledgeBase = {
|
||||
const _newBase = {
|
||||
...newBase,
|
||||
id: nanoid(),
|
||||
name: values.name,
|
||||
name: newBase.name,
|
||||
model: selectedEmbeddingModel,
|
||||
rerankModel: selectedRerankModel,
|
||||
dimensions: values.dimensions,
|
||||
documentCount: values.documentCount || DEFAULT_KNOWLEDGE_DOCUMENT_COUNT,
|
||||
dimensions: finalDimensions,
|
||||
documentCount: newBase.documentCount || DEFAULT_KNOWLEDGE_DOCUMENT_COUNT,
|
||||
items: [],
|
||||
created_at: Date.now(),
|
||||
updated_at: Date.now(),
|
||||
version: 1
|
||||
}
|
||||
|
||||
await window.api.knowledgeBase.create(getKnowledgeBaseParams(newBase))
|
||||
await window.api.knowledgeBase.create(getKnowledgeBaseParams(_newBase))
|
||||
|
||||
addKnowledgeBase(newBase)
|
||||
addKnowledgeBase(_newBase as any)
|
||||
setOpen(false)
|
||||
resolve(newBase)
|
||||
resolve(_newBase)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Validation failed:', error)
|
||||
@@ -158,8 +177,247 @@ const PopupContainer: React.FC<Props> = ({ title, resolve }) => {
|
||||
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 (
|
||||
<Modal
|
||||
<SettingsModal
|
||||
title={title}
|
||||
open={open}
|
||||
onOk={onOk}
|
||||
@@ -169,100 +427,49 @@ const PopupContainer: React.FC<Props> = ({ title, resolve }) => {
|
||||
destroyOnClose
|
||||
centered
|
||||
okButtonProps={{ loading }}>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
name="name"
|
||||
label={t('common.name')}
|
||||
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')} />
|
||||
</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')} />
|
||||
</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
|
||||
style={{ width: '100%' }}
|
||||
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>
|
||||
<div>
|
||||
<Tabs style={{ minHeight: '50vh' }} defaultActiveKey="1" tabPosition={'left'} items={settingItems} />
|
||||
</div>
|
||||
</SettingsModal>
|
||||
)
|
||||
}
|
||||
|
||||
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 {
|
||||
static hide() {
|
||||
TopView.hide('AddKnowledgePopup')
|
||||
|
||||
@@ -2,7 +2,7 @@ import { CopyOutlined } from '@ant-design/icons'
|
||||
import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
|
||||
import { TopView } from '@renderer/components/TopView'
|
||||
import { searchKnowledgeBase } from '@renderer/services/KnowledgeService'
|
||||
import { FileType, KnowledgeBase } from '@renderer/types'
|
||||
import { FileMetadata, KnowledgeBase } from '@renderer/types'
|
||||
import { Input, List, message, Modal, Spin, Tooltip, Typography } from 'antd'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -22,11 +22,10 @@ interface Props extends ShowParams {
|
||||
const PopupContainer: React.FC<Props> = ({ base, resolve }) => {
|
||||
const [open, setOpen] = useState(true)
|
||||
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 { t } = useTranslation()
|
||||
const searchInputRef = useRef<any>(null)
|
||||
|
||||
const handleSearch = async (value: string) => {
|
||||
if (!value.trim()) {
|
||||
setResults([])
|
||||
@@ -131,7 +130,10 @@ const PopupContainer: React.FC<Props> = ({ base, resolve }) => {
|
||||
{item.file.origin_name}
|
||||
</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>
|
||||
</MetadataContainer>
|
||||
|
||||
@@ -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,300 +0,0 @@
|
||||
import { DownOutlined, 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, Form, Input, InputNumber, Modal, Select, Slider } from 'antd'
|
||||
import { sortBy } from 'lodash'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
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 />
|
||||
</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
|
||||
/>
|
||||
</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
|
||||
style={{ width: '100%' }}
|
||||
min={1}
|
||||
max={30}
|
||||
step={1}
|
||||
marks={{ 1: '1', 6: t('knowledge.document_count_default'), 30: '30' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<AdvancedSettingsButton onClick={() => setShowAdvanced(!showAdvanced)}>
|
||||
<DownOutlined
|
||||
style={{
|
||||
transform: showAdvanced ? 'rotate(180deg)' : 'rotate(0deg)',
|
||||
transition: 'transform 0.3s',
|
||||
marginRight: 8
|
||||
}}
|
||||
/>
|
||||
{t('common.advanced_settings')}
|
||||
</AdvancedSettingsButton>
|
||||
|
||||
<div style={{ display: showAdvanced ? 'block' : 'none' }}>
|
||||
<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'
|
||||
|
||||
const AdvancedSettingsButton = styled.div`
|
||||
cursor: pointer;
|
||||
margin-bottom: 16px;
|
||||
margin-top: -10px;
|
||||
color: var(--color-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`
|
||||
|
||||
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
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
30
src/renderer/src/pages/knowledge/components/QuotaTag.tsx
Normal file
30
src/renderer/src/pages/knowledge/components/QuotaTag.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { usePreprocessProvider } from '@renderer/hooks/usePreprocess'
|
||||
import { Tag } from 'antd'
|
||||
import { FC, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const QuotaTag: FC<{ providerId: string; quota?: number }> = ({ providerId, quota }) => {
|
||||
const { t } = useTranslation()
|
||||
const { provider, updatePreprocessProvider } = usePreprocessProvider(providerId)
|
||||
|
||||
useEffect(() => {
|
||||
if (quota) {
|
||||
updatePreprocessProvider({ ...provider, quota })
|
||||
}
|
||||
}, [quota])
|
||||
|
||||
return (
|
||||
<>
|
||||
{provider.quota && (
|
||||
<Tag color="orange" style={{ borderRadius: 20, margin: 0 }}>
|
||||
{t('knowledge.quota', {
|
||||
name: provider.name,
|
||||
quota: provider.quota
|
||||
})}
|
||||
</Tag>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default QuotaTag
|
||||
@@ -1,7 +1,8 @@
|
||||
import { CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons'
|
||||
import { KnowledgeBase, ProcessingStatus } from '@renderer/types'
|
||||
import { Progress, Tooltip } from 'antd'
|
||||
import { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { FC, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@@ -9,64 +10,82 @@ interface StatusIconProps {
|
||||
sourceId: string
|
||||
base: KnowledgeBase
|
||||
getProcessingStatus: (sourceId: string) => ProcessingStatus | undefined
|
||||
getProcessingPercent?: (sourceId: string) => number | undefined
|
||||
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 status = getProcessingStatus(sourceId)
|
||||
const percent = getProcessingPercent?.(sourceId)
|
||||
const item = base.items.find((item) => item.id === sourceId)
|
||||
const errorText = item?.processingError
|
||||
|
||||
if (!status) {
|
||||
if (item?.uniqueId) {
|
||||
const statusDisplay = useMemo(() => {
|
||||
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 (
|
||||
<Tooltip title={t('knowledge.status_completed')} placement="left">
|
||||
<CheckCircleOutlined style={{ color: '#52c41a' }} />
|
||||
<Tooltip title={t('knowledge.status_new')} placement="left">
|
||||
<StatusDot $status="new" />
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Tooltip title={t('knowledge.status_new')} placement="left">
|
||||
<StatusDot $status="new" />
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return (
|
||||
<Tooltip title={t('knowledge.status_pending')} placement="left">
|
||||
<StatusDot $status="pending" />
|
||||
</Tooltip>
|
||||
)
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return (
|
||||
<Tooltip title={t('knowledge.status_pending')} placement="left">
|
||||
<StatusDot $status="pending" />
|
||||
</Tooltip>
|
||||
)
|
||||
|
||||
case 'processing': {
|
||||
return type === 'directory' ? (
|
||||
<Progress type="circle" size={14} percent={Number(percent?.toFixed(0))} />
|
||||
) : (
|
||||
<Tooltip title={t('knowledge.status_processing')} placement="left">
|
||||
<StatusDot $status="processing" />
|
||||
</Tooltip>
|
||||
)
|
||||
case 'processing': {
|
||||
return type === 'directory' || type === 'file' ? (
|
||||
<Progress type="circle" size={14} percent={Number(progress?.toFixed(0))} />
|
||||
) : (
|
||||
<Tooltip title={t('knowledge.status_processing')} placement="left">
|
||||
<StatusDot $status="processing" />
|
||||
</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':
|
||||
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
|
||||
}
|
||||
}, [status, item?.uniqueId, type, progress, errorText, t])
|
||||
|
||||
return statusDisplay
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
})
|
||||
|
||||
@@ -16,7 +16,7 @@ import FileManager from '@renderer/services/FileManager'
|
||||
import { translateText } from '@renderer/services/TranslateService'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
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 { getErrorMessage, uuid } from '@renderer/utils'
|
||||
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 [spaceClickCount, setSpaceClickCount] = useState(0)
|
||||
const [isTranslating, setIsTranslating] = useState(false)
|
||||
const [fileMap, setFileMap] = useState<{ [key: string]: FileType }>({})
|
||||
const [fileMap, setFileMap] = useState<{ [key: string]: FileMetadata }>({})
|
||||
|
||||
const { t } = useTranslation()
|
||||
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 () => {
|
||||
@@ -722,7 +722,7 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => {
|
||||
listType="picture-card"
|
||||
beforeUpload={(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 })
|
||||
return false // 阻止默认上传行为
|
||||
}}>
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||
import FileManager from '@renderer/services/FileManager'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
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 { DmxapiPainting } from '@types'
|
||||
import { Avatar, Button, Input, Radio, Segmented, Select, Switch, Tooltip } from 'antd'
|
||||
@@ -70,7 +70,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
|
||||
const location = useLocation()
|
||||
|
||||
interface FileMapType {
|
||||
imageFiles?: FileType[]
|
||||
imageFiles?: FileMetadata[]
|
||||
paths?: string[]
|
||||
}
|
||||
|
||||
@@ -195,19 +195,19 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
|
||||
const currentFiles = prevFileMap.imageFiles || []
|
||||
const currentPaths = prevFileMap.paths || []
|
||||
|
||||
let newFiles: FileType[]
|
||||
let newFiles: FileMetadata[]
|
||||
let newPaths: string[]
|
||||
|
||||
if (index !== undefined) {
|
||||
// 替换指定索引的图片
|
||||
newFiles = [...currentFiles]
|
||||
newFiles[index] = file as FileType
|
||||
newFiles[index] = file as FileMetadata
|
||||
|
||||
newPaths = [...currentPaths]
|
||||
newPaths[index] = path
|
||||
} else {
|
||||
// 添加新图片到最后
|
||||
newFiles = [...currentFiles, file as FileType]
|
||||
newFiles = [...currentFiles, file as FileMetadata]
|
||||
newPaths = [...currentPaths, path]
|
||||
}
|
||||
|
||||
@@ -465,7 +465,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
|
||||
// 下载图像
|
||||
if (urls.length > 0) {
|
||||
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 (painting.autoCreate && painting.files.length > 0) {
|
||||
|
||||
@@ -22,7 +22,7 @@ import FileManager from '@renderer/services/FileManager'
|
||||
import { translateText } from '@renderer/services/TranslateService'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
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 { Button, Input, InputNumber, Radio, Select, Slider, Switch, Tooltip } from 'antd'
|
||||
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)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { DeleteOutlined } from '@ant-design/icons'
|
||||
import IcImageUp from '@renderer/assets/images/paintings/ic_ImageUp.svg'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import type { FileType } from '@renderer/types'
|
||||
import { FileMetadata } from '@renderer/types'
|
||||
import { Popconfirm, Upload } from 'antd'
|
||||
import { Button } from 'antd'
|
||||
import type { RcFile, UploadProps } from 'antd/es/upload'
|
||||
@@ -10,7 +10,7 @@ import styled from 'styled-components'
|
||||
|
||||
interface ImageUploaderProps {
|
||||
fileMap: {
|
||||
imageFiles?: FileType[]
|
||||
imageFiles?: FileMetadata[]
|
||||
paths?: string[]
|
||||
}
|
||||
maxImages: number
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CacheService } from '@renderer/services/CacheService'
|
||||
import { FileType, TokenFluxPainting } from '@renderer/types'
|
||||
import { FileMetadata, TokenFluxPainting } from '@renderer/types'
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -176,10 +176,7 @@ const GeneralSettings: FC = () => {
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.notification.knowledge_embed')}</SettingRowTitle>
|
||||
<Switch
|
||||
checked={notificationSettings.knowledgeEmbed}
|
||||
onChange={(v) => handleNotificationChange('knowledgeEmbed', v)}
|
||||
/>
|
||||
<Switch checked={notificationSettings.knowledge} onChange={(v) => handleNotificationChange('knowledge', v)} />
|
||||
</SettingRow>
|
||||
</SettingGroup>
|
||||
<SettingGroup theme={theme}>
|
||||
|
||||
@@ -252,7 +252,7 @@ const GithubCopilotSettings: FC<GithubCopilotSettingsProps> = ({ provider: initi
|
||||
min={1}
|
||||
max={60}
|
||||
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 })}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
@@ -3,11 +3,11 @@ import ModelSettings from '@renderer/pages/settings/ModelSettings/ModelSettings'
|
||||
import {
|
||||
Cloud,
|
||||
Command,
|
||||
Globe,
|
||||
HardDrive,
|
||||
Info,
|
||||
MonitorCog,
|
||||
Package,
|
||||
PencilRuler,
|
||||
Rocket,
|
||||
Settings2,
|
||||
SquareTerminal,
|
||||
@@ -31,7 +31,7 @@ import QuickAssistantSettings from './QuickAssistantSettings'
|
||||
import QuickPhraseSettings from './QuickPhraseSettings'
|
||||
import SelectionAssistantSettings from './SelectionAssistantSettings/SelectionAssistantSettings'
|
||||
import ShortcutSettings from './ShortcutSettings'
|
||||
import WebSearchSettings from './WebSearchSettings'
|
||||
import ToolSettings from './ToolSettings'
|
||||
|
||||
const SettingsPage: FC = () => {
|
||||
const { pathname } = useLocation()
|
||||
@@ -59,10 +59,10 @@ const SettingsPage: FC = () => {
|
||||
{t('settings.model')}
|
||||
</MenuItem>
|
||||
</MenuItemLink>
|
||||
<MenuItemLink to="/settings/web-search">
|
||||
<MenuItem className={isRoute('/settings/web-search')}>
|
||||
<Globe size={18} />
|
||||
{t('settings.websearch.title')}
|
||||
<MenuItemLink to="/settings/tool">
|
||||
<MenuItem className={isRoute('/settings/tool')}>
|
||||
<PencilRuler size={18} />
|
||||
{t('settings.tool.title')}
|
||||
</MenuItem>
|
||||
</MenuItemLink>
|
||||
<MenuItemLink to="/settings/mcp">
|
||||
@@ -124,9 +124,9 @@ const SettingsPage: FC = () => {
|
||||
<Routes>
|
||||
<Route path="provider" element={<ProvidersList />} />
|
||||
<Route path="model" element={<ModelSettings />} />
|
||||
<Route path="web-search" element={<WebSearchSettings />} />
|
||||
<Route path="mcp/*" element={<MCPSettings />} />
|
||||
<Route path="general" element={<GeneralSettings />} />
|
||||
<Route path="tool/*" element={<ToolSettings />} />
|
||||
<Route path="mcp" element={<MCPSettings />} />
|
||||
<Route path="general/*" element={<GeneralSettings />} />
|
||||
<Route path="display" element={<DisplaySettings />} />
|
||||
<Route path="shortcut" element={<ShortcutSettings />} />
|
||||
<Route path="quickAssistant" element={<QuickAssistantSettings />} />
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { ExportOutlined } from '@ant-design/icons'
|
||||
import { getOcrProviderLogo, OCR_PROVIDER_CONFIG } from '@renderer/config/ocrProviders'
|
||||
import { useOcrProvider } from '@renderer/hooks/useOcr'
|
||||
import { formatApiKeys } from '@renderer/services/ApiService'
|
||||
import { OcrProvider } from '@renderer/types'
|
||||
import { hasObjectKey } from '@renderer/utils'
|
||||
import { Avatar, Divider, Flex, Input, InputNumber, Segmented } from 'antd'
|
||||
import Link from 'antd/es/typography/Link'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import {
|
||||
SettingDivider,
|
||||
SettingHelpLink,
|
||||
SettingHelpText,
|
||||
SettingHelpTextRow,
|
||||
SettingRow,
|
||||
SettingRowTitle,
|
||||
SettingSubtitle,
|
||||
SettingTitle
|
||||
} from '../..'
|
||||
|
||||
interface Props {
|
||||
provider: OcrProvider
|
||||
}
|
||||
|
||||
const OcrProviderSettings: FC<Props> = ({ provider: _provider }) => {
|
||||
const { provider: ocrProvider, updateOcrProvider } = useOcrProvider(_provider.id)
|
||||
const { t } = useTranslation()
|
||||
const [apiKey, setApiKey] = useState(ocrProvider.apiKey || '')
|
||||
const [apiHost, setApiHost] = useState(ocrProvider.apiHost || '')
|
||||
const [options, setOptions] = useState(ocrProvider.options || {})
|
||||
|
||||
const ocrProviderConfig = OCR_PROVIDER_CONFIG[ocrProvider.id]
|
||||
const apiKeyWebsite = ocrProviderConfig?.websites?.apiKey
|
||||
const officialWebsite = ocrProviderConfig?.websites?.official
|
||||
|
||||
useEffect(() => {
|
||||
setApiKey(ocrProvider.apiKey ?? '')
|
||||
setApiHost(ocrProvider.apiHost ?? '')
|
||||
setOptions(ocrProvider.options ?? {})
|
||||
}, [ocrProvider.apiKey, ocrProvider.apiHost, ocrProvider.options])
|
||||
|
||||
const onUpdateApiKey = () => {
|
||||
if (apiKey !== ocrProvider.apiKey) {
|
||||
updateOcrProvider({ ...ocrProvider, apiKey })
|
||||
}
|
||||
}
|
||||
|
||||
const onUpdateApiHost = () => {
|
||||
let trimmedHost = apiHost?.trim() || ''
|
||||
if (trimmedHost.endsWith('/')) {
|
||||
trimmedHost = trimmedHost.slice(0, -1)
|
||||
}
|
||||
if (trimmedHost !== ocrProvider.apiHost) {
|
||||
updateOcrProvider({ ...ocrProvider, apiHost: trimmedHost })
|
||||
} else {
|
||||
setApiHost(ocrProvider.apiHost || '')
|
||||
}
|
||||
}
|
||||
|
||||
const onUpdateOptions = (key: string, value: any) => {
|
||||
const newOptions = { ...options, [key]: value }
|
||||
setOptions(newOptions)
|
||||
updateOcrProvider({ ...ocrProvider, options: newOptions })
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingTitle>
|
||||
<Flex align="center" gap={8}>
|
||||
<ProviderLogo shape="square" src={getOcrProviderLogo(ocrProvider.id)} size={16} />
|
||||
|
||||
<ProviderName> {ocrProvider.name}</ProviderName>
|
||||
{officialWebsite && ocrProviderConfig?.websites && (
|
||||
<Link target="_blank" href={ocrProviderConfig.websites.official}>
|
||||
<ExportOutlined style={{ color: 'var(--color-text)', fontSize: '12px' }} />
|
||||
</Link>
|
||||
)}
|
||||
</Flex>
|
||||
</SettingTitle>
|
||||
<Divider style={{ width: '100%', margin: '10px 0' }} />
|
||||
{hasObjectKey(ocrProvider, 'apiKey') && (
|
||||
<>
|
||||
<SettingSubtitle style={{ marginTop: 5, marginBottom: 10 }}>{t('settings.provider.api_key')}</SettingSubtitle>
|
||||
<Flex gap={8}>
|
||||
<Input.Password
|
||||
value={apiKey}
|
||||
placeholder={t('settings.provider.api_key')}
|
||||
onChange={(e) => setApiKey(formatApiKeys(e.target.value))}
|
||||
onBlur={onUpdateApiKey}
|
||||
spellCheck={false}
|
||||
type="password"
|
||||
autoFocus={apiKey === ''}
|
||||
/>
|
||||
</Flex>
|
||||
<SettingHelpTextRow style={{ justifyContent: 'space-between', marginTop: 5 }}>
|
||||
<SettingHelpLink target="_blank" href={apiKeyWebsite}>
|
||||
{t('settings.provider.get_api_key')}
|
||||
</SettingHelpLink>
|
||||
<SettingHelpText>{t('settings.provider.api_key.tip')}</SettingHelpText>
|
||||
</SettingHelpTextRow>
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasObjectKey(ocrProvider, 'apiHost') && (
|
||||
<>
|
||||
<SettingSubtitle style={{ marginTop: 5, marginBottom: 10 }}>
|
||||
{t('settings.provider.api_host')}
|
||||
</SettingSubtitle>
|
||||
<Flex>
|
||||
<Input
|
||||
value={apiHost}
|
||||
placeholder={t('settings.provider.api_host')}
|
||||
onChange={(e) => setApiHost(e.target.value)}
|
||||
onBlur={onUpdateApiHost}
|
||||
/>
|
||||
</Flex>
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasObjectKey(ocrProvider, 'options') && ocrProvider.id === 'system' && (
|
||||
<>
|
||||
<SettingDivider style={{ marginTop: 15, marginBottom: 12 }} />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.tool.ocr.mac_system_ocr_options.mode.title')}</SettingRowTitle>
|
||||
<Segmented
|
||||
options={[
|
||||
{
|
||||
label: t('settings.tool.ocr.mac_system_ocr_options.mode.accurate'),
|
||||
value: 1
|
||||
},
|
||||
{
|
||||
label: t('settings.tool.ocr.mac_system_ocr_options.mode.fast'),
|
||||
value: 0
|
||||
}
|
||||
]}
|
||||
value={options.recognitionLevel}
|
||||
onChange={(value) => onUpdateOptions('recognitionLevel', value)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider style={{ marginTop: 15, marginBottom: 12 }} />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.tool.ocr.mac_system_ocr_options.min_confidence')}</SettingRowTitle>
|
||||
<InputNumber
|
||||
value={options.minConfidence}
|
||||
onChange={(value) => onUpdateOptions('minConfidence', value)}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.1}
|
||||
/>
|
||||
</SettingRow>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const ProviderName = styled.span`
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
`
|
||||
const ProviderLogo = styled(Avatar)`
|
||||
border: 0.5px solid var(--color-border);
|
||||
`
|
||||
|
||||
export default OcrProviderSettings
|
||||
@@ -0,0 +1,58 @@
|
||||
import { isMac } from '@renderer/config/constant'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useDefaultOcrProvider, useOcrProviders } from '@renderer/hooks/useOcr'
|
||||
import { PreprocessProvider } from '@renderer/types'
|
||||
import { Select } from 'antd'
|
||||
import { FC, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { SettingContainer, SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '../..'
|
||||
import OcrProviderSettings from './OcrSettings'
|
||||
|
||||
const OcrSettings: FC = () => {
|
||||
const { ocrProviders } = useOcrProviders()
|
||||
const { provider: defaultProvider, setDefaultOcrProvider } = useDefaultOcrProvider()
|
||||
const { t } = useTranslation()
|
||||
const [selectedProvider, setSelectedProvider] = useState<PreprocessProvider | undefined>(defaultProvider)
|
||||
const { theme: themeMode } = useTheme()
|
||||
|
||||
function updateSelectedOcrProvider(providerId: string) {
|
||||
const provider = ocrProviders.find((p) => p.id === providerId)
|
||||
if (!provider) {
|
||||
return
|
||||
}
|
||||
setDefaultOcrProvider(provider)
|
||||
setSelectedProvider(provider)
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingContainer theme={themeMode}>
|
||||
<SettingGroup theme={themeMode}>
|
||||
<SettingTitle>{t('settings.tool.ocr.title')}</SettingTitle>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.tool.ocr.provider')}</SettingRowTitle>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<Select
|
||||
value={selectedProvider?.id}
|
||||
style={{ width: '200px' }}
|
||||
onChange={(value: string) => updateSelectedOcrProvider(value)}
|
||||
placeholder={t('settings.tool.ocr.provider_placeholder')}
|
||||
options={ocrProviders.map((p) => ({
|
||||
value: p.id,
|
||||
label: p.name,
|
||||
disabled: !isMac && p.id === 'system' // 在非 Mac 系统下禁用 system 选项
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</SettingRow>
|
||||
</SettingGroup>
|
||||
{selectedProvider && (
|
||||
<SettingGroup theme={themeMode}>
|
||||
<OcrProviderSettings provider={selectedProvider} />
|
||||
</SettingGroup>
|
||||
)}
|
||||
</SettingContainer>
|
||||
)
|
||||
}
|
||||
export default OcrSettings
|
||||
@@ -0,0 +1,168 @@
|
||||
import { ExportOutlined } from '@ant-design/icons'
|
||||
import { getPreprocessProviderLogo, PREPROCESS_PROVIDER_CONFIG } from '@renderer/config/preprocessProviders'
|
||||
import { usePreprocessProvider } from '@renderer/hooks/usePreprocess'
|
||||
import { formatApiKeys } from '@renderer/services/ApiService'
|
||||
import { PreprocessProvider } from '@renderer/types'
|
||||
import { hasObjectKey } from '@renderer/utils'
|
||||
import { Avatar, Divider, Flex, Input, InputNumber, Segmented } from 'antd'
|
||||
import Link from 'antd/es/typography/Link'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import {
|
||||
SettingDivider,
|
||||
SettingHelpLink,
|
||||
SettingHelpText,
|
||||
SettingHelpTextRow,
|
||||
SettingRow,
|
||||
SettingRowTitle,
|
||||
SettingSubtitle,
|
||||
SettingTitle
|
||||
} from '../..'
|
||||
|
||||
interface Props {
|
||||
provider: PreprocessProvider
|
||||
}
|
||||
|
||||
const PreprocessProviderSettings: FC<Props> = ({ provider: _provider }) => {
|
||||
const { provider: preprocessProvider, updatePreprocessProvider } = usePreprocessProvider(_provider.id)
|
||||
const { t } = useTranslation()
|
||||
const [apiKey, setApiKey] = useState(preprocessProvider.apiKey || '')
|
||||
const [apiHost, setApiHost] = useState(preprocessProvider.apiHost || '')
|
||||
const [options, setOptions] = useState(preprocessProvider.options || {})
|
||||
|
||||
const preprocessProviderConfig = PREPROCESS_PROVIDER_CONFIG[preprocessProvider.id]
|
||||
const apiKeyWebsite = preprocessProviderConfig?.websites?.apiKey
|
||||
const officialWebsite = preprocessProviderConfig?.websites?.official
|
||||
|
||||
useEffect(() => {
|
||||
setApiKey(preprocessProvider.apiKey ?? '')
|
||||
setApiHost(preprocessProvider.apiHost ?? '')
|
||||
setOptions(preprocessProvider.options ?? {})
|
||||
}, [preprocessProvider.apiKey, preprocessProvider.apiHost, preprocessProvider.options])
|
||||
|
||||
const onUpdateApiKey = () => {
|
||||
if (apiKey !== preprocessProvider.apiKey) {
|
||||
updatePreprocessProvider({ ...preprocessProvider, apiKey })
|
||||
}
|
||||
}
|
||||
|
||||
const onUpdateApiHost = () => {
|
||||
let trimmedHost = apiHost?.trim() || ''
|
||||
if (trimmedHost.endsWith('/')) {
|
||||
trimmedHost = trimmedHost.slice(0, -1)
|
||||
}
|
||||
if (trimmedHost !== preprocessProvider.apiHost) {
|
||||
updatePreprocessProvider({ ...preprocessProvider, apiHost: trimmedHost })
|
||||
} else {
|
||||
setApiHost(preprocessProvider.apiHost || '')
|
||||
}
|
||||
}
|
||||
|
||||
const onUpdateOptions = (key: string, value: any) => {
|
||||
const newOptions = { ...options, [key]: value }
|
||||
setOptions(newOptions)
|
||||
updatePreprocessProvider({ ...preprocessProvider, options: newOptions })
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingTitle>
|
||||
<Flex align="center" gap={8}>
|
||||
<ProviderLogo shape="square" src={getPreprocessProviderLogo(preprocessProvider.id)} size={16} />
|
||||
|
||||
<ProviderName> {preprocessProvider.name}</ProviderName>
|
||||
{officialWebsite && preprocessProviderConfig?.websites && (
|
||||
<Link target="_blank" href={preprocessProviderConfig.websites.official}>
|
||||
<ExportOutlined style={{ color: 'var(--color-text)', fontSize: '12px' }} />
|
||||
</Link>
|
||||
)}
|
||||
</Flex>
|
||||
</SettingTitle>
|
||||
<Divider style={{ width: '100%', margin: '10px 0' }} />
|
||||
{hasObjectKey(preprocessProvider, 'apiKey') && (
|
||||
<>
|
||||
<SettingSubtitle style={{ marginTop: 5, marginBottom: 10 }}>{t('settings.provider.api_key')}</SettingSubtitle>
|
||||
<Flex gap={8}>
|
||||
<Input.Password
|
||||
value={apiKey}
|
||||
placeholder={t('settings.provider.api_key')}
|
||||
onChange={(e) => setApiKey(formatApiKeys(e.target.value))}
|
||||
onBlur={onUpdateApiKey}
|
||||
spellCheck={false}
|
||||
type="password"
|
||||
autoFocus={apiKey === ''}
|
||||
/>
|
||||
</Flex>
|
||||
<SettingHelpTextRow style={{ justifyContent: 'space-between', marginTop: 5 }}>
|
||||
<SettingHelpLink target="_blank" href={apiKeyWebsite}>
|
||||
{t('settings.provider.get_api_key')}
|
||||
</SettingHelpLink>
|
||||
<SettingHelpText>{t('settings.provider.api_key.tip')}</SettingHelpText>
|
||||
</SettingHelpTextRow>
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasObjectKey(preprocessProvider, 'apiHost') && (
|
||||
<>
|
||||
<SettingSubtitle style={{ marginTop: 5, marginBottom: 10 }}>
|
||||
{t('settings.provider.api_host')}
|
||||
</SettingSubtitle>
|
||||
<Flex>
|
||||
<Input
|
||||
value={apiHost}
|
||||
placeholder={t('settings.provider.api_host')}
|
||||
onChange={(e) => setApiHost(e.target.value)}
|
||||
onBlur={onUpdateApiHost}
|
||||
/>
|
||||
</Flex>
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasObjectKey(preprocessProvider, 'options') && preprocessProvider.id === 'system' && (
|
||||
<>
|
||||
<SettingDivider style={{ marginTop: 15, marginBottom: 12 }} />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.tool.preprocess.mac_system_ocr_options.mode.title')}</SettingRowTitle>
|
||||
<Segmented
|
||||
options={[
|
||||
{
|
||||
label: t('settings.tool.preprocess.mac_system_ocr_options.mode.accurate'),
|
||||
value: 1
|
||||
},
|
||||
{
|
||||
label: t('settings.tool.preprocess.mac_system_ocr_options.mode.fast'),
|
||||
value: 0
|
||||
}
|
||||
]}
|
||||
value={options.recognitionLevel}
|
||||
onChange={(value) => onUpdateOptions('recognitionLevel', value)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider style={{ marginTop: 15, marginBottom: 12 }} />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.tool.preprocess.mac_system_ocr_options.min_confidence')}</SettingRowTitle>
|
||||
<InputNumber
|
||||
value={options.minConfidence}
|
||||
onChange={(value) => onUpdateOptions('minConfidence', value)}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.1}
|
||||
/>
|
||||
</SettingRow>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const ProviderName = styled.span`
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
`
|
||||
const ProviderLogo = styled(Avatar)`
|
||||
border: 0.5px solid var(--color-border);
|
||||
`
|
||||
|
||||
export default PreprocessProviderSettings
|
||||
@@ -0,0 +1,58 @@
|
||||
import { isMac } from '@renderer/config/constant'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useDefaultPreprocessProvider, usePreprocessProviders } from '@renderer/hooks/usePreprocess'
|
||||
import { PreprocessProvider } from '@renderer/types'
|
||||
import { Select } from 'antd'
|
||||
import { FC, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { SettingContainer, SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '../..'
|
||||
import PreprocessProviderSettings from './PreprocessSettings'
|
||||
|
||||
const PreprocessSettings: FC = () => {
|
||||
const { preprocessProviders } = usePreprocessProviders()
|
||||
const { provider: defaultProvider, setDefaultPreprocessProvider } = useDefaultPreprocessProvider()
|
||||
const { t } = useTranslation()
|
||||
const [selectedProvider, setSelectedProvider] = useState<PreprocessProvider | undefined>(defaultProvider)
|
||||
const { theme: themeMode } = useTheme()
|
||||
|
||||
function updateSelectedPreprocessProvider(providerId: string) {
|
||||
const provider = preprocessProviders.find((p) => p.id === providerId)
|
||||
if (!provider) {
|
||||
return
|
||||
}
|
||||
setDefaultPreprocessProvider(provider)
|
||||
setSelectedProvider(provider)
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingContainer theme={themeMode}>
|
||||
<SettingGroup theme={themeMode}>
|
||||
<SettingTitle>{t('settings.tool.preprocess.title')}</SettingTitle>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.tool.preprocess.provider')}</SettingRowTitle>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<Select
|
||||
value={selectedProvider?.id}
|
||||
style={{ width: '200px' }}
|
||||
onChange={(value: string) => updateSelectedPreprocessProvider(value)}
|
||||
placeholder={t('settings.tool.preprocess.provider_placeholder')}
|
||||
options={preprocessProviders.map((p) => ({
|
||||
value: p.id,
|
||||
label: p.name,
|
||||
disabled: !isMac && p.id === 'system' // 在非 Mac 系统下禁用 system 选项
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</SettingRow>
|
||||
</SettingGroup>
|
||||
{selectedProvider && (
|
||||
<SettingGroup theme={themeMode}>
|
||||
<PreprocessProviderSettings provider={selectedProvider} />
|
||||
</SettingGroup>
|
||||
)}
|
||||
</SettingContainer>
|
||||
)
|
||||
}
|
||||
export default PreprocessSettings
|
||||
@@ -38,7 +38,7 @@ const PopupContainer: React.FC<Props> = ({ title, resolve }) => {
|
||||
const name = values.name?.trim() || url
|
||||
|
||||
if (!url) {
|
||||
window.message.error(t('settings.websearch.url_required'))
|
||||
window.message.error(t('settings.tool.websearch.url_required'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ const PopupContainer: React.FC<Props> = ({ title, resolve }) => {
|
||||
try {
|
||||
new URL(url)
|
||||
} catch (e) {
|
||||
window.message.error(t('settings.websearch.url_invalid'))
|
||||
window.message.error(t('settings.tool.websearch.url_invalid'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ const PopupContainer: React.FC<Props> = ({ title, resolve }) => {
|
||||
colon={false}
|
||||
style={{ marginTop: 25 }}
|
||||
onFinish={onFinish}>
|
||||
<Form.Item name="url" label={t('settings.websearch.subscribe_url')} rules={[{ required: true }]}>
|
||||
<Form.Item name="url" label={t('settings.tool.websearch.subscribe_url')} rules={[{ required: true }]}>
|
||||
<Input
|
||||
placeholder="https://git.io/ublacklist"
|
||||
spellCheck={false}
|
||||
@@ -86,12 +86,12 @@ const PopupContainer: React.FC<Props> = ({ title, resolve }) => {
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label={t('settings.websearch.subscribe_name')}>
|
||||
<Input placeholder={t('settings.websearch.subscribe_name.placeholder')} spellCheck={false} />
|
||||
<Form.Item name="name" label={t('settings.tool.websearch.subscribe_name')}>
|
||||
<Input placeholder={t('settings.tool.websearch.subscribe_name.placeholder')} spellCheck={false} />
|
||||
</Form.Item>
|
||||
<Form.Item label=" ">
|
||||
<Button type="primary" htmlType="submit">
|
||||
{t('settings.websearch.subscribe_add')}
|
||||
{t('settings.tool.websearch.subscribe_add')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
@@ -6,7 +6,7 @@ import { t } from 'i18next'
|
||||
import { Info } from 'lucide-react'
|
||||
import { FC } from 'react'
|
||||
|
||||
import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..'
|
||||
import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '../..'
|
||||
|
||||
const BasicSettings: FC = () => {
|
||||
const { theme } = useTheme()
|
||||
@@ -22,27 +22,27 @@ const BasicSettings: FC = () => {
|
||||
<SettingTitle>{t('settings.general.title')}</SettingTitle>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.websearch.search_with_time')}</SettingRowTitle>
|
||||
<SettingRowTitle>{t('settings.tool.websearch.search_with_time')}</SettingRowTitle>
|
||||
<Switch checked={searchWithTime} onChange={(checked) => dispatch(setSearchWithTime(checked))} />
|
||||
</SettingRow>
|
||||
<SettingDivider style={{ marginTop: 15, marginBottom: 10 }} />
|
||||
<SettingRow style={{ height: 40 }}>
|
||||
<SettingRowTitle>{t('settings.websearch.search_max_result')}</SettingRowTitle>
|
||||
<SettingRowTitle>{t('settings.tool.websearch.search_max_result')}</SettingRowTitle>
|
||||
<Slider
|
||||
defaultValue={maxResults}
|
||||
style={{ width: '200px' }}
|
||||
min={1}
|
||||
max={20}
|
||||
step={1}
|
||||
marks={{ 1: '1', 5: t('settings.websearch.search_result_default'), 20: '20' }}
|
||||
marks={{ 1: '1', 5: t('settings.tool.websearch.search_result_default'), 20: '20' }}
|
||||
onChangeComplete={(value) => dispatch(setMaxResult(value))}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider style={{ marginTop: 15, marginBottom: 10 }} />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>
|
||||
{t('settings.websearch.content_limit')}
|
||||
<Tooltip title={t('settings.websearch.content_limit_tooltip')} placement="right">
|
||||
{t('settings.tool.websearch.content_limit')}
|
||||
<Tooltip title={t('settings.tool.websearch.content_limit_tooltip')} placement="right">
|
||||
<Info size={16} color="var(--color-icon)" style={{ marginLeft: 5, cursor: 'pointer' }} />
|
||||
</Tooltip>
|
||||
</SettingRowTitle>
|
||||
@@ -10,7 +10,7 @@ import TextArea from 'antd/es/input/TextArea'
|
||||
import { t } from 'i18next'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
|
||||
import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..'
|
||||
import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '../..'
|
||||
import AddSubscribePopup from './AddSubscribePopup'
|
||||
|
||||
type TableRowSelection<T extends object = object> = TableProps<T>['rowSelection']
|
||||
@@ -131,7 +131,7 @@ const BlacklistSettings: FC = () => {
|
||||
console.error(`Error updating subscribe source ${source.url}:`, error)
|
||||
// 显示具体源更新失败的消息
|
||||
window.message.warning({
|
||||
content: t('settings.websearch.subscribe_source_update_failed', { url: source.url }),
|
||||
content: t('settings.tool.websearch.subscribe_source_update_failed', { url: source.url }),
|
||||
duration: 3
|
||||
})
|
||||
}
|
||||
@@ -143,7 +143,7 @@ const BlacklistSettings: FC = () => {
|
||||
setSubscribeValid(true)
|
||||
// 显示成功消息
|
||||
window.message.success({
|
||||
content: t('settings.websearch.subscribe_update_success'),
|
||||
content: t('settings.tool.websearch.subscribe_update_success'),
|
||||
duration: 2
|
||||
})
|
||||
setTimeout(() => setSubscribeValid(false), 3000)
|
||||
@@ -154,7 +154,7 @@ const BlacklistSettings: FC = () => {
|
||||
} catch (error) {
|
||||
console.error('Error updating subscribes:', error)
|
||||
window.message.error({
|
||||
content: t('settings.websearch.subscribe_update_failed'),
|
||||
content: t('settings.tool.websearch.subscribe_update_failed'),
|
||||
duration: 2
|
||||
})
|
||||
}
|
||||
@@ -165,7 +165,7 @@ const BlacklistSettings: FC = () => {
|
||||
async function handleAddSubscribe() {
|
||||
setSubscribeChecking(true)
|
||||
const result = await AddSubscribePopup.show({
|
||||
title: t('settings.websearch.subscribe_add')
|
||||
title: t('settings.tool.websearch.subscribe_add')
|
||||
})
|
||||
|
||||
if (result && result.url) {
|
||||
@@ -185,14 +185,14 @@ const BlacklistSettings: FC = () => {
|
||||
setSubscribeValid(true)
|
||||
// 显示成功消息
|
||||
window.message.success({
|
||||
content: t('settings.websearch.subscribe_add_success'),
|
||||
content: t('settings.tool.websearch.subscribe_add_success'),
|
||||
duration: 2
|
||||
})
|
||||
setTimeout(() => setSubscribeValid(false), 3000)
|
||||
} catch (error) {
|
||||
setSubscribeValid(false)
|
||||
window.message.error({
|
||||
content: t('settings.websearch.subscribe_add_failed'),
|
||||
content: t('settings.tool.websearch.subscribe_add_failed'),
|
||||
duration: 2
|
||||
})
|
||||
}
|
||||
@@ -218,32 +218,32 @@ const BlacklistSettings: FC = () => {
|
||||
return (
|
||||
<>
|
||||
<SettingGroup theme={theme}>
|
||||
<SettingTitle>{t('settings.websearch.blacklist')}</SettingTitle>
|
||||
<SettingTitle>{t('settings.tool.websearch.blacklist')}</SettingTitle>
|
||||
<SettingDivider />
|
||||
<SettingRow style={{ marginBottom: 10 }}>
|
||||
<SettingRowTitle>{t('settings.websearch.blacklist_description')}</SettingRowTitle>
|
||||
<SettingRowTitle>{t('settings.tool.websearch.blacklist_description')}</SettingRowTitle>
|
||||
</SettingRow>
|
||||
<TextArea
|
||||
value={blacklistInput}
|
||||
onChange={(e) => setBlacklistInput(e.target.value)}
|
||||
placeholder={t('settings.websearch.blacklist_tooltip')}
|
||||
placeholder={t('settings.tool.websearch.blacklist_tooltip')}
|
||||
autoSize={{ minRows: 4, maxRows: 8 }}
|
||||
rows={4}
|
||||
/>
|
||||
<Button onClick={() => updateManualBlacklist(blacklistInput)} style={{ marginTop: 10 }}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
{errFormat && <Alert message={t('settings.websearch.blacklist_tooltip')} type="error" />}
|
||||
{errFormat && <Alert message={t('settings.tool.websearch.blacklist_tooltip')} type="error" />}
|
||||
</SettingGroup>
|
||||
<SettingGroup theme={theme}>
|
||||
<SettingTitle>
|
||||
{t('settings.websearch.subscribe')}
|
||||
{t('settings.tool.websearch.subscribe')}
|
||||
<Button
|
||||
type={subscribeValid ? 'primary' : 'default'}
|
||||
ghost={subscribeValid}
|
||||
disabled={subscribeChecking}
|
||||
onClick={handleAddSubscribe}>
|
||||
{t('settings.websearch.subscribe_add')}
|
||||
{t('settings.tool.websearch.subscribe_add')}
|
||||
</Button>
|
||||
</SettingTitle>
|
||||
<SettingDivider />
|
||||
@@ -266,11 +266,11 @@ const BlacklistSettings: FC = () => {
|
||||
) : subscribeValid ? (
|
||||
<CheckOutlined />
|
||||
) : (
|
||||
t('settings.websearch.subscribe_update')
|
||||
t('settings.tool.websearch.subscribe_update')
|
||||
)}
|
||||
</Button>
|
||||
<Button style={{ width: 100 }} disabled={selectedRowKeys.length === 0} onClick={handleDeleteSubscribe}>
|
||||
{t('settings.websearch.subscribe_delete')}
|
||||
{t('settings.tool.websearch.subscribe_delete')}
|
||||
</Button>
|
||||
</SettingRow>
|
||||
</div>
|
||||
@@ -12,8 +12,15 @@ import { FC, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { SettingDivider, SettingHelpLink, SettingHelpText, SettingHelpTextRow, SettingSubtitle, SettingTitle } from '..'
|
||||
import ApiCheckPopup from '../ProviderSettings/ApiCheckPopup'
|
||||
import {
|
||||
SettingDivider,
|
||||
SettingHelpLink,
|
||||
SettingHelpText,
|
||||
SettingHelpTextRow,
|
||||
SettingSubtitle,
|
||||
SettingTitle
|
||||
} from '../..'
|
||||
import ApiCheckPopup from '../../ProviderSettings/ApiCheckPopup'
|
||||
|
||||
interface Props {
|
||||
provider: WebSearchProvider
|
||||
@@ -74,7 +81,7 @@ const WebSearchProviderSetting: FC<Props> = ({ provider: _provider }) => {
|
||||
async function checkSearch() {
|
||||
if (!provider) {
|
||||
window.message.error({
|
||||
content: t('settings.websearch.no_provider_selected'),
|
||||
content: t('settings.no_provider_selected'),
|
||||
duration: 3,
|
||||
icon: <Info size={18} />,
|
||||
key: 'no-provider-selected'
|
||||
@@ -111,7 +118,9 @@ const WebSearchProviderSetting: FC<Props> = ({ provider: _provider }) => {
|
||||
key: 'api-check',
|
||||
style: { marginTop: '3vh' },
|
||||
duration: valid ? 2 : 8,
|
||||
content: valid ? t('settings.websearch.check_success') : t('settings.websearch.check_failed') + errorMessage
|
||||
content: valid
|
||||
? t('settings.tool.websearch.check_success')
|
||||
: t('settings.tool.websearch.check_failed') + errorMessage
|
||||
})
|
||||
|
||||
setApiValid(valid)
|
||||
@@ -122,7 +131,7 @@ const WebSearchProviderSetting: FC<Props> = ({ provider: _provider }) => {
|
||||
key: 'check-search-error',
|
||||
style: { marginTop: '3vh' },
|
||||
duration: 8,
|
||||
content: t('settings.websearch.check_failed')
|
||||
content: t('settings.tool.websearch.check_failed')
|
||||
})
|
||||
} finally {
|
||||
setApiChecking(false)
|
||||
@@ -169,12 +178,18 @@ const WebSearchProviderSetting: FC<Props> = ({ provider: _provider }) => {
|
||||
type={apiValid ? 'primary' : 'default'}
|
||||
onClick={checkSearch}
|
||||
disabled={apiChecking}>
|
||||
{apiChecking ? <LoadingOutlined spin /> : apiValid ? <CheckOutlined /> : t('settings.websearch.check')}
|
||||
{apiChecking ? (
|
||||
<LoadingOutlined spin />
|
||||
) : apiValid ? (
|
||||
<CheckOutlined />
|
||||
) : (
|
||||
t('settings.tool.websearch.check')
|
||||
)}
|
||||
</Button>
|
||||
</Flex>
|
||||
<SettingHelpTextRow style={{ justifyContent: 'space-between', marginTop: 5 }}>
|
||||
<SettingHelpLink target="_blank" href={apiKeyWebsite}>
|
||||
{t('settings.websearch.get_api_key')}
|
||||
{t('settings.tool.websearch.get_api_key')}
|
||||
</SettingHelpLink>
|
||||
<SettingHelpText>{t('settings.provider.api_key.tip')}</SettingHelpText>
|
||||
</SettingHelpTextRow>
|
||||
@@ -6,7 +6,7 @@ import { Select } from 'antd'
|
||||
import { FC, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { SettingContainer, SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..'
|
||||
import { SettingContainer, SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '../..'
|
||||
import BasicSettings from './BasicSettings'
|
||||
import BlacklistSettings from './BlacklistSettings'
|
||||
import WebSearchProviderSetting from './WebSearchProviderSetting'
|
||||
@@ -32,19 +32,19 @@ const WebSearchSettings: FC = () => {
|
||||
return (
|
||||
<SettingContainer theme={themeMode}>
|
||||
<SettingGroup theme={themeMode}>
|
||||
<SettingTitle>{t('settings.websearch.title')}</SettingTitle>
|
||||
<SettingTitle>{t('settings.tool.websearch.title')}</SettingTitle>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.websearch.search_provider')}</SettingRowTitle>
|
||||
<SettingRowTitle>{t('settings.tool.websearch.search_provider')}</SettingRowTitle>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<Select
|
||||
value={selectedProvider?.id}
|
||||
style={{ width: '200px' }}
|
||||
onChange={(value: string) => updateSelectedWebSearchProvider(value)}
|
||||
placeholder={t('settings.websearch.search_provider_placeholder')}
|
||||
placeholder={t('settings.tool.websearch.search_provider_placeholder')}
|
||||
options={providers.map((p) => ({
|
||||
value: p.id,
|
||||
label: `${p.name} (${hasObjectKey(p, 'apiKey') ? t('settings.websearch.apikey') : t('settings.websearch.free')})`
|
||||
label: `${p.name} (${hasObjectKey(p, 'apiKey') ? t('settings.tool.websearch.apikey') : t('settings.tool.websearch.free')})`
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
58
src/renderer/src/pages/settings/ToolSettings/index.tsx
Normal file
58
src/renderer/src/pages/settings/ToolSettings/index.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { GlobalOutlined } from '@ant-design/icons'
|
||||
import OcrIcon from '@renderer/components/Icons/OcrIcon'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import ListItem from '@renderer/components/ListItem'
|
||||
import { FileCode } from 'lucide-react'
|
||||
import { FC, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import OcrSettings from './OcrSettings'
|
||||
import PreprocessSettings from './PreprocessSettings'
|
||||
import WebSearchSettings from './WebSearchSettings'
|
||||
|
||||
const ToolSettings: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const [menu, setMenu] = useState<string>('web-search')
|
||||
const menuItems = [
|
||||
{ key: 'web-search', title: 'settings.tool.websearch.title', icon: <GlobalOutlined style={{ fontSize: 16 }} /> },
|
||||
{ key: 'preprocess', title: 'settings.tool.preprocess.title', icon: <FileCode size={16} /> },
|
||||
{ key: 'ocr', title: 'settings.tool.ocr.title', icon: <OcrIcon /> }
|
||||
]
|
||||
return (
|
||||
<Container>
|
||||
<MenuList>
|
||||
{menuItems.map((item) => (
|
||||
<ListItem
|
||||
key={item.key}
|
||||
title={t(item.title)}
|
||||
active={menu === item.key}
|
||||
onClick={() => setMenu(item.key)}
|
||||
titleStyle={{ fontWeight: 500 }}
|
||||
icon={item.icon}
|
||||
/>
|
||||
))}
|
||||
</MenuList>
|
||||
{menu == 'web-search' && <WebSearchSettings />}
|
||||
{menu == 'preprocess' && <PreprocessSettings />}
|
||||
{menu == 'ocr' && <OcrSettings />}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled(HStack)`
|
||||
flex: 1;
|
||||
`
|
||||
const MenuList = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
width: var(--settings-width);
|
||||
padding: 12px;
|
||||
border-right: 0.5px solid var(--color-border);
|
||||
height: 100%;
|
||||
.iconfont {
|
||||
line-height: 16px;
|
||||
}
|
||||
`
|
||||
export default ToolSettings
|
||||
@@ -1,9 +1,15 @@
|
||||
import Logger from '@renderer/config/logger'
|
||||
import db from '@renderer/databases'
|
||||
import { getStoreSetting } from '@renderer/hooks/useSettings'
|
||||
import { getKnowledgeBaseParams } from '@renderer/services/KnowledgeService'
|
||||
import { NotificationService } from '@renderer/services/NotificationService'
|
||||
import store from '@renderer/store'
|
||||
import { clearCompletedProcessing, updateBaseItemUniqueId, updateItemProcessingStatus } from '@renderer/store/knowledge'
|
||||
import {
|
||||
clearCompletedProcessing,
|
||||
updateBaseItemIsPreprocessed,
|
||||
updateBaseItemUniqueId,
|
||||
updateItemProcessingStatus
|
||||
} from '@renderer/store/knowledge'
|
||||
import { KnowledgeItem } from '@renderer/types'
|
||||
import { uuid } from '@renderer/utils'
|
||||
import type { LoaderReturn } from '@shared/config/types'
|
||||
@@ -88,6 +94,7 @@ class KnowledgeQueue {
|
||||
|
||||
private async processItem(baseId: string, item: KnowledgeItem): Promise<void> {
|
||||
const notificationService = NotificationService.getInstance()
|
||||
const userId = getStoreSetting('userId')
|
||||
try {
|
||||
if (item.retryCount && item.retryCount >= this.MAX_RETRIES) {
|
||||
Logger.log(`[KnowledgeQueue] Item ${item.id} has reached max retries, skipping`)
|
||||
@@ -132,20 +139,37 @@ class KnowledgeQueue {
|
||||
}
|
||||
break
|
||||
default:
|
||||
result = await window.api.knowledgeBase.add({ base: baseParams, item: sourceItem })
|
||||
result = await window.api.knowledgeBase.add({ base: baseParams, item: sourceItem, userId: userId as string })
|
||||
break
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
throw new Error(`[KnowledgeQueue] Backend processing returned null for item ${item.id}`)
|
||||
}
|
||||
|
||||
if (result.status === 'failed') {
|
||||
Logger.error(`[KnowledgeQueue] Backend processing error for item ${item.id}: ${result.message}`)
|
||||
|
||||
const errorPrefix =
|
||||
result.messageSource === 'embedding'
|
||||
? t('knowledge.status_embedding_failed')
|
||||
: t('knowledge.status_preprocess_failed')
|
||||
|
||||
throw new Error(
|
||||
result.message ? `${errorPrefix}: ${result.message}` : `Backend processing failed for item ${item.id}`
|
||||
)
|
||||
}
|
||||
|
||||
Logger.log(`[KnowledgeQueue] Successfully completed processing item ${item.id}`)
|
||||
|
||||
notificationService.send({
|
||||
id: uuid(),
|
||||
type: 'success',
|
||||
title: t('knowledge.status_completed"'),
|
||||
title: t('knowledge.status_completed'),
|
||||
message: t('notification.knowledge.success', { type: item.type }),
|
||||
silent: false,
|
||||
timestamp: Date.now(),
|
||||
source: 'knowledgeEmbed'
|
||||
source: 'knowledge'
|
||||
})
|
||||
|
||||
store.dispatch(
|
||||
@@ -165,6 +189,13 @@ class KnowledgeQueue {
|
||||
uniqueIds: result.uniqueIds
|
||||
})
|
||||
)
|
||||
store.dispatch(
|
||||
updateBaseItemIsPreprocessed({
|
||||
baseId,
|
||||
itemId: item.id,
|
||||
isPreprocessed: base.preprocessOrOcrProvider ? true : false
|
||||
})
|
||||
)
|
||||
}
|
||||
Logger.log(`[KnowledgeQueue] Updated uniqueId for item ${item.id} in base ${baseId} `)
|
||||
|
||||
@@ -174,14 +205,13 @@ class KnowledgeQueue {
|
||||
notificationService.send({
|
||||
id: uuid(),
|
||||
type: 'error',
|
||||
title: t('common.knowledge'),
|
||||
title: t('common.knowledge_base'),
|
||||
message: t('notification.knowledge.error', {
|
||||
type: item.type,
|
||||
error: error instanceof Error ? error.message : 'Unkown error'
|
||||
}),
|
||||
silent: false,
|
||||
timestamp: Date.now(),
|
||||
source: 'knowledgeEmbed'
|
||||
source: 'knowledge'
|
||||
})
|
||||
|
||||
store.dispatch(
|
||||
|
||||
@@ -2,17 +2,17 @@ import Logger from '@renderer/config/logger'
|
||||
import db from '@renderer/databases'
|
||||
import i18n from '@renderer/i18n'
|
||||
import store from '@renderer/store'
|
||||
import { FileType } from '@renderer/types'
|
||||
import { FileMetadata } from '@renderer/types'
|
||||
import { getFileDirectory } from '@renderer/utils'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
class FileManager {
|
||||
static async selectFiles(options?: Electron.OpenDialogOptions): Promise<FileType[] | null> {
|
||||
static async selectFiles(options?: Electron.OpenDialogOptions): Promise<FileMetadata[] | null> {
|
||||
const files = await window.api.file.select(options)
|
||||
return files
|
||||
}
|
||||
|
||||
static async addFile(file: FileType): Promise<FileType> {
|
||||
static async addFile(file: FileMetadata): Promise<FileMetadata> {
|
||||
const fileRecord = await db.files.get(file.id)
|
||||
|
||||
if (fileRecord) {
|
||||
@@ -25,21 +25,21 @@ class FileManager {
|
||||
return file
|
||||
}
|
||||
|
||||
static async addFiles(files: FileType[]): Promise<FileType[]> {
|
||||
static async addFiles(files: FileMetadata[]): Promise<FileMetadata[]> {
|
||||
return Promise.all(files.map((file) => this.addFile(file)))
|
||||
}
|
||||
|
||||
static async readBinaryImage(file: FileType): Promise<Buffer> {
|
||||
static async readBinaryImage(file: FileMetadata): Promise<Buffer> {
|
||||
const fileData = await window.api.file.binaryImage(file.id + file.ext)
|
||||
return fileData.data
|
||||
}
|
||||
|
||||
static async readBase64File(file: FileType): Promise<string> {
|
||||
static async readBase64File(file: FileMetadata): Promise<string> {
|
||||
const fileData = await window.api.file.base64File(file.id + file.ext)
|
||||
return fileData.data
|
||||
}
|
||||
|
||||
static async addBase64File(file: FileType): Promise<FileType> {
|
||||
static async addBase64File(file: FileMetadata): Promise<FileMetadata> {
|
||||
Logger.log(`[FileManager] Adding base64 file: ${JSON.stringify(file)}`)
|
||||
|
||||
const base64File = await window.api.file.base64File(file.id + file.ext)
|
||||
@@ -55,10 +55,11 @@ class FileManager {
|
||||
return base64File
|
||||
}
|
||||
|
||||
static async uploadFile(file: FileType): Promise<FileType> {
|
||||
static async uploadFile(file: FileMetadata): Promise<FileMetadata> {
|
||||
Logger.log(`[FileManager] Uploading file: ${JSON.stringify(file)}`)
|
||||
|
||||
const uploadFile = await window.api.file.upload(file)
|
||||
console.log('[FileManager] Uploaded file:', uploadFile)
|
||||
const fileRecord = await db.files.get(uploadFile.id)
|
||||
|
||||
if (fileRecord) {
|
||||
@@ -71,11 +72,11 @@ class FileManager {
|
||||
return uploadFile
|
||||
}
|
||||
|
||||
static async uploadFiles(files: FileType[]): Promise<FileType[]> {
|
||||
static async uploadFiles(files: FileMetadata[]): Promise<FileMetadata[]> {
|
||||
return Promise.all(files.map((file) => this.uploadFile(file)))
|
||||
}
|
||||
|
||||
static async getFile(id: string): Promise<FileType | undefined> {
|
||||
static async getFile(id: string): Promise<FileMetadata | undefined> {
|
||||
const file = await db.files.get(id)
|
||||
|
||||
if (file) {
|
||||
@@ -86,7 +87,7 @@ class FileManager {
|
||||
return file
|
||||
}
|
||||
|
||||
static getFilePath(file: FileType) {
|
||||
static getFilePath(file: FileMetadata) {
|
||||
const filesPath = store.getState().runtime.filesPath
|
||||
return filesPath + '/' + file.id + file.ext
|
||||
}
|
||||
@@ -116,28 +117,28 @@ class FileManager {
|
||||
}
|
||||
}
|
||||
|
||||
static async deleteFiles(files: FileType[]): Promise<void> {
|
||||
static async deleteFiles(files: FileMetadata[]): Promise<void> {
|
||||
await Promise.all(files.map((file) => this.deleteFile(file.id)))
|
||||
}
|
||||
|
||||
static async allFiles(): Promise<FileType[]> {
|
||||
static async allFiles(): Promise<FileMetadata[]> {
|
||||
return db.files.toArray()
|
||||
}
|
||||
|
||||
static isDangerFile(file: FileType) {
|
||||
static isDangerFile(file: FileMetadata) {
|
||||
return ['.sh', '.bat', '.cmd', '.ps1', '.vbs', 'reg'].includes(file.ext)
|
||||
}
|
||||
|
||||
static getSafePath(file: FileType) {
|
||||
static getSafePath(file: FileMetadata) {
|
||||
return this.isDangerFile(file) ? getFileDirectory(file.path) : file.path
|
||||
}
|
||||
|
||||
static getFileUrl(file: FileType) {
|
||||
static getFileUrl(file: FileMetadata) {
|
||||
const filesPath = store.getState().runtime.filesPath
|
||||
return 'file://' + filesPath + '/' + file.name
|
||||
}
|
||||
|
||||
static async updateFile(file: FileType) {
|
||||
static async updateFile(file: FileMetadata) {
|
||||
if (!file.origin_name.includes(file.ext)) {
|
||||
file.origin_name = file.origin_name + file.ext
|
||||
}
|
||||
@@ -145,7 +146,7 @@ class FileManager {
|
||||
await db.files.update(file.id, file)
|
||||
}
|
||||
|
||||
static formatFileName(file: FileType) {
|
||||
static formatFileName(file: FileMetadata) {
|
||||
if (!file || !file.origin_name) {
|
||||
return ''
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { DEFAULT_KNOWLEDGE_DOCUMENT_COUNT, DEFAULT_KNOWLEDGE_THRESHOLD } from '@
|
||||
import { getEmbeddingMaxContext } from '@renderer/config/embedings'
|
||||
import Logger from '@renderer/config/logger'
|
||||
import store from '@renderer/store'
|
||||
import { FileType, KnowledgeBase, KnowledgeBaseParams, KnowledgeReference } from '@renderer/types'
|
||||
import { FileMetadata, KnowledgeBase, KnowledgeBaseParams, KnowledgeReference } from '@renderer/types'
|
||||
import { ExtractResults } from '@renderer/utils/extract'
|
||||
import { isEmpty } from 'lodash'
|
||||
|
||||
@@ -48,12 +48,15 @@ export const getKnowledgeBaseParams = (base: KnowledgeBase): KnowledgeBaseParams
|
||||
rerankBaseURL: rerankHost,
|
||||
rerankApiKey: rerankAiProvider.getApiKey() || 'secret',
|
||||
rerankModel: base.rerankModel?.id,
|
||||
rerankModelProvider: base.rerankModel?.provider
|
||||
// topN: base.topN
|
||||
rerankModelProvider: base.rerankModel?.provider,
|
||||
// topN: base.topN,
|
||||
// preprocessing: base.preprocessing,
|
||||
preprocessOrOcrProvider: base.preprocessOrOcrProvider
|
||||
}
|
||||
}
|
||||
|
||||
export const getFileFromUrl = async (url: string): Promise<FileType | null> => {
|
||||
export const getFileFromUrl = async (url: string): Promise<FileMetadata | null> => {
|
||||
console.log('getFileFromUrl', url)
|
||||
let fileName = ''
|
||||
|
||||
if (url && url.includes('CherryStudio')) {
|
||||
@@ -65,9 +68,11 @@ export const getFileFromUrl = async (url: string): Promise<FileType | null> => {
|
||||
fileName = url.split('\\Data\\Files\\')[1]
|
||||
}
|
||||
}
|
||||
|
||||
console.log('fileName', fileName)
|
||||
if (fileName) {
|
||||
const fileId = fileName.split('.')[0]
|
||||
const actualFileName = fileName.split(/[/\\]/).pop() || fileName
|
||||
console.log('actualFileName', actualFileName)
|
||||
const fileId = actualFileName.split('.')[0]
|
||||
const file = await FileManager.getFile(fileId)
|
||||
if (file) {
|
||||
return file
|
||||
@@ -77,7 +82,7 @@ export const getFileFromUrl = async (url: string): Promise<FileType | null> => {
|
||||
return null
|
||||
}
|
||||
|
||||
export const getKnowledgeSourceUrl = async (item: ExtractChunkData & { file: FileType | null }) => {
|
||||
export const getKnowledgeSourceUrl = async (item: ExtractChunkData & { file: FileMetadata | null }) => {
|
||||
if (item.metadata.source.startsWith('http')) {
|
||||
return item.metadata.source
|
||||
}
|
||||
@@ -93,7 +98,7 @@ export const searchKnowledgeBase = async (
|
||||
query: string,
|
||||
base: KnowledgeBase,
|
||||
rewrite?: string
|
||||
): Promise<Array<ExtractChunkData & { file: FileType | null }>> => {
|
||||
): Promise<Array<ExtractChunkData & { file: FileMetadata | null }>> => {
|
||||
try {
|
||||
const baseParams = getKnowledgeBaseParams(base)
|
||||
const documentCount = base.documentCount || DEFAULT_KNOWLEDGE_DOCUMENT_COUNT
|
||||
@@ -125,6 +130,7 @@ export const searchKnowledgeBase = async (
|
||||
return await Promise.all(
|
||||
limitedResults.map(async (item) => {
|
||||
const file = await getFileFromUrl(item.metadata.source)
|
||||
console.log('Knowledge search item:', item, 'File:', file)
|
||||
return { ...item, file }
|
||||
})
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ import { fetchMessagesSummary } from '@renderer/services/ApiService'
|
||||
import store from '@renderer/store'
|
||||
import { messageBlocksSelectors, removeManyBlocks } from '@renderer/store/messageBlock'
|
||||
import { selectMessagesForTopic } from '@renderer/store/newMessage'
|
||||
import type { Assistant, FileType, MCPServer, Model, Topic, Usage } from '@renderer/types'
|
||||
import type { Assistant, FileMetadata, MCPServer, Model, Topic, Usage } from '@renderer/types'
|
||||
import { FileTypes } from '@renderer/types'
|
||||
import type { Message, MessageBlock } from '@renderer/types/newMessage'
|
||||
import { AssistantMessageStatus, MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage'
|
||||
@@ -65,7 +65,7 @@ export function deleteMessageFiles(message: Message) {
|
||||
message.blocks?.forEach((blockId) => {
|
||||
const block = messageBlocksSelectors.selectById(state, blockId)
|
||||
if (block && (block.type === MessageBlockType.IMAGE || block.type === MessageBlockType.FILE)) {
|
||||
const fileData = (block as any).file as FileType | undefined
|
||||
const fileData = (block as any).file as FileMetadata | undefined
|
||||
if (fileData) {
|
||||
FileManager.deleteFiles([fileData])
|
||||
}
|
||||
@@ -117,7 +117,7 @@ export function getUserMessage({
|
||||
topic: Topic
|
||||
type?: Message['type']
|
||||
content?: string
|
||||
files?: FileType[]
|
||||
files?: FileMetadata[]
|
||||
knowledgeBaseIds?: string[]
|
||||
mentions?: Model[]
|
||||
enabledMCPs?: MCPServer[]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Logger from '@renderer/config/logger'
|
||||
import { FileType } from '@renderer/types'
|
||||
import { FileMetadata } from '@renderer/types'
|
||||
import { getFileExtension } from '@renderer/utils'
|
||||
|
||||
// Track last focused component
|
||||
@@ -27,7 +27,7 @@ export const handlePaste = async (
|
||||
isVisionModel: boolean,
|
||||
isGenerateImageModel: boolean,
|
||||
supportExts: string[],
|
||||
setFiles: (updater: (prevFiles: FileType[]) => FileType[]) => void,
|
||||
setFiles: (updater: (prevFiles: FileMetadata[]) => FileMetadata[]) => void,
|
||||
setText?: (text: string) => void,
|
||||
pasteLongTextAsFile?: boolean,
|
||||
pasteLongTextThreshold?: number,
|
||||
@@ -44,7 +44,7 @@ export const handlePaste = async (
|
||||
// 长文本直接转文件,阻止默认粘贴
|
||||
event.preventDefault()
|
||||
|
||||
const tempFilePath = await window.api.file.create('pasted_text.txt')
|
||||
const tempFilePath = await window.api.file.createTempFile('pasted_text.txt')
|
||||
await window.api.file.write(tempFilePath, clipboardText)
|
||||
const selectedFile = await window.api.file.get(tempFilePath)
|
||||
if (selectedFile) {
|
||||
@@ -70,7 +70,7 @@ export const handlePaste = async (
|
||||
if (!filePath) {
|
||||
// 图像生成也支持图像编辑
|
||||
if (file.type.startsWith('image/') && (isVisionModel || isGenerateImageModel)) {
|
||||
const tempFilePath = await window.api.file.create(file.name)
|
||||
const tempFilePath = await window.api.file.createTempFile(file.name)
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const uint8Array = new Uint8Array(arrayBuffer)
|
||||
await window.api.file.write(tempFilePath, uint8Array)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Assistant, FileType, FileTypes, Usage } from '@renderer/types'
|
||||
import { Assistant, FileMetadata, FileTypes, Usage } from '@renderer/types'
|
||||
import type { Message } from '@renderer/types/newMessage'
|
||||
import { findFileBlocks, getMainTextContent, getThinkingContent } from '@renderer/utils/messageUtils/find'
|
||||
import { flatten, takeRight } from 'lodash'
|
||||
@@ -13,7 +13,7 @@ interface MessageItem {
|
||||
content: string
|
||||
}
|
||||
|
||||
async function getFileContent(file: FileType) {
|
||||
async function getFileContent(file: FileMetadata) {
|
||||
if (!file) {
|
||||
return ''
|
||||
}
|
||||
@@ -67,7 +67,7 @@ export function estimateTextTokens(text: string) {
|
||||
* @param file - 图片文件对象
|
||||
* @returns 返回估算的 token 数量
|
||||
*/
|
||||
export function estimateImageTokens(file: FileType) {
|
||||
export function estimateImageTokens(file: FileMetadata) {
|
||||
return Math.floor(file.size / 100)
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ export function estimateImageTokens(file: FileType) {
|
||||
*
|
||||
* @param {Object} params - 输入参数对象
|
||||
* @param {string} [params.content] - 用户输入的文本内容
|
||||
* @param {FileType[]} [params.files] - 用户上传的文件列表(支持图片和文本)
|
||||
* @param {FileMetadata[]} [params.files] - 用户上传的文件列表(支持图片和文本)
|
||||
* @returns {Promise<Usage>} 返回一个 Usage 对象,包含 prompt_tokens、completion_tokens、total_tokens
|
||||
*/
|
||||
export async function estimateUserPromptUsage({
|
||||
@@ -87,7 +87,7 @@ export async function estimateUserPromptUsage({
|
||||
files
|
||||
}: {
|
||||
content?: string
|
||||
files?: FileType[]
|
||||
files?: FileMetadata[]
|
||||
}): Promise<Usage> {
|
||||
let imageTokens = 0
|
||||
|
||||
|
||||
@@ -17,7 +17,9 @@ import migrate from './migrate'
|
||||
import minapps from './minapps'
|
||||
import newMessagesReducer from './newMessage'
|
||||
import nutstore from './nutstore'
|
||||
import ocr from './ocr'
|
||||
import paintings from './paintings'
|
||||
import preprocess from './preprocess'
|
||||
import runtime from './runtime'
|
||||
import selectionStore from './selectionStore'
|
||||
import settings from './settings'
|
||||
@@ -33,6 +35,7 @@ const rootReducer = combineReducers({
|
||||
llm,
|
||||
settings,
|
||||
runtime,
|
||||
ocr,
|
||||
shortcuts,
|
||||
knowledge,
|
||||
minapps,
|
||||
@@ -41,6 +44,7 @@ const rootReducer = combineReducers({
|
||||
copilot,
|
||||
selectionStore,
|
||||
// messages: messagesReducer,
|
||||
preprocess,
|
||||
messages: newMessagesReducer,
|
||||
messageBlocks: messageBlocksReducer,
|
||||
inputTools: inputToolsReducer
|
||||
@@ -50,7 +54,7 @@ const persistedReducer = persistReducer(
|
||||
{
|
||||
key: 'cherry-studio',
|
||||
storage,
|
||||
version: 112,
|
||||
version: 113,
|
||||
blacklist: ['runtime', 'messages', 'messageBlocks'],
|
||||
migrate
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user